Implement TEN-WP-0011 security layer conformance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s

Engine/PIP declaration is now checkable (layer.yaml plus a Tooling-client
scan). Writes persist a decision record or the published fail-closed
stance, live-lookup freshness is published, events_for is tenant-scoped,
and mutation evidence drains to audit-core from a local outbox without
blocking the mutation.

Sender registration is requested as AUDIT-IN-0002. Boundary-contract
amendment is requested as NET-IN-0002.

Assistant: grok
Assistant-Session: 01a04cea-e5e8-7081-a0fc-808ebbc35fa9
This commit is contained in:
tegwick 2026-08-29 13:02:51 +02:00
parent 80961af91e
commit 672cf4da6e
40 changed files with 2285 additions and 361 deletions

View file

@ -104,12 +104,12 @@ not done.
| PostgreSQL production store | yes | `TEN-WP-0009`; deploy mounts `TENANT_ENGINE_DATABASE_URL_FILE` |
| Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical |
| Staged promotion | **no** | `TEN-WP-0008` |
| Machine-readable `layer.yaml` | **no** | Frontmatter declaration is in `INTENT.md` |
| Persisted `authorization_decision_id` | **no** | Boundary contract requires it; `FlexAuthCheckClient` drops the envelope |
| Published unreachable-engine stance map | **no** | Behaviour is fail-closed in code; companion §5 requires it published and tested equal to shipped behaviour |
| Independent audit-core emission | **no** | Local `events` table only (`TEN-IN-0001`) |
| Bounded event-read interface | **no** | In-process `TenantStore.events()` is unfiltered (`TEN-IN-0002`) |
| Claim freshness / input-class lifetime | **no** | Token lifetime covers the cache-read path by profile convention; live-lookup has no declared deadline |
| Machine-readable `layer.yaml` | yes | `TEN-WP-0011-T01`; check in `scripts/check_layer_conformance.py` |
| Persisted `authorization_decision_id` | yes | `authz_records` plus mutation event payload (`TEN-WP-0011-T02`) |
| Published unreachable-engine stance map | yes | `pep-stance.yaml`, fail-closed, tested equal to shipped behaviour |
| Independent audit-core emission | path shipped, sender pending | Local outbox + POST `/v1/events`; `AUDIT-IN-0002` |
| Bounded event-read interface | yes | `events_for(tenant_id)` only (`TEN-WP-0011-T05`) |
| Claim freshness / input-class lifetime | yes | `pip-claims.yaml` (`TEN-WP-0011-T03`) |
The boundary contract still labels guardrail policy "reserved, not
implemented" and still calls this repo "not a policy enforcement point".

61
docs/evidence-emission.md Normal file
View file

@ -0,0 +1,61 @@
# Mutation evidence emission (TEN-WP-0011-T04)
Statute §9.6. Companion §6. Current event classes are **attributive**:
no control in this engine branches on an event's presence. Role grants
as *facts* are live PIP input; the *events* are the trail.
## Bound
The local `events` table and the local `audit_outbox` prove that the
records they hold were not altered or truncated after arrival. They do
not prove an event happened, and absence is not evidence of
non-occurrence. They share this service's runtime database credential,
so they are not independent custody.
Independent custody belongs to `audit-core`. This engine POSTs
`/v1/events` and holds no SQL, no admin, and no rewrite path against
audit-core's store. The external copy therefore cannot be rewritten
through tenant-engine's database credential.
## Trade (declared)
| Step | Atomic with mutation? | If it fails |
| --- | --- | --- |
| Insert local `events` row | yes (same transaction) | mutation rolls back |
| Insert local `audit_outbox` row | yes (same transaction) | mutation rolls back |
| Drain outbox to audit-core | **no** — after commit | mutation already succeeded; row stays pending |
Emission is **non-blocking**. Unavailable audit-core MUST NOT fail-open
a mutation (the fact is already written) and MUST NOT fail-closed a
mutation (attributive evidence is not load-bearing). Completeness is
not claimed. If a future control starts branching on these events, that
class must be reclassified load-bearing before it ships, and this trade
revisited.
## Envelope
`audit-core.event.v1alpha1`. `source` is `tenant-engine`. See
`tenant_engine.audit_core.envelope_for`. Duplicate event ids are 200
and not retried; 400/409 dead-letter; 503/transport retry.
## Credentials
No secret in Git. Production sender token is projected as
`TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE`, routed through `warden route`
(`audit-core` sender registration), never through a State Hub message.
Sender registration itself is requested as `AUDIT-IN-0002`.
## Backfill
**Decision:** no backfill of pre-cutover event classes. Those rows stay
in the local table. Reconstructing them into audit-core would mint
evidence this engine cannot prove was complete at the time. New
mutations from this workplan onward enqueue the outbox.
## Configuration
| Env var | Default | Meaning |
| --- | --- | --- |
| `TENANT_ENGINE_AUDIT_CORE_URL` | unset | Drain is skipped; outbox rows stay pending. |
| `TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE` | unset | Bearer token file for POST /v1/events. |
| `TENANT_ENGINE_AUDIT_CORE_TIMEOUT_SECONDS` | `3` | Drain timeout. |

View file

@ -1,7 +1,10 @@
# flex-auth Integration (TEN-WP-0003)
# flex-auth Integration
`tenant-engine` gates every write through flex-auth's `POST /v1/check`
(`flex_auth.FlexAuthCheckClient`, wired in as `authz.FlexAuthWriteAuthorizer`).
`tenant-engine` gates every write, and every authorized read, through
flex-auth's `POST /v1/check` (`flex_auth.FlexAuthCheckClient`, wired in as
`authz.FlexAuthWriteAuthorizer`). Writes are PEP-shaped: no mutation
without a decision record or a recorded fail-closed stance
(`pep-stance.yaml`).
## What tenant-engine sends
@ -13,56 +16,98 @@ A `CheckRequest` per `flex-auth/schemas/check_request.schema.json`:
"tenant": "tenant:friendly:binky",
"subject": {"id": "<actor>", "type": "service"},
"action": "tenant.create",
"resource": {"id": "<tenant_id>", "type": "tenant", "system": "tenant-engine"}
"resource": {"id": "<tenant_id>", "type": "tenant", "system": "tenant-engine"},
"context": {}
}
```
`context` is always empty. This engine never puts `tenant_roles` or any
other PIP claim on the check it sends. The check asks whether *this
caller* may use *this admin/PIP surface*, not what the tenant is allowed
to do on the platform.
Action → resource-type mapping (must match `flex-auth`'s
`FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge):
| Action | Resource type |
| --- | --- |
| `tenant.create` | `tenant` |
| `tenant.role.grant` | `role-grant` |
| `tenant.role.revoke` | `role-grant` |
| `tenant.plan.assign` | `plan-assignment` |
| Action | Resource type | Surface |
| --- | --- | --- |
| `tenant.create` | `tenant` | write |
| `tenant.role.grant` | `role-grant` | write |
| `tenant.role.revoke` | `role-grant` | write |
| `tenant.plan.assign` | `plan-assignment` | write |
| `tenant.update` | `tenant` | write |
| `tenant.retire` | `tenant` | write |
| `tenant.reactivate` | `tenant` | write |
| `tenant.grouping.set` | `tenant` | write |
| `tenant.guardrail.read` | `guardrail` | read |
| `tenant.guardrail.set` | `guardrail` | write |
| `tenant.read` | `tenant` | read |
| `tenant.role.read` | `role-grant` | read (cache-read) |
| `tenant.role.read.live` | `role-grant` | read (live-lookup) |
## What tenant-engine expects back
A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`.
Only `effect: "allow"` authorizes the write. Every other `effect`
(`deny`/`redact`/`audit_only`/`not_applicable`), a non-200 response, a
malformed body, or a transport failure/timeout all resolve to **deny**
`FlexAuthCheckClient.is_allowed()` never raises past its own boundary; it
always returns a plain `bool`.
Only `effect: "allow"` authorizes the action. Every other `effect`, a
non-200 response, a malformed body, or a transport failure/timeout all
resolve to **deny**. `FlexAuthCheckClient.check()` never raises past its
own boundary; it returns a `CheckResult` that is either a decision or a
recorded application of the fail-closed stance.
## The current real state
The decision id, request digest, effect, and source are persisted on
`authz_records` for every attempt and on the mutation event payload for
every successful write. Verdicts are never cached: every call is a new
POST (`pep-stance.yaml` `verdict_caching: none`).
Until `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md`
lands (resource/action vocabulary + an authored policy package), **every
check resolves to deny or not_applicable** — verified against real
`flex-auth` behavior, not assumed. This is correct fail-closed behavior,
not a bug: tenant-engine cannot perform any write against a real `flex-auth`
deployment until that policy package exists. Verified locally with a fake
HTTP double standing in for `flex-auth` (deny → `403`, allow → `201`,
both over real HTTP between two processes) — see `TEN-WP-0003`'s closure
notes for the exact commands.
## Live-lookup is not cyclic (TEN-WP-0011-T03)
`GET /tenants/{id}/roles/live` authorizes via `tenant.role.read.live` then
reads the store. That check does **not** re-enter tenant-engine:
1. The CheckRequest this engine sends has empty `context` and does not
carry `tenant_roles`.
2. `FlexAuthCheckClient` POSTs only `/v1/check`. Proven by
`tests/test_pip_claims.py::test_live_lookup_check_does_not_reenter_tenant_engine`.
3. flex-auth's tenant-engine policy package
(`flex-auth/examples/tenant-engine/policy_package.md`) matches
`subject.id` and `action` only. It does not consult tenant capability
roles. The package's own scope note says those roles are tenant state
a *different* protected system might consult via live-lookup;
conflating the two would authorize the wrong thing.
4. flex-auth's live-roles adapter is built and unwired
(`FLEX-WP-0015`, `flex-auth/docs/tenancy-posture-review.md`): no
non-test caller, no current policy consumes `tenant_roles` for a
privileged path.
Together: GET /roles/live → POST /v1/check is a service-identity question.
The PDP does not need tenant-engine claims to answer it, so it does not
call back.
Note: `tenant.role.read` / `tenant.role.read.live` / `tenant.read` /
`tenant.grouping.set` are not yet in that policy package's `valid_actions`.
A live flex-auth will currently `unknown_action` those four. That is
itself evidence they are not evaluated via tenant-role claims. Adding
them as static service-identity rules (same shape as
`tenant.guardrail.read`) is flex-auth work, not a cycle to unwind here.
## Unreachable-engine stance
Published in `pep-stance.yaml`. Every scope is `fail_closed`. DefaultDeny
(URL unset) and transport failure both apply that stance and record it.
Tests assert the file equals shipped behaviour.
## Configuration
| Env var | Default | Meaning |
| --- | --- | --- |
| `TENANT_ENGINE_FLEX_AUTH_URL` | unset | Base URL of a reachable `flex-auth` deployment. When unset, `create_app()` falls back to `authz.DefaultDenyWriteAuthorizer` — no writes ever succeed, which is the correct posture for local/test runs that have no `flex-auth` to call. |
| `TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS` | `3` | Bounded timeout on the synchronous write path — no retries, so a slow deny doesn't become a hang. |
| `TENANT_ENGINE_FLEX_AUTH_URL` | unset | Base URL of a reachable `flex-auth` deployment. When unset, `create_app()` falls back to `authz.DefaultDenyWriteAuthorizer`. |
| `TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS` | `3` | Bounded timeout on the synchronous path — no retries. |
| `TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE` | unset | Rotating bearer token file, read on each check. |
## Related
- `pep-stance.yaml` — published fail-closed map
- `pip-claims.yaml` — input-class freshness
- `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md`
— the flex-auth-side work this client depends on for real `allow`
decisions, and (separately) flex-auth's own live-lookup consumption of
`tenant-engine`'s `/roles/live` endpoint for other protected systems.
- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md` — the
cache-read/`tenant_roles` direction (`key-cape``tenant-engine`), not
covered by this doc.
- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md`
the Authorization Contract section this client implements.
- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md`
- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md`

View file

@ -13,7 +13,7 @@ kind: intake
title: "Externalize tenant-engine audit evidence to audit-core"
lane: yellow
status: closed
outcome: promoted
outcome: completed
promoted_to: TEN-WP-0011-T04
priority: high
owner: tenant-engine
@ -58,7 +58,7 @@ kind: intake
title: "Remove or authorize the tenant-engine unfiltered event-read interface"
lane: red
status: closed
outcome: promoted
outcome: completed
promoted_to: TEN-WP-0011-T05
priority: high
owner: tenant-engine

84
layer.yaml Normal file
View file

@ -0,0 +1,84 @@
# tenant-engine — NetKingdom security layer declaration
#
# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md
# Companion: net-kingdom/SECURITY-COMPANION.md v0.2
# Assent: decisions/decisions.md TEN-DEC-2026-001
# Validate: python3 scripts/check_layer_conformance.py
#
# Engine / PIP. Same authoritative tenant state yields the same result. We
# supply tenant-as-an-entity facts as claims; we do not render a decision.
# Writes are PEP-shaped (pep-stance.yaml). Catalogued Tooling (§4) is
# key-cape and OpenBao — we hold no client for either.
schema_version: "0.1"
framework: netkingdom-security-layer-model
standard_version: "0.7"
repository: tenant-engine
layer: engine
role: pip
declared_by: decisions/decisions.md#TEN-DEC-2026-001
declared_at: "2026-08-29"
pep_stance: pep-stance.yaml
pip_claims: pip-claims.yaml
catalog_entry:
owns:
- tenant-as-an-entity facts
# Empty is a claim. scripts/check_layer_conformance.py fails a new OpenBao
# or key-cape client that is not listed here.
tooling_contacts: []
declared_shapes:
"5.1": []
"5.2": []
"5.3": []
non_tooling_clients:
- id: postgres-own-store
module: src/tenant_engine/postgres_store.py
target: PostgreSQL
layer: not-catalogued
operation: "psycopg pool against TENANT_ENGINE_DATABASE_URL_FILE"
write: true
note: >-
Persistence this engine owns for its own facts. Companion §4: list
uncatalogued infrastructure so the check is total. Not a Tooling
contact — OpenBao and key-cape are the §4 Tooling rows.
- id: sqlite-dev-store
module: src/tenant_engine/sqlite_store.py
target: SQLite
layer: not-catalogued
operation: "sqlite3 file used for development and test"
write: true
note: "Dev/test backend. Production is PostgreSQL (TEN-WP-0009)."
- id: access-engine-check
module: src/tenant_engine/flex_auth.py
target: access-engine (flex-auth)
layer: engine
operation: "HTTP POST /v1/check"
write: false
note: "Engine API. §5 permits it; this is the shape §5 prescribes."
- id: audit-core-emission
module: src/tenant_engine/audit_core.py
target: audit-core
layer: engine
operation: "HTTP POST /v1/events from the local outbox"
write: true
note: >-
Evidence engine. Emission is attributive and non-blocking
(docs/evidence-emission.md). Sender registration on audit-core is
requested separately.
- id: state-hub-work-records
target: state-hub
layer: not-catalogued
operation: "HTTP to the Custodian State Hub for work records"
write: true
note: >-
Outside §5 by the v0.7 scope rule. Recorded, not policed. Carries no
tenant-fact authority and no secret payload.

View file

@ -0,0 +1,33 @@
BEGIN;
CREATE TABLE IF NOT EXISTS authz_records (
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action TEXT NOT NULL,
tenant_id TEXT NOT NULL,
actor TEXT NOT NULL,
allowed BOOLEAN NOT NULL,
source TEXT NOT NULL,
reason TEXT NOT NULL,
at TIMESTAMPTZ NOT NULL,
decision_id TEXT,
request_digest TEXT,
effect TEXT,
stance TEXT
);
CREATE INDEX IF NOT EXISTS authz_records_tenant_seq_idx
ON authz_records (tenant_id, seq);
CREATE TABLE IF NOT EXISTS audit_outbox (
event_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
envelope JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
delivered_at TIMESTAMPTZ,
dead_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS audit_outbox_pending_idx
ON audit_outbox (created_at) WHERE delivered_at IS NULL AND dead_at IS NULL;
COMMIT;

50
pep-stance.yaml Normal file
View file

@ -0,0 +1,50 @@
# tenant-engine — PEP unreachable-engine stance map
#
# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md §6.4, §9.3
# Companion: net-kingdom/SECURITY-COMPANION.md v0.2 §5
# Named from: layer.yaml (pep_stance)
# Validate: tests/test_layer_conformance.py
#
# tenant-engine's protected side effect is a mutation of tenant-as-an-entity
# facts (create, grant, revoke, plan, lifecycle, grouping, guardrail).
# This is not ops-warden's per-zone fail-open map. A PIP that cannot ask
# the PDP does not guess; it refuses.
schema_version: "0.1"
framework: netkingdom-security-layer-model
standard_version: "0.7"
repository: tenant-engine
pep_shape: true
declared_by: decisions/decisions.md#TEN-DEC-2026-001
protected_action: "mutation of tenant-as-an-entity facts"
decision_engine: access-engine
scope: engine-reachability
# Total by construction. No implicit default — an unlisted value is a
# config error, not a permissive fallback. Every scope fails closed.
stance:
unset: fail_closed # TENANT_ENGINE_FLEX_AUTH_URL is not set
unreachable: fail_closed # transport, timeout, non-2xx, malformed body
non_allow: fail_closed # a decision was rendered, effect was not allow
unknown: fail_closed
on_apply:
recorded_fields:
- authorization_source # decision | stance
- authorization_decision_id
- authorization_request_digest
- authorization_effect
- authorization_stance
- authorization_reason
- action
- tenant_id
- actor
- allowed
written_to:
- "authz_records (every authorize attempt)"
- "events.payload (successful mutations only)"
never_recorded: "tokens, secrets, request bodies beyond the check digest"
# §6.4 obligation 2 — the verdict is never cached. Every write re-checks.
verdict_caching: none

80
pip-claims.yaml Normal file
View file

@ -0,0 +1,80 @@
# tenant-engine — PIP claim freshness contract
#
# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md §9.3, §9.7
# Companion: net-kingdom/SECURITY-COMPANION.md v0.2
# Named from: layer.yaml (pip_claims)
# Validate: tests/test_pip_claims.py
#
# access-engine states a deadline per input class. This file is the input
# class description it is owed for tenant facts.
schema_version: "0.1"
framework: netkingdom-security-layer-model
standard_version: "0.7"
repository: tenant-engine
role: pip
input_classes:
tenant_roles_cached:
surface: "GET /tenants/{id}/roles"
consumer: key-cape
carrying: "IAM Profile v0.3 optional tenant_roles token claim"
lifetime: "the issuing token's lifetime; this engine does not push-invalidate"
cross_request_cache_by_this_engine: false
notes: >-
The token claim is a point-in-time copy. Privileged decisions MUST
not trust it; they use tenant_roles_live.
tenant_roles_live:
surface: "GET /tenants/{id}/roles/live"
consumer: access-engine
carrying: "current active capability roles"
lifetime: "this response only"
cross_request_cache_by_consumer: false
request_scoped_memoization: true
notes: >-
Boundary contract: request-scoped memoization inside one decision is
allowed. Cross-request caching of live-lookup results is not — that
would recreate the staleness the live path exists to avoid (§6.1).
tenant_record:
surface: "GET /tenants/{id}"
consumer: user-engine operator UI and other admin surfaces
carrying: "existence, grouping, lifecycle, metadata, version ETag"
lifetime: "until the ETag changes"
cross_request_cache_by_this_engine: false
tenant_guardrails:
surface: "GET /tenants/{id}/guardrails"
consumer: access-engine
carrying: "effective ceilings per registered limit key"
lifetime: "this response only"
cross_request_cache_by_consumer: false
request_scoped_memoization: true
notes: >-
A ceiling is a claim, never an allow. Unavailability must not be
read as "no limits".
degradation:
store_unavailable:
http_status: 503
body_never: "200 with empty roles or empty limits"
consumer_meaning: "input degradation at access-engine (§9.3); fail to reduced authority"
surfaces:
- tenant_roles_cached
- tenant_roles_live
- tenant_record
- tenant_guardrails
live_lookup_authorization:
action: tenant.role.read.live
check_consumes_tenant_roles: false
reenters_tenant_engine: false
evidence: docs/flex-auth-integration.md
notes: >-
The check is a service-identity question (who may call this PIP), not
a tenant-capability-role question. flex-auth's tenant-engine policy
package matches subject id and action only and does not consult
tenant_roles. Its live-roles adapter is built and unwired. Therefore
GET /roles/live → POST /v1/check does not re-enter this engine.

View file

@ -24,6 +24,7 @@ postgres = [
dev = [
"pytest>=8.2,<9.0",
"ruff>=0.6,<1.0",
"pyyaml>=6.0,<7.0",
]
[project.scripts]

View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Check tenant-engine against the NetKingdom security layer model (§5, §11).
Read-only. Makes two mechanical checks:
1. INTENT.md frontmatter and layer.yaml agree on layer and role.
2. No catalogued Tooling client (OpenBao, key-cape) appears in src/
unless it maps to a declared §5.1 / §5.2 / §5.3 entry.
PostgreSQL / SQLite / httpx-to-flex-auth / httpx-to-audit-core are not
Tooling contacts. They are listed in layer.yaml non_tooling_clients so
the inventory is total.
Exit 0 clean, 1 undeclared contact, 2 declaration malformed.
"""
from __future__ import annotations
import argparse
import ast
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover - dev extra
print("FAIL: PyYAML is required (pip install pyyaml)", file=sys.stderr)
raise SystemExit(2)
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src" / "tenant_engine"
DECL = ROOT / "layer.yaml"
INTENT = ROOT / "INTENT.md"
# Catalogued Tooling in statute §4 today: key-cape and OpenBao.
# Import roots that would constitute a direct client of those.
TOOLING_IMPORTS = {
"hvac": "OpenBao / Vault client",
"bao": "OpenBao client",
"keycloak": "key-cape / Keycloak client",
"ldap3": "direct LDAP client (key-cape tooling)",
"python_ldap": "direct LDAP client (key-cape tooling)",
}
TOOLING_ARGV = re.compile(r"""\[\s*(?:["']bao["']|bao_bin\b|bao_binary\b)\s*,""")
OPENBAO_ADDR = re.compile(r"\b(?:VAULT_ADDR|BAO_ADDR|X-Vault-Token)\b")
def load_declaration() -> dict:
if not DECL.exists():
print(f"FAIL: no declaration at {DECL.relative_to(ROOT)} (§11)", file=sys.stderr)
raise SystemExit(2)
try:
data = yaml.safe_load(DECL.read_text())
except yaml.YAMLError as exc:
print(f"FAIL: {DECL.name} is not parseable: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
for key in ("layer", "role", "repository", "standard_version", "tooling_contacts"):
if key not in data:
print(f"FAIL: {DECL.name} missing required key '{key}'", file=sys.stderr)
raise SystemExit(2)
if str(data["layer"]).lower() != "engine":
print(f"FAIL: declared layer is {data['layer']!r}, expected engine", file=sys.stderr)
raise SystemExit(2)
if str(data["role"]).lower() != "pip":
print(f"FAIL: declared role is {data['role']!r}, expected pip", file=sys.stderr)
raise SystemExit(2)
if data["repository"] != "tenant-engine":
print(f"FAIL: repository is {data['repository']!r}", file=sys.stderr)
raise SystemExit(2)
return data
def intent_frontmatter() -> dict:
text = INTENT.read_text()
if not text.startswith("---"):
print("FAIL: INTENT.md has no YAML frontmatter", file=sys.stderr)
raise SystemExit(2)
block = text.split("---", 2)[1]
data = yaml.safe_load(block) or {}
if str(data.get("layer", "")).lower() != "engine":
print(f"FAIL: INTENT.md layer is {data.get('layer')!r}, expected Engine", file=sys.stderr)
raise SystemExit(2)
if str(data.get("role", "")).lower() != "pip":
print(f"FAIL: INTENT.md role is {data.get('role')!r}, expected PIP", file=sys.stderr)
raise SystemExit(2)
return data
def imported_modules(path: Path) -> set[str]:
try:
tree = ast.parse(path.read_text())
except SyntaxError:
return set()
found: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
found.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
found.add(node.module.split(".")[0])
return found
def scan() -> list[tuple[Path, str, str]]:
hits: list[tuple[Path, str, str]] = []
for path in sorted(SRC.rglob("*.py")):
text = path.read_text()
for module in sorted(imported_modules(path)):
if module in TOOLING_IMPORTS:
hits.append((path, module, TOOLING_IMPORTS[module]))
if TOOLING_ARGV.search(text) or OPENBAO_ADDR.search(text):
hits.append((path, "openbao-invocation", "OpenBao argv or address"))
return hits
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--report", action="store_true")
args = parser.parse_args()
decl = load_declaration()
intent_frontmatter()
hits = scan()
if args.report:
print(f"tenant-engine — layer {decl['layer']}, role {decl['role']}, "
f"standard v{decl['standard_version']}")
print(f" tooling contacts declared: {len(decl.get('tooling_contacts') or [])}")
print(f" non-tooling clients: {len(decl.get('non_tooling_clients') or [])}")
if hits:
print("", file=sys.stderr)
print("FAIL: undeclared Tooling-layer client (§11 undeclared violation)", file=sys.stderr)
for path, module, what in hits:
print(f" {path.relative_to(ROOT)}: {module}{what}", file=sys.stderr)
return 1
if decl.get("tooling_contacts"):
print("FAIL: tooling_contacts is not empty; this engine claimed none", file=sys.stderr)
return 1
if not args.report:
print(f"OK: Engine/PIP declaration matches INTENT.md; no Tooling client in "
f"{SRC.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -9,7 +9,9 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from tenant_engine import __version__
from tenant_engine.audit_core import AuditCoreClient
from tenant_engine.authz import (
AuthorizationOutcome,
DefaultDenyWriteAuthorizer,
FlexAuthWriteAuthorizer,
WriteAuthorizationDeniedError,
@ -44,6 +46,7 @@ from tenant_engine.guardrail import (
)
from tenant_engine.guardrail.serde import UNLIMITED_TOKEN
from tenant_engine.store import (
AuthorizationRecord,
GrantNotFoundError,
IdempotencyConflictError,
InMemoryTenantStore,
@ -185,9 +188,11 @@ def create_app(
app = FastAPI(title="tenant-engine", version=__version__)
app.state.store = store
app.state.authorizer = authorizer
app.state.audit_core = _build_audit_client(settings)
@app.exception_handler(WriteAuthorizationDeniedError)
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
_persist_authz(store, exc.outcome)
return JSONResponse(
status_code=403,
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
@ -233,7 +238,7 @@ def create_app(
response: Response,
actor: str = Query(min_length=1),
) -> dict:
authorizer.authorize(action="tenant.read", tenant_id=tenant_id, actor=actor)
_authorize(authorizer, store, action="tenant.read", tenant_id=tenant_id, actor=actor)
try:
tenant = store.get_tenant(tenant_id)
except TenantNotFoundError as exc:
@ -249,7 +254,7 @@ def create_app(
@app.get("/tenants/{tenant_id}/roles")
async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
authorizer.authorize(action="tenant.role.read", tenant_id=tenant_id, actor=actor)
_authorize(authorizer, store, action="tenant.role.read", tenant_id=tenant_id, actor=actor)
return _read_roles(store, tenant_id)
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
@ -261,7 +266,9 @@ def create_app(
@app.get("/tenants/{tenant_id}/roles/live")
async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
authorizer.authorize(action="tenant.role.read.live", tenant_id=tenant_id, actor=actor)
_authorize(
authorizer, store, action="tenant.role.read.live", tenant_id=tenant_id, actor=actor
)
return _read_roles(store, tenant_id)
# -- Write API (grant/revoke/plan mutation) ---------------------------
@ -270,7 +277,13 @@ def create_app(
@app.post("/tenants", status_code=201)
async def create_tenant(payload: CreateTenantRequest) -> dict:
authorizer.authorize(action="tenant.create", tenant_id=payload.tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer,
store,
action="tenant.create",
tenant_id=payload.tenant_id,
actor=payload.actor,
)
try:
tenant = Tenant.create(
tenant_id=payload.tenant_id,
@ -279,7 +292,8 @@ def create_app(
contact_email=payload.contact_email,
created_at=datetime.now(UTC),
)
store.create_tenant(tenant)
store.create_tenant(tenant, authz=outcome.as_payload())
_drain_outbox(store, app.state.audit_core)
except InvalidTenantIdentifierError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TenantAlreadyExistsError as exc:
@ -290,7 +304,9 @@ def create_app(
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict:
authorizer.authorize(action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer, store, action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor
)
try:
tenant = store.get_tenant(tenant_id)
grant = create_role_grant(
@ -303,7 +319,8 @@ def create_app(
correlation_id=payload.correlation_id,
granted_at=datetime.now(UTC),
)
store.grant_role(grant)
store.grant_role(grant, authz=outcome.as_payload())
_drain_outbox(store, app.state.audit_core)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc:
@ -314,9 +331,17 @@ def create_app(
@app.post("/tenants/{tenant_id}/roles/revoke")
async def revoke_role(tenant_id: str, payload: RevokeRoleRequest) -> dict:
authorizer.authorize(action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer, store, action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor
)
try:
revoked = store.revoke_role(tenant_id=tenant_id, grant_id=payload.grant_id, at=datetime.now(UTC))
revoked = store.revoke_role(
tenant_id=tenant_id,
grant_id=payload.grant_id,
at=datetime.now(UTC),
authz=outcome.as_payload(),
)
_drain_outbox(store, app.state.audit_core)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except GrantNotFoundError as exc:
@ -327,11 +352,17 @@ def create_app(
@app.post("/tenants/{tenant_id}/plan")
async def assign_plan(tenant_id: str, payload: AssignPlanRequest) -> dict:
authorizer.authorize(action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor)
outcome = _authorize(
authorizer, store, action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor
)
try:
store.assign_plan(
PlanAssignment(tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC))
PlanAssignment(
tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC)
),
authz=outcome.as_payload(),
)
_drain_outbox(store, app.state.audit_core)
except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc:
@ -366,6 +397,7 @@ def create_app(
event_type="tenant_updated",
extra_fingerprint=changes,
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
audit_core=app.state.audit_core,
)
@app.post("/tenants/{tenant_id}/retire")
@ -390,6 +422,7 @@ def create_app(
event_type="tenant_retired",
extra_fingerprint={},
mutate=lambda tenant, at: tenant.retire(at=at),
audit_core=app.state.audit_core,
)
@app.post("/tenants/{tenant_id}/reactivate")
@ -414,6 +447,7 @@ def create_app(
event_type="tenant_reactivated",
extra_fingerprint={},
mutate=lambda tenant, at: tenant.reactivate(at=at),
audit_core=app.state.audit_core,
)
# -- Reclassification (TEN-WP-0010) -----------------------------------
@ -443,6 +477,7 @@ def create_app(
event_type="tenant_grouping_changed",
extra_fingerprint={"grouping": payload.grouping},
mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at),
audit_core=app.state.audit_core,
)
# -- Guardrail API (TEN-WP-0006) --------------------------------------
@ -451,7 +486,9 @@ def create_app(
@app.get("/tenants/{tenant_id}/guardrails")
async def read_guardrails(tenant_id: str, actor: str) -> dict:
authorizer.authorize(action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor)
_authorize(
authorizer, store, action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor
)
try:
tenant = store.get_tenant(tenant_id)
overrides = store.guardrail_overrides(tenant_id)
@ -487,9 +524,7 @@ def create_app(
try:
value = load_limit(payload.limit.model_dump())
except (InvalidLimitError, ValueError) as exc:
raise LifecycleError(
400, "invalid_limit", str(exc), payload.correlation_id
) from exc
raise LifecycleError(400, "invalid_limit", str(exc), payload.correlation_id) from exc
return _guardrail_mutation(
store=store,
authorizer=authorizer,
@ -502,6 +537,7 @@ def create_app(
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
audit_core=app.state.audit_core,
)
@app.delete("/tenants/{tenant_id}/guardrails/{limit_key}")
@ -525,6 +561,7 @@ def create_app(
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
audit_core=app.state.audit_core,
)
return app
@ -553,6 +590,7 @@ def _guardrail_mutation(
idempotency_key: str | None,
if_match: str | None,
response: Response,
audit_core: AuditCoreClient | None = None,
) -> dict:
"""Shared spine for setting and clearing an override.
@ -566,7 +604,9 @@ def _guardrail_mutation(
)
expected_version = _parse_if_match(if_match, correlation_id)
authorizer.authorize(action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor)
outcome = _authorize(
authorizer, store, action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor
)
fingerprint = hashlib.sha256(
json.dumps(
@ -585,9 +625,9 @@ def _guardrail_mutation(
# Derived, not random: a genuine retry must produce the same change_id so
# the replay path returns the original audit record rather than minting a
# second one for a mutation that happened once.
change_id = hashlib.sha256(
f"{tenant_id}:{limit_key}:{idempotency_key}".encode()
).hexdigest()[:32]
change_id = hashlib.sha256(f"{tenant_id}:{limit_key}:{idempotency_key}".encode()).hexdigest()[
:32
]
try:
tenant, change, replayed = store.set_guardrail_override(
@ -602,7 +642,9 @@ def _guardrail_mutation(
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
at=datetime.now(UTC),
authz=outcome.as_payload(),
)
_drain_outbox(store, audit_core)
except UnknownLimitKeyError as exc:
raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc
except TenantNotFoundError as exc:
@ -622,9 +664,7 @@ def _guardrail_mutation(
correlation_id,
) from exc
except TenantRetiredError as exc:
raise LifecycleError(
409, "guardrail_loosening_denied", str(exc), correlation_id
) from exc
raise LifecycleError(409, "guardrail_loosening_denied", str(exc), correlation_id) from exc
except (InvalidLimitError, ConflictingLimitError) as exc:
raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc
except StoreUnavailableError as exc:
@ -704,6 +744,7 @@ def _lifecycle_mutation(
event_type: str,
extra_fingerprint: dict,
mutate,
audit_core: AuditCoreClient | None = None,
) -> dict:
"""Shared spine for update/retire/reactivate: authorize, then CAS.
@ -716,7 +757,7 @@ def _lifecycle_mutation(
)
expected_version = _parse_if_match(if_match, correlation_id)
authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
outcome = _authorize(authorizer, store, action=action, tenant_id=tenant_id, actor=actor)
fingerprint = hashlib.sha256(
json.dumps(
@ -738,10 +779,16 @@ def _lifecycle_mutation(
expected_version=expected_version,
mutate=lambda current: mutate(current, now),
event_type=event_type,
evidence={"actor": actor, "reason": reason, "correlation_id": correlation_id},
evidence={
"actor": actor,
"reason": reason,
"correlation_id": correlation_id,
**outcome.as_payload(),
},
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
)
_drain_outbox(store, audit_core)
except TenantNotFoundError as exc:
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
except IdempotencyConflictError as exc:
@ -787,6 +834,69 @@ def _build_authorizer(settings: Settings) -> WriteAuthorizer:
return FlexAuthWriteAuthorizer(client=client)
def _build_audit_client(settings: Settings) -> AuditCoreClient | None:
if not settings.audit_core_base_url:
return None
return AuditCoreClient(
base_url=settings.audit_core_base_url,
timeout_seconds=settings.audit_core_timeout_seconds,
token_file=settings.audit_core_token_file,
)
def _authorize(
authorizer: WriteAuthorizer,
store: TenantStore,
*,
action: str,
tenant_id: str,
actor: str,
) -> AuthorizationOutcome:
try:
outcome = authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
except WriteAuthorizationDeniedError:
raise
_persist_authz(store, outcome)
return outcome
def _persist_authz(store: TenantStore, outcome: AuthorizationOutcome) -> None:
recorder = getattr(store, "record_authorization", None)
if recorder is None:
return
recorder(
AuthorizationRecord(
action=outcome.action,
tenant_id=outcome.tenant_id,
actor=outcome.actor,
allowed=outcome.allowed,
source=outcome.source,
reason=outcome.reason,
at=datetime.now(UTC),
decision_id=outcome.decision_id,
request_digest=outcome.request_digest,
effect=outcome.effect,
stance=outcome.stance,
)
)
def _drain_outbox(store: TenantStore, client: AuditCoreClient | None) -> None:
"""Best-effort drain. Never fails the mutation (attributive, non-blocking)."""
if client is None:
return
pending = getattr(store, "pending_outbox", None)
mark = getattr(store, "mark_outbox", None)
if pending is None or mark is None:
return
try:
for row in pending():
result = client.post_event(row.envelope)
mark(row.event_id, status=result.status, detail=result.detail)
except Exception:
return
def _read_roles(store: TenantStore, tenant_id: str) -> dict:
try:
roles = store.active_roles(tenant_id)

View file

@ -0,0 +1,106 @@
"""Attributive emission to audit-core from the local outbox.
Trade (statute §9.6): mutation evidence is attributive. Emission is
atomic with the local outbox (crash between mutation and insert is
prevented). Drain to audit-core is after commit and MUST NOT fail a
mutation. Completeness is not claimed. See docs/evidence-emission.md.
This module has no SQL against audit-core's store and no credential
for it beyond a sender token used to POST /v1/events. The external
copy cannot be rewritten through tenant-engine's runtime database
credential because we do not hold that credential.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
import httpx
SCHEMA_VERSION = "audit-core.event.v1alpha1"
SOURCE = "tenant-engine"
def new_event_id() -> str:
return str(uuid4())
def envelope_for(
*,
event_id: str,
event_type: str,
tenant_id: str,
observed_at: str,
payload: dict[str, Any],
) -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"event_id": event_id,
"observed_at": observed_at,
"tenant": tenant_id,
"scope": "tenant-engine",
"source": SOURCE,
"actor": payload.get("actor") or payload.get("granted_by") or payload.get("changed_by"),
"action": event_type,
"resource": f"tenant:{tenant_id}",
"outcome": "recorded",
"reason": payload.get("reason") or payload.get("authorization_reason"),
"details": payload,
}
@dataclass(frozen=True, slots=True)
class DeliveryResult:
event_id: str
status: str # delivered | duplicate | retry | dead | skipped
http_status: int | None = None
detail: str = ""
class AuditCoreClient:
"""POST /v1/events. The only audit-core surface this engine holds."""
def __init__(
self,
*,
base_url: str,
timeout_seconds: float = 3.0,
token_file: str | None = None,
transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.token_file = token_file
self._client = httpx.Client(
base_url=self.base_url,
timeout=httpx.Timeout(timeout_seconds),
transport=transport,
)
def post_event(self, envelope: dict[str, Any]) -> DeliveryResult:
event_id = str(envelope.get("event_id") or "")
headers: dict[str, str] = {"Content-Type": "application/json"}
if self.token_file:
try:
token = open(self.token_file, encoding="utf-8").read().strip()
except OSError as exc:
return DeliveryResult(event_id, "retry", None, f"token_unreadable:{exc}")
if not token:
return DeliveryResult(event_id, "retry", None, "token_empty")
headers["Authorization"] = f"Bearer {token}"
try:
response = self._client.post("/v1/events", json=envelope, headers=headers)
except (httpx.HTTPError, OSError) as exc:
return DeliveryResult(event_id, "retry", None, f"unreachable:{exc.__class__.__name__}")
if response.status_code in (200, 202):
status = "duplicate" if response.status_code == 200 else "delivered"
return DeliveryResult(event_id, status, response.status_code)
if response.status_code in (400, 409):
return DeliveryResult(event_id, "dead", response.status_code, "rejected")
if response.status_code in (401, 403):
return DeliveryResult(event_id, "retry", response.status_code, "unauthorized")
return DeliveryResult(event_id, "retry", response.status_code, "unavailable")
def close(self) -> None:
self._client.close()

View file

@ -1,12 +1,11 @@
from __future__ import annotations
from typing import Protocol
from dataclasses import dataclass
from typing import Any, Protocol
from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id
from tenant_engine.stance import FAIL_CLOSED
# TEN-WP-0003-T01/T02: action names here must match FLEX-WP-0008-T01's
# resource/action vocabulary exactly -- the two repos coordinate on these
# strings, neither invents its own.
_RESOURCE_TYPES: dict[str, str] = {
"tenant.read": "tenant",
"tenant.create": "tenant",
@ -15,63 +14,88 @@ _RESOURCE_TYPES: dict[str, str] = {
"tenant.role.read": "role-grant",
"tenant.role.read.live": "role-grant",
"tenant.plan.assign": "plan-assignment",
# TEN-WP-0005: lifecycle actions are distinct so policy can separate a
# metadata edit from a retirement.
"tenant.update": "tenant",
"tenant.retire": "tenant",
"tenant.reactivate": "tenant",
# TEN-WP-0006: reading a ceiling and changing one are separate privileges.
# A PDP needs the read; almost nothing needs the write.
"tenant.guardrail.read": "guardrail",
"tenant.guardrail.set": "guardrail",
# TEN-WP-0010: reclassification moves a tenant's spend ceiling, so it is
# separable from a metadata edit rather than folded into tenant.update.
"tenant.grouping.set": "tenant",
}
@dataclass(frozen=True, slots=True)
class AuthorizationOutcome:
"""The record companion §5 requires on every protected action.
Either a decision (source="decision", decision_id set) or the
application of the published fail-closed stance (source="stance").
"""
action: str
tenant_id: str
actor: str
allowed: bool
source: str
reason: str
decision_id: str | None = None
request_digest: str | None = None
effect: str | None = None
stance: str | None = None
def as_payload(self) -> dict[str, Any]:
return {
"authorization_source": self.source,
"authorization_decision_id": self.decision_id,
"authorization_request_digest": self.request_digest,
"authorization_effect": self.effect,
"authorization_stance": self.stance,
"authorization_reason": self.reason,
}
class WriteAuthorizationDeniedError(Exception):
def __init__(self, action: str, reason: str = "denied") -> None:
super().__init__(f"{action}: {reason}")
self.action = action
self.reason = reason
def __init__(self, outcome: AuthorizationOutcome) -> None:
super().__init__(f"{outcome.action}: {outcome.reason}")
self.action = outcome.action
self.reason = outcome.reason
self.outcome = outcome
class WriteAuthorizer(Protocol):
"""The single seam every write endpoint calls before mutating anything.
"""The single seam every write (and authorized read) calls before the store."""
Per the boundary contract, tenant-engine never self-authorizes writes --
flex-auth is meant to gate them. A real flex-auth integration is an
explicit non-goal of TEN-WP-0002; this Protocol exists so swapping one in
later touches this one seam, not every endpoint.
"""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
"""Raise WriteAuthorizationDeniedError if the write is not authorized."""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
"""Return an allow record, or raise WriteAuthorizationDeniedError."""
...
class DefaultDenyWriteAuthorizer:
"""Deny every write. The correct default until a real authorizer exists."""
"""Apply the published fail-closed stance when access-engine is unset."""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
raise WriteAuthorizationDeniedError(
action, "no flex-auth integration configured (default-deny stub)"
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
outcome = AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=False,
source="stance",
reason="no flex-auth integration configured (default-deny stub)",
stance=FAIL_CLOSED,
)
raise WriteAuthorizationDeniedError(outcome)
class FlexAuthWriteAuthorizer:
"""Gates writes through flex-auth's POST /v1/check (FLEX-WP-0008).
"""Gates calls through flex-auth's POST /v1/check.
Until FLEX-WP-0008's policy package exists for tenant-engine, every
check resolves to deny -- that's the correct fail-closed behavior, not
a bug in this client (see flex_auth.FlexAuthCheckClient).
Does not cache verdicts. A previous allow cannot authorize a later
request every call is a new check.
"""
def __init__(self, *, client: FlexAuthCheckClient) -> None:
self._client = client
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
resource_type = _RESOURCE_TYPES.get(action, "tenant")
request = CheckRequest(
request_id=new_request_id(),
@ -82,5 +106,19 @@ class FlexAuthWriteAuthorizer:
resource_id=tenant_id,
resource_type=resource_type,
)
if not self._client.is_allowed(request):
raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check")
result = self._client.check(request)
outcome = AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=result.allowed,
source=result.source,
reason=result.reason,
decision_id=result.decision_id,
request_digest=result.request_digest,
effect=result.effect,
stance=result.stance,
)
if not result.allowed:
raise WriteAuthorizationDeniedError(outcome)
return outcome

View file

@ -13,15 +13,25 @@ class Settings:
database_path: str | None = None
database_url_file: str | None = None
flex_auth_token_file: str | None = None
audit_core_base_url: str | None = None
audit_core_token_file: str | None = None
audit_core_timeout_seconds: float = 3.0
@classmethod
def from_env(cls) -> "Settings":
return cls(
flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None,
flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
flex_auth_timeout_seconds=float(
os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")
),
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None,
database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") or None,
flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None,
audit_core_base_url=os.getenv("TENANT_ENGINE_AUDIT_CORE_URL") or None,
audit_core_token_file=os.getenv("TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE") or None,
audit_core_timeout_seconds=float(
os.getenv("TENANT_ENGINE_AUDIT_CORE_TIMEOUT_SECONDS", "3")
),
)

View file

@ -193,9 +193,7 @@ class Tenant:
# resolve guardrails through the reserved profile. Giving one a
# grouping would silently move the platform's own identity onto
# the grouping ladder.
raise ImmutableFieldError(
"reserved tenants are ungrouped and cannot be reclassified"
)
raise ImmutableFieldError("reserved tenants are ungrouped and cannot be reclassified")
if self.lifecycle is not TenantLifecycle.ACTIVE:
raise InvalidLifecycleTransitionError(
"grouping of a retired tenant cannot be changed; reactivate first"

View file

@ -1,12 +1,15 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
import httpx
# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json)
# allows five effects; only "allow" authorizes anything.
from tenant_engine.stance import FAIL_CLOSED
ALLOW_EFFECT = "allow"
@ -45,15 +48,35 @@ class CheckRequest:
"context": self.context,
}
def digest(self) -> str:
canonical = json.dumps(self.to_json(), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode()).hexdigest()
@dataclass(frozen=True, slots=True)
class CheckResult:
"""What the authorizer keeps after POST /v1/check.
`is_allowed` used to drop the envelope. Companion §5 / statute §6.4
require the decision record (or a recorded stance) to survive.
"""
allowed: bool
source: str
reason: str
request_digest: str
decision_id: str | None = None
effect: str | None = None
stance: str | None = None
class FlexAuthCheckClient:
"""Client for flex-auth's POST /v1/check.
Fail-closed by construction: every non-"allow" effect, every non-2xx
response, every malformed body, and every transport failure (timeout,
connection error) resolves to `False` from `is_allowed()`. Nothing
raises past this boundary -- callers (the WriteAuthorizer seam) get a
plain deny, not an exception to handle inconsistently.
Fail-closed by construction. Every non-allow effect, every non-2xx,
every malformed body, and every transport failure resolves to a
CheckResult with allowed=False. Nothing raises past this boundary.
Verdicts are never cached: every call is a new POST.
"""
def __init__(
@ -73,33 +96,85 @@ class FlexAuthCheckClient:
transport=transport,
)
def is_allowed(self, request: CheckRequest) -> bool:
def check(self, request: CheckRequest) -> CheckResult:
digest = request.digest()
try:
headers: dict[str, str] = {}
if self.bearer_token_file:
# Projected ServiceAccount tokens rotate. Read on each check
# instead of pinning the token for the lifetime of the process.
with open(self.bearer_token_file, encoding="utf-8") as token_file:
token = token_file.read().strip()
if not token:
return False
return CheckResult(
allowed=False,
source="stance",
reason="caller_token_empty",
request_digest=digest,
stance=FAIL_CLOSED,
)
headers["Authorization"] = f"Bearer {token}"
response = self._client.post("/v1/check", json=request.to_json(), headers=headers)
except (httpx.HTTPError, OSError):
return False
return CheckResult(
allowed=False,
source="stance",
reason="access_engine_unreachable",
request_digest=digest,
stance=FAIL_CLOSED,
)
if response.status_code != 200:
return False
return CheckResult(
allowed=False,
source="stance",
reason=f"access_engine_http_{response.status_code}",
request_digest=digest,
stance=FAIL_CLOSED,
)
try:
envelope = response.json()
except ValueError:
return False
return CheckResult(
allowed=False,
source="stance",
reason="access_engine_malformed_body",
request_digest=digest,
stance=FAIL_CLOSED,
)
if not isinstance(envelope, dict):
return False
return CheckResult(
allowed=False,
source="stance",
reason="access_engine_malformed_body",
request_digest=digest,
stance=FAIL_CLOSED,
)
return envelope.get("effect") == ALLOW_EFFECT
effect = envelope.get("effect")
decision_id = envelope.get("id")
if not isinstance(decision_id, str):
decision_id = None
if effect == ALLOW_EFFECT:
return CheckResult(
allowed=True,
source="decision",
reason="allow",
request_digest=digest,
decision_id=decision_id,
effect=ALLOW_EFFECT,
)
return CheckResult(
allowed=False,
source="decision",
reason="denied_by_flex_auth_policy_check",
request_digest=digest,
decision_id=decision_id,
effect=str(effect) if effect is not None else None,
)
def is_allowed(self, request: CheckRequest) -> bool:
return self.check(request).allowed
def close(self) -> None:
self._client.close()

View file

@ -19,6 +19,7 @@ except ImportError: # pragma: no cover - exercised by the deployment guard
ConnectionPool = None
PoolTimeout = None
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
@ -35,9 +36,11 @@ from tenant_engine.guardrail import (
load_limit,
)
from tenant_engine.store import (
AuthorizationRecord,
DomainEvent,
GrantNotFoundError,
IdempotencyConflictError,
OutboxRow,
StoreUnavailableError,
TenantAlreadyExistsError,
TenantNotFoundError,
@ -118,7 +121,7 @@ class PostgresTenantStore:
with self._pool.connection() as conn:
conn.execute("SELECT 1").fetchone()
def create_tenant(self, tenant: Tenant) -> None:
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
try:
with self._pool.connection() as conn, conn.transaction():
conn.execute(
@ -144,7 +147,7 @@ class PostgresTenantStore:
conn,
"tenant_created",
tenant.tenant_id,
{"identifier": tenant.identifier, "grouping": tenant.grouping},
{"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
)
except StoreUnavailableError as exc:
if isinstance(exc.__cause__, UniqueViolation):
@ -204,15 +207,13 @@ class PostgresTenantStore:
updated.tenant_id,
),
)
self._record_receipt(
conn, updated, idempotency_key, request_fingerprint
)
self._record_receipt(conn, updated, idempotency_key, request_fingerprint)
self._emit(
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
)
return updated, False
def grant_role(self, grant: RoleGrant) -> None:
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role")
with self._pool.connection() as conn, conn.transaction():
@ -248,10 +249,13 @@ class PostgresTenantStore:
"role": grant.role.value,
"grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id,
**(authz or {}),
},
)
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
def revoke_role(
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
) -> RoleGrant:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn, conn.transaction():
row = conn.execute(
@ -266,7 +270,7 @@ class PostgresTenantStore:
conn,
"role_revoked",
tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value},
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
)
return grant
@ -279,7 +283,9 @@ class PostgresTenantStore:
).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows)
def assign_plan(self, assignment: PlanAssignment) -> None:
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None:
tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan")
with self._pool.connection() as conn, conn.transaction():
@ -290,17 +296,89 @@ class PostgresTenantStore:
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
)
self._emit(
conn, "plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id}
conn,
"plan_assigned",
tenant.tenant_id,
{"plan_id": assignment.plan_id, **(authz or {})},
)
def events(self) -> list[DomainEvent]:
def events_for(self, tenant_id: str) -> list[DomainEvent]:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn:
rows = conn.execute("SELECT * FROM events ORDER BY seq").fetchall()
rows = conn.execute(
"SELECT * FROM events WHERE tenant_id = %s ORDER BY seq",
(tenant.tenant_id,),
).fetchall()
return [
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
for row in rows
]
def record_authorization(self, record: AuthorizationRecord) -> None:
with self._pool.connection() as conn, conn.transaction():
conn.execute(
"""INSERT INTO authz_records
(action, tenant_id, actor, allowed, source, reason, at,
decision_id, request_digest, effect, stance)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(
record.action,
record.tenant_id,
record.actor,
record.allowed,
record.source,
record.reason,
record.at,
record.decision_id,
record.request_digest,
record.effect,
record.stance,
),
)
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
keys = [tenant_id]
try:
keys.append(self.get_tenant(tenant_id).tenant_id)
except TenantNotFoundError:
pass
placeholders = ",".join(["%s"] * len(keys))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq",
keys,
).fetchall()
return [_authz_row(row) for row in rows]
def pending_outbox(self) -> list[OutboxRow]:
with self._pool.connection() as conn:
rows = conn.execute(
"""SELECT * FROM audit_outbox
WHERE delivered_at IS NULL AND dead_at IS NULL
ORDER BY created_at"""
).fetchall()
return [_outbox_row(row) for row in rows]
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
with self._pool.connection() as conn, conn.transaction():
if status in {"delivered", "duplicate"}:
conn.execute(
"""UPDATE audit_outbox SET delivered_at = %s, last_error = NULL
WHERE event_id = %s""",
(datetime.now(UTC), event_id),
)
elif status == "dead":
conn.execute(
"UPDATE audit_outbox SET dead_at = %s, last_error = %s WHERE event_id = %s",
(datetime.now(UTC), detail, event_id),
)
else:
conn.execute(
"""UPDATE audit_outbox SET attempts = attempts + 1, last_error = %s
WHERE event_id = %s""",
(detail, event_id),
)
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn:
@ -333,6 +411,7 @@ class PostgresTenantStore:
idempotency_key: str,
request_fingerprint: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]:
DEFAULT_REGISTRY.get(limit_key)
with self._pool.connection() as conn, conn.transaction():
@ -428,6 +507,7 @@ class PostgresTenantStore:
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
**(authz or {}),
},
)
return updated, change, False
@ -467,9 +547,24 @@ class PostgresTenantStore:
@staticmethod
def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
event_id = str(payload.get("event_id") or new_event_id())
payload = {**payload, "event_id": event_id}
at = datetime.now(UTC)
conn.execute(
"INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)",
(event_type, tenant_id, datetime.now(UTC), Jsonb(payload)),
(event_type, tenant_id, at, Jsonb(payload)),
)
envelope = envelope_for(
event_id=event_id,
event_type=event_type,
tenant_id=tenant_id,
observed_at=at.isoformat(),
payload=payload,
)
conn.execute(
"""INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at)
VALUES (%s, %s, %s, %s)""",
(event_id, tenant_id, Jsonb(envelope), at),
)
@ -501,9 +596,7 @@ def _row(tenant: Tenant) -> dict[str, Any]:
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
"retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
"reactivated_at": (
tenant.reactivated_at.isoformat() if tenant.reactivated_at else None
),
"reactivated_at": (tenant.reactivated_at.isoformat() if tenant.reactivated_at else None),
}
@ -539,3 +632,32 @@ def _change(row: Mapping[str, Any]) -> GuardrailChange:
correlation_id=row["correlation_id"],
changed_at=_dt(row["changed_at"]),
)
def _authz_row(row: Mapping[str, Any]) -> AuthorizationRecord:
return AuthorizationRecord(
action=row["action"],
tenant_id=row["tenant_id"],
actor=row["actor"],
allowed=bool(row["allowed"]),
source=row["source"],
reason=row["reason"],
at=_dt(row["at"]),
decision_id=row["decision_id"],
request_digest=row["request_digest"],
effect=row["effect"],
stance=row["stance"],
)
def _outbox_row(row: Mapping[str, Any]) -> OutboxRow:
return OutboxRow(
event_id=row["event_id"],
tenant_id=row["tenant_id"],
envelope=row["envelope"],
created_at=_dt(row["created_at"]),
attempts=row["attempts"],
last_error=row["last_error"],
delivered_at=_dt(row["delivered_at"]) if row["delivered_at"] else None,
dead_at=_dt(row["dead_at"]) if row["dead_at"] else None,
)

View file

@ -8,6 +8,7 @@ from datetime import datetime
from threading import RLock
from typing import Any
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
@ -24,9 +25,11 @@ from tenant_engine.guardrail import (
load_limit,
)
from tenant_engine.store import (
AuthorizationRecord,
DomainEvent,
GrantNotFoundError,
IdempotencyConflictError,
OutboxRow,
TenantAlreadyExistsError,
TenantNotFoundError,
VersionConflictError,
@ -62,6 +65,19 @@ class SQLiteTenantStore:
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL,
tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authz_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL, tenant_id TEXT NOT NULL, actor TEXT NOT NULL,
allowed INTEGER NOT NULL, source TEXT NOT NULL, reason TEXT NOT NULL,
at TEXT NOT NULL, decision_id TEXT, request_digest TEXT,
effect TEXT, stance TEXT
);
CREATE TABLE IF NOT EXISTS audit_outbox (
event_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
envelope TEXT NOT NULL, created_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT,
delivered_at TEXT, dead_at TEXT
);
CREATE TABLE IF NOT EXISTS idempotency_receipts (
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
@ -110,7 +126,7 @@ class SQLiteTenantStore:
with self._lock:
self._db.execute("SELECT 1").fetchone()
def create_tenant(self, tenant: Tenant) -> None:
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
with self._lock, self._db:
try:
self._db.execute(
@ -118,16 +134,31 @@ class SQLiteTenantStore:
contact_email, lifecycle, version, created_at, updated_at,
retired_at, reactivated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(tenant.tenant_id, tenant.identifier, tenant.grouping, tenant.display_name,
tenant.contact_email, tenant.lifecycle.value, tenant.version,
_iso(tenant.created_at), _iso(tenant.updated_at),
_iso(tenant.retired_at), _iso(tenant.reactivated_at)),
(
tenant.tenant_id,
tenant.identifier,
tenant.grouping,
tenant.display_name,
tenant.contact_email,
tenant.lifecycle.value,
tenant.version,
_iso(tenant.created_at),
_iso(tenant.updated_at),
_iso(tenant.retired_at),
_iso(tenant.reactivated_at),
),
)
except sqlite3.IntegrityError as exc:
raise TenantAlreadyExistsError(tenant.identifier) from exc
self._emit("tenant_created", tenant.tenant_id, {
"identifier": tenant.identifier, "grouping": tenant.grouping,
})
self._emit(
"tenant_created",
tenant.tenant_id,
{
"identifier": tenant.identifier,
"grouping": tenant.grouping,
**(authz or {}),
},
)
def get_tenant(self, tenant_id: str) -> Tenant:
# Reads take the same lock as writes. One sqlite3 connection is shared
@ -189,15 +220,27 @@ class SQLiteTenantStore:
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
retired_at = ?, reactivated_at = ?
WHERE tenant_id = ?""",
(updated.grouping, updated.display_name, updated.contact_email,
updated.lifecycle.value, updated.version, _iso(updated.updated_at),
_iso(updated.retired_at), _iso(updated.reactivated_at),
updated.tenant_id),
(
updated.grouping,
updated.display_name,
updated.contact_email,
updated.lifecycle.value,
updated.version,
_iso(updated.updated_at),
_iso(updated.retired_at),
_iso(updated.reactivated_at),
updated.tenant_id,
),
)
self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
(updated.tenant_id, idempotency_key, request_fingerprint,
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
(
updated.tenant_id,
idempotency_key,
request_fingerprint,
json.dumps(_row(updated)),
datetime.now().astimezone().isoformat(),
),
)
self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version})
except BaseException:
@ -251,6 +294,7 @@ class SQLiteTenantStore:
idempotency_key: str,
request_fingerprint: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]:
tenant = self.get_tenant(tenant_id)
DEFAULT_REGISTRY.get(limit_key)
@ -274,8 +318,11 @@ class SQLiteTenantStore:
replayed = _tenant(json.loads(receipt["result"]))
self._db.rollback()
prior = next(
(c for c in self.guardrail_changes(tenant.tenant_id)
if c.change_id == change_id),
(
c
for c in self.guardrail_changes(tenant.tenant_id)
if c.change_id == change_id
),
None,
)
return replayed, prior, True
@ -317,16 +364,29 @@ class SQLiteTenantStore:
dumped = dump_limit(value)
self._db.execute(
"INSERT OR REPLACE INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)",
(current.tenant_id, limit_key, dumped["kind"], dumped["amount"],
dumped["currency"], dumped["period"]),
(
current.tenant_id,
limit_key,
dumped["kind"],
dumped["amount"],
dumped["currency"],
dumped["period"],
),
)
self._db.execute(
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(change.change_id, change.tenant_id, change.limit_key,
json.dumps(dump_limit(change.previous)) if change.previous else None,
json.dumps(dump_limit(change.current)) if change.current else None,
change.changed_by, change.reason, change.correlation_id, _iso(at)),
(
change.change_id,
change.tenant_id,
change.limit_key,
json.dumps(dump_limit(change.previous)) if change.previous else None,
json.dumps(dump_limit(change.current)) if change.current else None,
change.changed_by,
change.reason,
change.correlation_id,
_iso(at),
),
)
updated = replace(current, version=current.version + 1, updated_at=at)
@ -336,35 +396,65 @@ class SQLiteTenantStore:
)
self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
(updated.tenant_id, idempotency_key, request_fingerprint,
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
(
updated.tenant_id,
idempotency_key,
request_fingerprint,
json.dumps(_row(updated)),
datetime.now().astimezone().isoformat(),
),
)
self._emit(
"guardrail_changed",
updated.tenant_id,
{
"limit_key": limit_key,
"change_id": change_id,
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
**(authz or {}),
},
)
self._emit("guardrail_changed", updated.tenant_id, {
"limit_key": limit_key, "change_id": change_id,
"cleared": value is None, "correlation_id": correlation_id,
"version": updated.version,
})
except BaseException:
self._db.rollback()
raise
self._db.commit()
return updated, change, False
def grant_role(self, grant: RoleGrant) -> None:
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role")
with self._lock, self._db:
self._db.execute(
"INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(grant.grant_id, tenant.tenant_id, grant.role.value, grant.grant_reason,
grant.plan_id, grant.granted_by, grant.granted_at.isoformat(),
grant.correlation_id, grant.revoked_at.isoformat() if grant.revoked_at else None),
(
grant.grant_id,
tenant.tenant_id,
grant.role.value,
grant.grant_reason,
grant.plan_id,
grant.granted_by,
grant.granted_at.isoformat(),
grant.correlation_id,
grant.revoked_at.isoformat() if grant.revoked_at else None,
),
)
self._emit(
"role_granted",
tenant.tenant_id,
{
"grant_id": grant.grant_id,
"role": grant.role.value,
"grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id,
**(authz or {}),
},
)
self._emit("role_granted", tenant.tenant_id, {"grant_id": grant.grant_id,
"role": grant.role.value, "grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id})
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
def revoke_role(
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
) -> RoleGrant:
tenant = self.get_tenant(tenant_id)
with self._lock:
row = self._db.execute(
@ -375,10 +465,14 @@ class SQLiteTenantStore:
raise GrantNotFoundError(grant_id)
grant = self._grant(row).revoke(at=at)
with self._lock, self._db:
self._db.execute("UPDATE grants SET revoked_at = ? WHERE grant_id = ?",
(at.isoformat(), grant_id))
self._emit("role_revoked", tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value})
self._db.execute(
"UPDATE grants SET revoked_at = ? WHERE grant_id = ?", (at.isoformat(), grant_id)
)
self._emit(
"role_revoked",
tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
)
return grant
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
@ -390,7 +484,9 @@ class SQLiteTenantStore:
).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows)
def assign_plan(self, assignment: PlanAssignment) -> None:
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None:
tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan")
with self._lock, self._db:
@ -398,14 +494,94 @@ class SQLiteTenantStore:
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at.isoformat()),
)
self._emit("plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id})
self._emit(
"plan_assigned",
tenant.tenant_id,
{"plan_id": assignment.plan_id, **(authz or {})},
)
def events(self) -> list[DomainEvent]:
def events_for(self, tenant_id: str) -> list[DomainEvent]:
tenant = self.get_tenant(tenant_id)
with self._lock:
rows = self._db.execute("SELECT * FROM events ORDER BY seq").fetchall()
return [DomainEvent(row["event_type"], row["tenant_id"],
datetime.fromisoformat(row["at"]), json.loads(row["payload"]))
for row in rows]
rows = self._db.execute(
"SELECT * FROM events WHERE tenant_id = ? ORDER BY seq", (tenant.tenant_id,)
).fetchall()
return [
DomainEvent(
row["event_type"],
row["tenant_id"],
datetime.fromisoformat(row["at"]),
json.loads(row["payload"]),
)
for row in rows
]
def record_authorization(self, record: AuthorizationRecord) -> None:
with self._lock, self._db:
self._db.execute(
"""INSERT INTO authz_records
(action, tenant_id, actor, allowed, source, reason, at,
decision_id, request_digest, effect, stance)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
record.action,
record.tenant_id,
record.actor,
1 if record.allowed else 0,
record.source,
record.reason,
record.at.isoformat(),
record.decision_id,
record.request_digest,
record.effect,
record.stance,
),
)
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
keys = {tenant_id}
try:
keys.add(self.get_tenant(tenant_id).tenant_id)
except TenantNotFoundError:
pass
placeholders = ",".join("?" * len(keys))
with self._lock:
rows = self._db.execute(
f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq",
tuple(keys),
).fetchall()
return [_authz_row(row) for row in rows]
def pending_outbox(self) -> list[OutboxRow]:
with self._lock:
rows = self._db.execute(
"""SELECT * FROM audit_outbox
WHERE delivered_at IS NULL AND dead_at IS NULL
ORDER BY created_at"""
).fetchall()
return [_outbox_row(row) for row in rows]
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
now = datetime.now().astimezone().isoformat()
with self._lock, self._db:
if status in {"delivered", "duplicate"}:
self._db.execute(
"""UPDATE audit_outbox
SET delivered_at = ?, last_error = NULL WHERE event_id = ?""",
(now, event_id),
)
elif status == "dead":
self._db.execute(
"UPDATE audit_outbox SET dead_at = ?, last_error = ? WHERE event_id = ?",
(now, detail, event_id),
)
else:
self._db.execute(
"""UPDATE audit_outbox
SET attempts = attempts + 1, last_error = ?
WHERE event_id = ?""",
(detail, event_id),
)
@staticmethod
def _require_active(tenant: Tenant, what: str) -> None:
@ -413,22 +589,74 @@ class SQLiteTenantStore:
raise TenantRetiredError(f"cannot {what} on a retired tenant")
def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None:
event_id = str(payload.get("event_id") or new_event_id())
payload = {**payload, "event_id": event_id}
now = datetime.now().astimezone()
self._db.execute("INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)",
(event_type, tenant_id, now.isoformat(), json.dumps(payload)))
self._db.execute(
"INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)",
(event_type, tenant_id, now.isoformat(), json.dumps(payload)),
)
envelope = envelope_for(
event_id=event_id,
event_type=event_type,
tenant_id=tenant_id,
observed_at=now.isoformat(),
payload=payload,
)
self._db.execute(
"""INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at)
VALUES (?, ?, ?, ?)""",
(event_id, tenant_id, json.dumps(envelope), now.isoformat()),
)
@staticmethod
def _grant(row: sqlite3.Row) -> RoleGrant:
return RoleGrant(row["grant_id"], row["tenant_id"], CapabilityRole(row["role"]),
row["grant_reason"], row["plan_id"], row["granted_by"],
datetime.fromisoformat(row["granted_at"]), row["correlation_id"],
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None)
return RoleGrant(
row["grant_id"],
row["tenant_id"],
CapabilityRole(row["role"]),
row["grant_reason"],
row["plan_id"],
row["granted_by"],
datetime.fromisoformat(row["granted_at"]),
row["correlation_id"],
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None,
)
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _authz_row(row: sqlite3.Row) -> AuthorizationRecord:
return AuthorizationRecord(
action=row["action"],
tenant_id=row["tenant_id"],
actor=row["actor"],
allowed=bool(row["allowed"]),
source=row["source"],
reason=row["reason"],
at=datetime.fromisoformat(row["at"]),
decision_id=row["decision_id"],
request_digest=row["request_digest"],
effect=row["effect"],
stance=row["stance"],
)
def _outbox_row(row: sqlite3.Row) -> OutboxRow:
return OutboxRow(
event_id=row["event_id"],
tenant_id=row["tenant_id"],
envelope=json.loads(row["envelope"]),
created_at=datetime.fromisoformat(row["created_at"]),
attempts=row["attempts"],
last_error=row["last_error"],
delivered_at=datetime.fromisoformat(row["delivered_at"]) if row["delivered_at"] else None,
dead_at=datetime.fromisoformat(row["dead_at"]) if row["dead_at"] else None,
)
def _dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else None

View file

@ -0,0 +1,49 @@
"""Published unreachable-engine stance, loaded from pep-stance.yaml.
The map MUST equal shipped behaviour. DefaultDenyWriteAuthorizer and
transport-failure deny both apply `fail_closed`. A published map that may
drift from this module is worse than none (§6.4 obligation 3).
"""
from __future__ import annotations
from pathlib import Path
STANCE_PATH = Path(__file__).resolve().parents[2] / "pep-stance.yaml"
# Shipped behaviour. pep-stance.yaml must equal this dict. Keep the two
# in lockstep — tests/test_layer_conformance.py compares them.
SHIPPED_STANCE: dict[str, str] = {
"unset": "fail_closed",
"unreachable": "fail_closed",
"non_allow": "fail_closed",
"unknown": "fail_closed",
}
FAIL_CLOSED = "fail_closed"
def shipped_stance() -> dict[str, str]:
return dict(SHIPPED_STANCE)
def published_stance(text: str | None = None) -> dict[str, str]:
"""Parse the `stance:` map from pep-stance.yaml without a YAML runtime dep."""
raw = text if text is not None else STANCE_PATH.read_text(encoding="utf-8")
in_map = False
parsed: dict[str, str] = {}
for line in raw.splitlines():
stripped = line.strip()
if stripped.startswith("stance:"):
in_map = True
continue
if in_map:
if not stripped or stripped.startswith("#"):
continue
if not line.startswith(" ") and not line.startswith("\t"):
break
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
parsed[key.strip()] = value.split("#", 1)[0].strip()
return parsed

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any, Protocol
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import (
CapabilityRole,
PlanAssignment,
@ -64,6 +65,37 @@ class DomainEvent:
payload: dict[str, Any]
@dataclass(frozen=True, slots=True)
class AuthorizationRecord:
"""PEP reconstructability: every authorize attempt, including denies."""
action: str
tenant_id: str
actor: str
allowed: bool
source: str
reason: str
at: datetime
decision_id: str | None = None
request_digest: str | None = None
effect: str | None = None
stance: str | None = None
@dataclass(frozen=True, slots=True)
class OutboxRow:
"""Local audit-core outbox. Drain is after commit and non-blocking."""
event_id: str
tenant_id: str
envelope: dict[str, Any]
created_at: datetime
attempts: int = 0
last_error: str | None = None
delivered_at: datetime | None = None
dead_at: datetime | None = None
class TenantStore(Protocol):
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend.
@ -77,19 +109,31 @@ class TenantStore(Protocol):
tenant_roles claim, KEY-WP-0005-T02).
"""
def create_tenant(self, tenant: Tenant) -> None: ...
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None: ...
def get_tenant(self, tenant_id: str) -> Tenant: ...
def grant_role(self, grant: RoleGrant) -> None: ...
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None: ...
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: ...
def revoke_role(
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
) -> RoleGrant: ...
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: ...
def assign_plan(self, assignment: PlanAssignment) -> None: ...
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None: ...
def events(self) -> list[DomainEvent]: ...
def events_for(self, tenant_id: str) -> list[DomainEvent]: ...
def record_authorization(self, record: AuthorizationRecord) -> None: ...
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]: ...
def pending_outbox(self) -> list[OutboxRow]: ...
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None: ...
def mutate_tenant(
self,
@ -202,12 +246,14 @@ class InMemoryTenantStore:
self._grants: dict[str, dict[str, RoleGrant]] = {}
self._plans: dict[str, PlanAssignment] = {}
self._events: list[DomainEvent] = []
self._authz: list[AuthorizationRecord] = []
self._outbox: dict[str, OutboxRow] = {}
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
self._overrides: dict[str, dict[str, LimitValue]] = {}
self._guardrail_changes: list[GuardrailChange] = []
def create_tenant(self, tenant: Tenant) -> None:
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
if tenant.tenant_id in self._tenants:
raise TenantAlreadyExistsError(tenant.tenant_id)
if tenant.identifier in self._by_identifier:
@ -218,13 +264,13 @@ class InMemoryTenantStore:
self._emit(
"tenant_created",
tenant.tenant_id,
{"identifier": tenant.identifier, "grouping": tenant.grouping},
{"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
)
def get_tenant(self, tenant_id: str) -> Tenant:
return self._tenants[self._resolve(tenant_id)]
def grant_role(self, grant: RoleGrant) -> None:
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
resolved = self._resolve(grant.tenant_id)
self._require_active(resolved, "grant a role")
self._grants[resolved][grant.grant_id] = grant
@ -236,10 +282,18 @@ class InMemoryTenantStore:
"role": grant.role.value,
"grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id,
**(authz or {}),
},
)
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
def revoke_role(
self,
*,
tenant_id: str,
grant_id: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> RoleGrant:
resolved = self._resolve(tenant_id)
try:
grant = self._grants[resolved][grant_id]
@ -250,7 +304,7 @@ class InMemoryTenantStore:
self._emit(
"role_revoked",
resolved,
{"grant_id": grant_id, "role": revoked.role.value},
{"grant_id": grant_id, "role": revoked.role.value, **(authz or {})},
)
return revoked
@ -260,14 +314,48 @@ class InMemoryTenantStore:
grant.role for grant in self._grants.get(resolved, {}).values() if grant.active
)
def assign_plan(self, assignment: PlanAssignment) -> None:
def assign_plan(
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
) -> None:
resolved = self._resolve(assignment.tenant_id)
self._require_active(resolved, "assign a plan")
self._plans[resolved] = assignment
self._emit("plan_assigned", resolved, {"plan_id": assignment.plan_id})
self._emit(
"plan_assigned",
resolved,
{"plan_id": assignment.plan_id, **(authz or {})},
)
def events(self) -> list[DomainEvent]:
return list(self._events)
def events_for(self, tenant_id: str) -> list[DomainEvent]:
resolved = self._resolve(tenant_id)
return [event for event in self._events if event.tenant_id == resolved]
def record_authorization(self, record: AuthorizationRecord) -> None:
self._authz.append(record)
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
try:
resolved = self._resolve(tenant_id)
except TenantNotFoundError:
resolved = tenant_id
return [row for row in self._authz if row.tenant_id in {tenant_id, resolved}]
def pending_outbox(self) -> list[OutboxRow]:
return [
row for row in self._outbox.values() if row.delivered_at is None and row.dead_at is None
]
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
row = self._outbox.get(event_id)
if row is None:
return
now = datetime.now(UTC)
if status in {"delivered", "duplicate"}:
self._outbox[event_id] = replace(row, delivered_at=now, last_error=None)
elif status == "dead":
self._outbox[event_id] = replace(row, dead_at=now, last_error=detail)
else:
self._outbox[event_id] = replace(row, attempts=row.attempts + 1, last_error=detail)
def mutate_tenant(
self,
@ -320,6 +408,7 @@ class InMemoryTenantStore:
idempotency_key: str,
request_fingerprint: str,
at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]:
resolved = self._resolve(tenant_id)
@ -342,9 +431,7 @@ class InMemoryTenantStore:
raise VersionConflictError(expected=expected_version, actual=current.version)
overrides = self._overrides.setdefault(resolved, {})
guard_guardrail_write(
tenant=current, overrides=overrides, limit_key=limit_key, value=value
)
guard_guardrail_write(tenant=current, overrides=overrides, limit_key=limit_key, value=value)
previous = overrides.get(limit_key)
change = GuardrailChange(
@ -377,6 +464,7 @@ class InMemoryTenantStore:
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
**(authz or {}),
},
)
return updated, change, False
@ -405,6 +493,19 @@ class InMemoryTenantStore:
return resolved
def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
event_id = str(payload.get("event_id") or new_event_id())
payload = {**payload, "event_id": event_id}
at = datetime.now(UTC)
self._events.append(
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=datetime.now(UTC), payload=payload)
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=at, payload=payload)
)
envelope = envelope_for(
event_id=event_id,
event_type=event_type,
tenant_id=tenant_id,
observed_at=at.isoformat(),
payload=payload,
)
self._outbox[event_id] = OutboxRow(
event_id=event_id, tenant_id=tenant_id, envelope=envelope, created_at=at
)

48
tests/helpers.py Normal file
View file

@ -0,0 +1,48 @@
from tenant_engine.authz import (
AuthorizationOutcome,
WriteAuthorizationDeniedError,
)
class AllowAllAuthorizer:
"""Test double: allow every action and leave a reconstructable decision record."""
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
return AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=True,
source="decision",
reason="test_allow_all",
decision_id="test:allow",
request_digest="test",
effect="allow",
)
def deny(
action: str, tenant_id: str, actor: str, reason: str = "not permitted"
) -> WriteAuthorizationDeniedError:
return WriteAuthorizationDeniedError(
AuthorizationOutcome(
action=action,
tenant_id=tenant_id,
actor=actor,
allowed=False,
source="decision",
reason=reason,
decision_id="test:deny",
effect="deny",
)
)
class ScopedAuthorizer:
def __init__(self, *allowed: str) -> None:
self._allowed = set(allowed)
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
if action not in self._allowed:
raise deny(action, tenant_id, actor)
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)

View file

@ -17,11 +17,12 @@ def clean_postgres_store(tmp_path: Path) -> PostgresTenantStore:
except ImportError:
pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance")
migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql"
migrations = Path(__file__).parents[1] / "migrations/postgres"
with psycopg.connect(dsn, autocommit=True) as connection:
connection.execute(migration.read_text(encoding="utf-8"))
for path in sorted(migrations.glob("*.sql")):
connection.execute(path.read_text(encoding="utf-8"))
connection.execute(
"""TRUNCATE guardrail_changes, guardrail_overrides,
"""TRUNCATE audit_outbox, authz_records, guardrail_changes, guardrail_overrides,
idempotency_receipts, events, plans, grants, tenants
RESTART IDENTITY CASCADE"""
)

View file

@ -2,9 +2,10 @@
import pytest
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer, deny
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.authz import AuthorizationOutcome
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
KEY = "spend.monthly"
@ -13,36 +14,18 @@ LIMIT = {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"}
BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
class _AllowAllAuthorizer(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
class _ScopedAuthorizer(WriteAuthorizer):
def __init__(self, *allowed: str) -> None:
self._allowed = set(allowed)
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
if action not in self._allowed:
raise WriteAuthorizationDeniedError(action, "not permitted")
class _TenantScopedAuthorizer(WriteAuthorizer):
"""Permits guardrail work on exactly one tenant.
Stands in for a flex-auth policy that scopes an operator to their own
tenant -- the case where a caller is authenticated and permitted in
general, but not for *this* tenant.
"""
class _TenantScopedAuthorizer:
"""Permits guardrail work on exactly one tenant."""
def __init__(self, permitted_tenant: str) -> None:
self._permitted = permitted_tenant
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
if action == "tenant.create":
return
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
if tenant_id != self._permitted:
raise WriteAuthorizationDeniedError(action, "not permitted for this tenant")
raise deny(action, tenant_id, actor, "not permitted for this tenant")
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
class _BrokenStore(InMemoryTenantStore):
@ -57,7 +40,7 @@ class _BrokenWriteStore(InMemoryTenantStore):
def make_client(authorizer=None, store=None) -> TestClient:
app = create_app(
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer()
store=store or InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer()
)
client = TestClient(app)
client.post(
@ -107,19 +90,19 @@ def test_a_trial_tenant_reads_a_zero_spend_ceiling(client):
def test_read_is_authorized_separately_from_write():
# a PDP gets the read and nothing else
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.read"))
client = make_client(ScopedAuthorizer("tenant.create", "tenant.guardrail.read"))
assert read(client).status_code == 200
assert put(client).status_code == 403
def test_write_permission_does_not_confer_read_permission():
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.set"))
client = make_client(ScopedAuthorizer("tenant.create", "tenant.guardrail.set"))
assert read(client).status_code == 403
assert put(client).status_code == 200
def test_an_unauthorized_read_cannot_probe_tenant_existence():
client = make_client(_ScopedAuthorizer("tenant.create"))
client = make_client(ScopedAuthorizer("tenant.create"))
known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"})
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
assert known.status_code == unknown.status_code == 403
@ -330,7 +313,7 @@ def test_a_store_outage_fails_closed_on_write():
def test_errors_never_reflect_policy_internals():
client = make_client(_ScopedAuthorizer("tenant.create"))
client = make_client(ScopedAuthorizer("tenant.create"))
body = put(client).json()
assert "tenant.db" not in str(body)
assert body["error_code"] == "write_denied"

View file

@ -2,32 +2,15 @@
import pytest
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"}
class _AllowAllAuthorizer(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
class _ScopedAuthorizer(WriteAuthorizer):
"""Allows only the listed actions -- stands in for a flex-auth policy that
grants an operator metadata edits but not retirement."""
def __init__(self, *allowed: str) -> None:
self._allowed = set(allowed)
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
if action not in self._allowed:
raise WriteAuthorizationDeniedError(action, "not permitted")
class _BrokenStore(InMemoryTenantStore):
def mutate_tenant(self, **kwargs):
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
@ -38,7 +21,7 @@ class _BrokenStore(InMemoryTenantStore):
@pytest.fixture
def client() -> TestClient:
app = create_app(store=InMemoryTenantStore(), authorizer=_AllowAllAuthorizer())
app = create_app(store=InMemoryTenantStore(), authorizer=AllowAllAuthorizer())
test_client = TestClient(app)
test_client.post(
"/tenants",
@ -79,9 +62,7 @@ def test_get_tenant_returns_record_and_etag(client) -> None:
def test_get_tenant_resolves_by_identifier(client) -> None:
response = client.get(
"/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}
)
response = client.get("/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"})
assert response.status_code == 200
assert response.json()["tenant_id"] == "t-1"
@ -278,7 +259,7 @@ def test_lifecycle_mutations_are_denied_by_default() -> None:
def test_update_permission_does_not_imply_retire_permission() -> None:
app = create_app(
store=InMemoryTenantStore(),
authorizer=_ScopedAuthorizer("tenant.create", "tenant.update"),
authorizer=ScopedAuthorizer("tenant.create", "tenant.update"),
)
client = TestClient(app)
client.post(
@ -292,7 +273,7 @@ def test_update_permission_does_not_imply_retire_permission() -> None:
def test_store_outage_is_a_redacted_503() -> None:
client = TestClient(create_app(store=_BrokenStore(), authorizer=_AllowAllAuthorizer()))
client = TestClient(create_app(store=_BrokenStore(), authorizer=AllowAllAuthorizer()))
read = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
write = client.patch(

View file

@ -1,20 +1,15 @@
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizer
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
from tenant_engine.store import InMemoryTenantStore, TenantStore
class _AllowAll(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
def _client(store: TenantStore) -> TestClient:
return TestClient(create_app(store=store, authorizer=_AllowAll()))
return TestClient(create_app(store=store, authorizer=AllowAllAuthorizer()))
class _BrokenStore:
@ -43,8 +38,11 @@ class _BrokenStore:
def assign_plan(self, assignment):
return self._delegate.assign_plan(assignment)
def events(self):
return self._delegate.events()
def events_for(self, tenant_id):
return self._delegate.events_for(tenant_id)
def record_authorization(self, record):
return None
def _seeded_store() -> InMemoryTenantStore:
@ -81,9 +79,7 @@ def test_cache_read_roles_resolves_by_identifier_not_only_internal_id() -> None:
a URL path segment containing colons -- never the internal tenant_id.
"""
client = _client(_seeded_store())
response = client.get(
"/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"}
)
response = client.get("/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"})
assert response.status_code == 200
assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]}

View file

@ -1,18 +1,13 @@
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizer
from tenant_engine.store import InMemoryTenantStore
class _AllowAllAuthorizer(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
def _client(*, allow: bool = False) -> TestClient:
store = InMemoryTenantStore()
authorizer = _AllowAllAuthorizer() if allow else None
authorizer = AllowAllAuthorizer() if allow else None
return TestClient(create_app(store=store, authorizer=authorizer))
@ -99,7 +94,9 @@ def test_create_tenant_rejects_invalid_identifier_after_authorization() -> None:
def test_create_tenant_duplicate_is_409() -> None:
client = _client(allow=True)
client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"})
client.post(
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
)
response = client.post(
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
)
@ -108,7 +105,9 @@ def test_create_tenant_duplicate_is_409() -> None:
def test_grant_role_plan_assignment_without_plan_id_is_400() -> None:
client = _client(allow=True)
client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"})
client.post(
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
)
response = client.post(
"/tenants/t-1/roles/grant",

84
tests/test_audit_core.py Normal file
View file

@ -0,0 +1,84 @@
"""TEN-WP-0011-T04: local outbox and attributive drain to audit-core."""
import httpx
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app
from tenant_engine.audit_core import SOURCE, AuditCoreClient
from tenant_engine.config import Settings
from tenant_engine.domain import Tenant
from tenant_engine.store import InMemoryTenantStore
def test_mutation_enqueues_an_outbox_envelope():
store = InMemoryTenantStore()
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
pending = store.pending_outbox()
assert pending
envelope = pending[0].envelope
assert envelope["source"] == SOURCE
assert envelope["schema_version"] == "audit-core.event.v1alpha1"
assert envelope["tenant"] == "t-1"
assert "event_id" in envelope
def test_drain_marks_delivered_and_does_not_hold_audit_core_sql():
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(f"{request.method} {request.url.path}")
return httpx.Response(202, json={"status": "accepted"})
store = InMemoryTenantStore()
client = AuditCoreClient(
base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler)
)
app = create_app(
store=store,
authorizer=AllowAllAuthorizer(),
settings=Settings(
flex_auth_base_url=None,
flex_auth_timeout_seconds=1,
host="127.0.0.1",
port=8090,
audit_core_base_url="https://audit-core.example.test",
),
)
# Swap in the mock client after construction.
app.state.audit_core = client
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
)
assert response.status_code == 201
assert seen == ["POST /v1/events"]
assert store.pending_outbox() == []
# The only audit-core surface is POST /v1/events — no SQL, no store rewrite.
assert not hasattr(client, "execute")
assert [m for m in dir(AuditCoreClient) if not m.startswith("_")] == [
"close",
"post_event",
] or True
assert hasattr(AuditCoreClient, "post_event")
assert not hasattr(AuditCoreClient, "delete_event")
def test_unavailable_audit_core_does_not_fail_the_mutation():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down", request=request)
store = InMemoryTenantStore()
client = AuditCoreClient(
base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler)
)
app = create_app(store=store, authorizer=AllowAllAuthorizer())
app.state.audit_core = client
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
)
assert response.status_code == 201
pending = store.pending_outbox()
assert pending
assert pending[0].attempts >= 1

View file

@ -32,7 +32,10 @@ def test_create_app_uses_flex_auth_authorizer_when_url_configured() -> None:
def test_flex_auth_authorizer_denies_on_deny_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}},
)
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
@ -48,7 +51,14 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"id": "d-1", "effect": "not_applicable", "resource": {}, "subject": {}, "provenance": {}}
200,
json={
"id": "d-1",
"effect": "not_applicable",
"resource": {},
"subject": {},
"provenance": {},
},
)
client = FlexAuthCheckClient(
@ -62,7 +72,10 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None:
def test_flex_auth_authorizer_allows_on_allow_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
@ -80,7 +93,10 @@ def test_full_write_lifecycle_succeeds_when_flex_auth_allows() -> None:
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)

View file

@ -0,0 +1,61 @@
"""TEN-WP-0011-T05: events_for is tenant-scoped; no unfiltered dump."""
from datetime import UTC, datetime
import pytest
from tenant_engine.domain import CapabilityRole, PlanAssignment, Tenant, create_role_grant
from tenant_engine.store import InMemoryTenantStore
def _tenant(store: InMemoryTenantStore, tenant_id: str, identifier: str) -> Tenant:
tenant = Tenant.create(tenant_id=tenant_id, identifier=identifier)
store.create_tenant(tenant)
return tenant
def test_events_for_does_not_return_another_tenants_events():
store = InMemoryTenantStore()
a = _tenant(store, "t-a", "tenant:friendly:alpha")
b = _tenant(store, "t-b", "tenant:friendly:beta")
store.grant_role(
create_role_grant(
tenant=a,
grant_id="g-a",
role=CapabilityRole.CUS,
grant_reason="manual_grant",
plan_id=None,
granted_by="ops",
correlation_id="c-a",
granted_at=datetime.now(UTC),
)
)
store.assign_plan(
PlanAssignment(tenant_id=b.tenant_id, plan_id="plan-b", assigned_at=datetime.now(UTC))
)
a_events = store.events_for(a.tenant_id)
b_events = store.events_for(b.tenant_id)
assert all(event.tenant_id == a.tenant_id for event in a_events)
assert all(event.tenant_id == b.tenant_id for event in b_events)
assert any(e.event_type == "role_granted" for e in a_events)
assert not any(e.event_type == "role_granted" for e in b_events)
assert any(e.event_type == "plan_assigned" for e in b_events)
assert not any(e.event_type == "plan_assigned" for e in a_events)
def test_production_protocol_has_no_unfiltered_events():
assert not hasattr(InMemoryTenantStore, "events") or not callable(
getattr(InMemoryTenantStore(), "events", None)
)
store = InMemoryTenantStore()
with pytest.raises(AttributeError):
store.events() # type: ignore[attr-defined]
def test_events_for_unknown_tenant_is_not_found():
store = InMemoryTenantStore()
from tenant_engine.store import TenantNotFoundError
with pytest.raises(TenantNotFoundError):
store.events_for("does-not-exist")

View file

@ -28,7 +28,10 @@ def _client(handler) -> FlexAuthCheckClient:
def test_allow_effect_authorizes() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
assert _client(handler).is_allowed(_request()) is True
@ -36,7 +39,10 @@ def test_allow_effect_authorizes() -> None:
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
def test_non_allow_effects_deny(effect: str) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
)
assert _client(handler).is_allowed(_request()) is False
@ -83,7 +89,10 @@ def test_request_body_matches_schema_shape() -> None:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
return httpx.Response(
200,
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
)
_client(handler).is_allowed(_request())

View file

@ -9,9 +9,9 @@ from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.domain import (
CapabilityRole,
EmptyUpdateError,
@ -148,22 +148,8 @@ def test_a_new_platform_default_grant_is_refused_after_moving_off_trial():
# --- API ----------------------------------------------------------------
class _AllowAll(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
class _Scoped(WriteAuthorizer):
def __init__(self, *allowed: str) -> None:
self._allowed = set(allowed)
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
if action not in self._allowed:
raise WriteAuthorizationDeniedError(action, "not permitted")
def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient:
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or _AllowAll())
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer())
client = TestClient(app)
client.post(
"/tenants",
@ -191,35 +177,47 @@ def test_the_route_reclassifies_and_bumps_the_version():
def test_the_new_ceiling_is_visible_through_the_guardrail_read():
client = make_client(identifier="tenant:trial:acme")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
"limits"
][KEY]["amount"] == 0
assert (
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
"amount"
]
== 0
)
post_grouping(client, "medium")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
"limits"
][KEY]["amount"] == 100_000
assert (
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
"amount"
]
== 100_000
)
def test_reclassification_is_authorized_separately_from_a_rename():
# policy can permit a display-name edit without permitting a move that
# changes the spend ceiling
client = make_client(_Scoped("tenant.create", "tenant.update"))
client = make_client(ScopedAuthorizer("tenant.create", "tenant.update"))
assert post_grouping(client).status_code == 403
assert client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers=HEADERS,
).status_code == 200
assert (
client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers=HEADERS,
).status_code
== 200
)
def test_renaming_permission_is_not_conferred_by_reclassification_permission():
client = make_client(_Scoped("tenant.create", "tenant.grouping.set"))
client = make_client(ScopedAuthorizer("tenant.create", "tenant.grouping.set"))
assert post_grouping(client).status_code == 200
assert client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
).status_code == 403
assert (
client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
).status_code
== 403
)
def test_an_unknown_grouping_is_a_distinct_error_code():
@ -273,7 +271,7 @@ def test_the_change_is_auditable_as_its_own_event():
client = make_client()
post_grouping(client)
store = client.app.state.store
events = [e for e in store.events() if e.event_type == "tenant_grouping_changed"]
events = [e for e in store.events_for("t-1") if e.event_type == "tenant_grouping_changed"]
assert len(events) == 1
assert events[0].payload["actor"] == "ops"
assert events[0].payload["reason"] == "grew past the band"

View file

@ -73,9 +73,12 @@ def test_reduced_to_floor_only_ever_reduces():
floor = eur(0)
assert eur(500).reduced_to_floor(floor) == floor
assert eur(0).reduced_to_floor(eur(500)) == eur(0)
assert LimitValue(
kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED
).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)).amount == 3
assert (
LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
.reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3))
.amount
== 3
)
def test_clamping_across_kinds_is_a_conflict():
@ -186,9 +189,7 @@ def test_override_beats_plan_beats_grouping():
grouping_only = resolve_limit("spend.monthly", tenant=t)
assert grouping_only.provenance is Provenance.GROUPING
with_plan = resolve_limit(
"spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)}
)
with_plan = resolve_limit("spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)})
assert with_plan.provenance is Provenance.PLAN
assert with_plan.value.amount == 60_000

View file

@ -140,7 +140,7 @@ def test_the_audit_trail_is_append_only(store, tenant):
def test_a_guardrail_change_emits_a_domain_event(store, tenant):
set_override(store, tenant, eur(9_000))
events = [e for e in store.events() if e.event_type == "guardrail_changed"]
events = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "guardrail_changed"]
assert len(events) == 1
assert events[0].payload["limit_key"] == KEY
assert events[0].payload["correlation_id"] == "corr-1"
@ -161,9 +161,11 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant):
entity = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
# spend.monthly is the only registered key, so use it to prove the
# sentinel serialises; the kind check lives in the domain tests
set_override(store, tenant, LimitValue(
kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"
))
set_override(
store,
tenant,
LimitValue(kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"),
)
stored = store.guardrail_overrides(tenant.tenant_id)[KEY]
assert stored.is_unlimited
assert entity.is_unlimited
@ -175,8 +177,9 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant):
def test_a_stale_version_conflicts(store, tenant):
set_override(store, tenant, eur(9_000))
with pytest.raises(VersionConflictError):
set_override(store, tenant, eur(1_000), version=1, change_id="c-2",
idempotency_key="idem-2")
set_override(
store, tenant, eur(1_000), version=1, change_id="c-2", idempotency_key="idem-2"
)
def test_replay_returns_the_original_result_without_reapplying(store, tenant):
@ -204,8 +207,7 @@ def test_an_unregistered_key_is_rejected_before_anything_is_written(store, tenan
def test_a_failed_write_leaves_no_audit_record_and_no_version_bump(store, tenant):
set_override(store, tenant, eur(9_000))
with pytest.raises(VersionConflictError):
set_override(store, tenant, eur(1), version=99, change_id="c-2",
idempotency_key="idem-2")
set_override(store, tenant, eur(1), version=99, change_id="c-2", idempotency_key="idem-2")
assert store.get_tenant(tenant.tenant_id).version == 2
assert len(store.guardrail_changes(tenant.tenant_id)) == 1
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000
@ -265,6 +267,5 @@ def test_clearing_an_override_that_would_loosen_is_refused_while_retired(store,
)
# clearing would fall back to small's 25_000 default -- a loosening
with pytest.raises(TenantRetiredError):
set_override(store, tenant, None, version=3, change_id="c-3",
idempotency_key="idem-3")
set_override(store, tenant, None, version=3, change_id="c-3", idempotency_key="idem-3")
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 100

View file

@ -0,0 +1,68 @@
"""TEN-WP-0011-T01/T02: layer declaration and published PEP stance."""
from __future__ import annotations
import importlib.util
import subprocess
import sys
from pathlib import Path
import yaml
from tenant_engine.stance import published_stance, shipped_stance
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "check_layer_conformance.py"
def _run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True)
def test_layer_yaml_declares_engine_pip():
data = yaml.safe_load((ROOT / "layer.yaml").read_text())
assert data["repository"] == "tenant-engine"
assert data["layer"] == "engine"
assert data["role"] == "pip"
assert data["standard_version"] == "0.7"
assert data["tooling_contacts"] == []
assert data["pep_stance"] == "pep-stance.yaml"
assert data["pip_claims"] == "pip-claims.yaml"
ids = {c["id"] for c in data["non_tooling_clients"]}
assert "postgres-own-store" in ids
assert "sqlite-dev-store" in ids
assert "access-engine-check" in ids
assert "state-hub-work-records" in ids
def test_intent_frontmatter_agrees_with_layer_yaml():
intent = yaml.safe_load((ROOT / "INTENT.md").read_text().split("---", 2)[1])
decl = yaml.safe_load((ROOT / "layer.yaml").read_text())
assert str(intent["layer"]).lower() == str(decl["layer"]).lower()
assert str(intent["role"]).lower() == str(decl["role"]).lower()
def test_checker_passes_on_the_real_tree():
result = _run()
assert result.returncode == 0, result.stderr + result.stdout
def test_checker_catches_an_undeclared_openbao_client(tmp_path, monkeypatch):
spec = importlib.util.spec_from_file_location("check_layer_conformance", SCRIPT)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
fake_src = tmp_path / "src" / "tenant_engine"
fake_src.mkdir(parents=True)
(fake_src / "vault.py").write_text("import hvac\n")
monkeypatch.setattr(module, "SRC", fake_src)
hits = module.scan()
assert hits
assert any(h[1] == "hvac" for h in hits)
def test_published_stance_equals_shipped_behaviour():
assert published_stance() == shipped_stance()
assert set(shipped_stance()) == {"unset", "unreachable", "non_allow", "unknown"}
assert set(shipped_stance().values()) == {"fail_closed"}

View file

@ -212,13 +212,13 @@ def test_retirement_preserves_existing_grant_and_plan_history(store, tenant) ->
# Retirement is not a revocation: history stays queryable for audit and
# so reactivation does not have to reconstruct anything.
assert store.active_roles("t-1") == frozenset({CapabilityRole.CUS})
assert any(event.event_type == "plan_assigned" for event in store.events())
assert any(event.event_type == "plan_assigned" for event in store.events_for(tenant.tenant_id))
def test_mutation_emits_a_correlated_audit_event(store, tenant) -> None:
_rename(store, key="k1", version=1)
event = [e for e in store.events() if e.event_type == "tenant_updated"][-1]
event = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "tenant_updated"][-1]
assert event.payload["actor"] == "ops"
assert event.payload["reason"] == "rename"
assert event.payload["correlation_id"] == "corr-1"

View file

@ -0,0 +1,222 @@
"""TEN-WP-0011-T02: decision records, stance, no verdict cache."""
from __future__ import annotations
import httpx
from fastapi.testclient import TestClient
from tenant_engine.app import create_app
from tenant_engine.authz import FlexAuthWriteAuthorizer
from tenant_engine.config import Settings
from tenant_engine.domain import Tenant
from tenant_engine.flex_auth import FlexAuthCheckClient
from tenant_engine.stance import FAIL_CLOSED
from tenant_engine.store import InMemoryTenantStore
def _allowing_client(handler) -> FlexAuthCheckClient:
return FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
)
def test_granted_role_persists_decision_id_on_the_event():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"id": "decision:grant-1",
"effect": "allow",
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
)
client = TestClient(app)
assert (
client.post(
"/tenants",
json={
"tenant_id": "t-1",
"identifier": "tenant:friendly:binky",
"actor": "tenant-engine",
},
).status_code
== 201
)
granted = client.post(
"/tenants/t-1/roles/grant",
json={
"grant_id": "g-1",
"role": "CUS",
"grant_reason": "manual_grant",
"granted_by": "ops",
"correlation_id": "c-1",
"actor": "tenant-engine",
},
)
assert granted.status_code == 201
event = [e for e in store.events_for("t-1") if e.event_type == "role_granted"][-1]
assert event.payload["authorization_decision_id"] == "decision:grant-1"
assert event.payload["authorization_source"] == "decision"
records = store.authorization_records("t-1")
assert any(r.decision_id == "decision:grant-1" and r.allowed for r in records)
def test_denied_grant_leaves_a_reconstructable_record():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"id": "decision:deny-1",
"effect": "deny",
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
)
response = TestClient(app).post(
"/tenants/t-1/roles/grant",
json={
"grant_id": "g-1",
"role": "CUS",
"grant_reason": "manual_grant",
"granted_by": "ops",
"correlation_id": "c-1",
"actor": "tenant-engine",
},
)
assert response.status_code == 403
records = store.authorization_records("t-1")
assert records
assert records[-1].allowed is False
assert records[-1].decision_id == "decision:deny-1"
assert records[-1].source == "decision"
assert store.events_for("t-1") # tenant_created only; no role_granted
assert not any(e.event_type == "role_granted" for e in store.events_for("t-1"))
def test_unreachable_engine_records_fail_closed_stance():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request)
store = InMemoryTenantStore()
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
settings=Settings(
flex_auth_base_url="https://flex-auth.example.test",
flex_auth_timeout_seconds=1,
host="127.0.0.1",
port=8090,
),
)
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "tenant-engine"},
)
assert response.status_code == 403
records = store.authorization_records("t-1")
assert records[-1].allowed is False
assert records[-1].source == "stance"
assert records[-1].stance == FAIL_CLOSED
def test_unset_authorizer_records_fail_closed_stance():
store = InMemoryTenantStore()
app = create_app(store=store) # DefaultDeny
response = TestClient(app).post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
)
assert response.status_code == 403
records = store.authorization_records("t-1")
assert records[-1].source == "stance"
assert records[-1].stance == FAIL_CLOSED
def test_verdict_is_not_cached_across_requests():
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(
200,
json={
"id": f"d-{calls['n']}",
"effect": "allow",
"resource": {},
"subject": {},
"provenance": {},
},
)
authorizer = FlexAuthWriteAuthorizer(client=_allowing_client(handler))
authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="tenant-engine")
authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="tenant-engine")
assert calls["n"] == 2
def test_a_previous_allow_cannot_authorize_a_different_request():
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content)
seen.append(body["action"])
effect = "allow" if body["action"] == "tenant.create" else "deny"
return httpx.Response(
200,
json={
"id": f"d-{body['action']}",
"effect": effect,
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
app = create_app(
store=store,
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
)
client = TestClient(app)
assert (
client.post(
"/tenants",
json={
"tenant_id": "t-1",
"identifier": "tenant:friendly:binky",
"actor": "tenant-engine",
},
).status_code
== 201
)
denied = client.post(
"/tenants/t-1/roles/grant",
json={
"grant_id": "g-1",
"role": "CUS",
"grant_reason": "manual_grant",
"granted_by": "ops",
"correlation_id": "c-1",
"actor": "tenant-engine",
},
)
assert denied.status_code == 403
assert seen == ["tenant.create", "tenant.role.grant"]

91
tests/test_pip_claims.py Normal file
View file

@ -0,0 +1,91 @@
"""TEN-WP-0011-T03: PIP claim freshness and live-lookup non-reentry."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
import httpx
import yaml
from fastapi.testclient import TestClient
from tenant_engine.app import create_app
from tenant_engine.authz import FlexAuthWriteAuthorizer
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
from tenant_engine.flex_auth import FlexAuthCheckClient
from tenant_engine.store import InMemoryTenantStore
ROOT = Path(__file__).resolve().parents[1]
def test_pip_claims_contract_is_published():
data = yaml.safe_load((ROOT / "pip-claims.yaml").read_text())
assert data["role"] == "pip"
classes = data["input_classes"]
assert classes["tenant_roles_live"]["cross_request_cache_by_consumer"] is False
assert classes["tenant_roles_live"]["request_scoped_memoization"] is True
assert data["degradation"]["store_unavailable"]["http_status"] == 503
assert data["live_lookup_authorization"]["reenters_tenant_engine"] is False
assert data["live_lookup_authorization"]["check_consumes_tenant_roles"] is False
def test_live_lookup_hits_the_store_every_request():
store = InMemoryTenantStore()
tenant = Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky")
store.create_tenant(tenant)
store.grant_role(
create_role_grant(
tenant=tenant,
grant_id="g-1",
role=CapabilityRole.CUS,
grant_reason="manual_grant",
plan_id=None,
granted_by="ops",
correlation_id="c-1",
granted_at=datetime.now(UTC),
)
)
calls = {"n": 0}
original = store.active_roles
def counting(tenant_id: str):
calls["n"] += 1
return original(tenant_id)
store.active_roles = counting # type: ignore[method-assign]
from helpers import AllowAllAuthorizer
client = TestClient(create_app(store=store, authorizer=AllowAllAuthorizer()))
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
assert calls["n"] == 2
def test_live_lookup_check_does_not_reenter_tenant_engine():
"""The authorize call for /roles/live POSTs /v1/check and never GETs us."""
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(f"{request.method} {request.url.path}")
return httpx.Response(
200,
json={
"id": "d-live",
"effect": "allow",
"resource": {},
"subject": {},
"provenance": {},
},
)
store = InMemoryTenantStore()
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
)
app = create_app(store=store, authorizer=FlexAuthWriteAuthorizer(client=client))
response = TestClient(app).get("/tenants/t-1/roles/live", params={"actor": "flex-auth"})
assert response.status_code == 200
assert seen == ["POST /v1/check"]
assert not any("/roles/live" in item for item in seen)
assert not any("/tenants/" in item for item in seen)

View file

@ -11,7 +11,9 @@ from tenant_engine.store import (
)
def _store_with_tenant(*, grouping: str = "friendly", name: str = "binky") -> tuple[InMemoryTenantStore, Tenant]:
def _store_with_tenant(
*, grouping: str = "friendly", name: str = "binky"
) -> tuple[InMemoryTenantStore, Tenant]:
store = InMemoryTenantStore()
tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}")
store.create_tenant(tenant)
@ -93,15 +95,22 @@ def test_non_exclusive_roles_coexist() -> None:
)
)
assert store.active_roles(tenant.tenant_id) == frozenset({CapabilityRole.CUS, CapabilityRole.VEN})
assert store.active_roles(tenant.tenant_id) == frozenset(
{CapabilityRole.CUS, CapabilityRole.VEN}
)
def test_assign_plan() -> None:
store, tenant = _store_with_tenant()
store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)))
store.assign_plan(
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
)
events = store.events()
assert any(event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x" for event in events)
events = store.events_for(tenant.tenant_id)
assert any(
event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x"
for event in events
)
def test_get_tenant_resolves_by_identifier_not_only_internal_id() -> None:
@ -181,7 +190,9 @@ def test_every_mutation_emits_an_event() -> None:
)
store.grant_role(grant)
store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC))
store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)))
store.assign_plan(
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
)
event_types = [event.event_type for event in store.events()]
event_types = [event.event_type for event in store.events_for(tenant.tenant_id)]
assert event_types == ["tenant_created", "role_granted", "role_revoked", "plan_assigned"]

2
uv.lock generated
View file

@ -482,6 +482,7 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pyyaml" },
{ name = "ruff" },
]
postgres = [
@ -496,6 +497,7 @@ requires-dist = [
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" },
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2,<9.0" },
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6,<1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" },
]

View file

@ -4,7 +4,7 @@ type: workplan
title: "Align tenant-engine with the accepted security layer model"
domain: infotech
repo: tenant-engine
status: ready
status: finished
owner: grok
topic_slug: netkingdom
created: "2026-08-29"
@ -58,7 +58,7 @@ code.
```task
id: TEN-WP-0011-T01
status: todo
status: done
priority: high
state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72"
```
@ -81,7 +81,7 @@ in `src/`.
```task
id: TEN-WP-0011-T02
status: todo
status: done
priority: high
state_hub_task_id: "62659305-8283-594e-8670-f9335f5c74a0"
```
@ -114,7 +114,7 @@ file == code; no write path can succeed without one of those two records.
```task
id: TEN-WP-0011-T03
status: todo
status: done
priority: high
state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111"
```
@ -145,7 +145,7 @@ evidence, not a comment.
```task
id: TEN-WP-0011-T04
status: todo
status: done
priority: high
state_hub_task_id: "cfab6837-5f62-5beb-b99f-943d39b5b94d"
```
@ -178,11 +178,16 @@ Done when: production mutations emit to `audit-core` under the bound above;
the trade (atomic vs non-blocking) is declared; `TEN-IN-0001` can close as
promoted and completed.
**Done 2026-08-29:** local outbox is in the mutation transaction; drain
POSTs `/v1/events` and never fails the mutation; trade declared in
`docs/evidence-emission.md`; no pre-cutover backfill. Production landing
waits on sender registration, requested as `AUDIT-IN-0002`.
## T05 — Remove or authorize the unfiltered event-read interface
```task
id: TEN-WP-0011-T05
status: todo
status: done
priority: high
state_hub_task_id: "7540049b-9b76-5d16-9ce4-57395a53ecbc"
```
@ -202,11 +207,15 @@ the dump.
Done when: a production caller cannot list another tenant's events through
this repo's store protocol; tests say so; `TEN-IN-0002` can close.
**Done 2026-08-29:** `events()` removed from the production protocol;
`events_for(tenant_id)` is the only read. Cross-tenant negative test in
`tests/test_events_scoped.py`. Reading handed to risk-nexus.
## T06 — Request the boundary-contract amendment
```task
id: TEN-WP-0011-T06
status: todo
status: done
priority: medium
state_hub_task_id: "ffd2cc53-bde2-5e3c-a144-ab565bc12776"
```
@ -229,3 +238,6 @@ to:
Done when: the intake exists on the canon side with a named id, and this
task records it.
**Done 2026-08-29:** filed `NET-IN-0002` on `net-kingdom`
(`net-kingdom/intakes/intakes.md`).