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` | | PostgreSQL production store | yes | `TEN-WP-0009`; deploy mounts `TENANT_ENGINE_DATABASE_URL_FILE` |
| Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical | | Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical |
| Staged promotion | **no** | `TEN-WP-0008` | | Staged promotion | **no** | `TEN-WP-0008` |
| Machine-readable `layer.yaml` | **no** | Frontmatter declaration is in `INTENT.md` | | Machine-readable `layer.yaml` | yes | `TEN-WP-0011-T01`; check in `scripts/check_layer_conformance.py` |
| Persisted `authorization_decision_id` | **no** | Boundary contract requires it; `FlexAuthCheckClient` drops the envelope | | Persisted `authorization_decision_id` | yes | `authz_records` plus mutation event payload (`TEN-WP-0011-T02`) |
| Published unreachable-engine stance map | **no** | Behaviour is fail-closed in code; companion §5 requires it published and tested equal to shipped behaviour | | Published unreachable-engine stance map | yes | `pep-stance.yaml`, fail-closed, tested equal to shipped behaviour |
| Independent audit-core emission | **no** | Local `events` table only (`TEN-IN-0001`) | | Independent audit-core emission | path shipped, sender pending | Local outbox + POST `/v1/events`; `AUDIT-IN-0002` |
| Bounded event-read interface | **no** | In-process `TenantStore.events()` is unfiltered (`TEN-IN-0002`) | | Bounded event-read interface | yes | `events_for(tenant_id)` only (`TEN-WP-0011-T05`) |
| Claim freshness / input-class lifetime | **no** | Token lifetime covers the cache-read path by profile convention; live-lookup has no declared deadline | | Claim freshness / input-class lifetime | yes | `pip-claims.yaml` (`TEN-WP-0011-T03`) |
The boundary contract still labels guardrail policy "reserved, not The boundary contract still labels guardrail policy "reserved, not
implemented" and still calls this repo "not a policy enforcement point". 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` `tenant-engine` gates every write, and every authorized read, through
(`flex_auth.FlexAuthCheckClient`, wired in as `authz.FlexAuthWriteAuthorizer`). 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 ## What tenant-engine sends
@ -13,56 +16,98 @@ A `CheckRequest` per `flex-auth/schemas/check_request.schema.json`:
"tenant": "tenant:friendly:binky", "tenant": "tenant:friendly:binky",
"subject": {"id": "<actor>", "type": "service"}, "subject": {"id": "<actor>", "type": "service"},
"action": "tenant.create", "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 Action → resource-type mapping (must match `flex-auth`'s
`FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge): `FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge):
| Action | Resource type | | Action | Resource type | Surface |
| --- | --- | | --- | --- | --- |
| `tenant.create` | `tenant` | | `tenant.create` | `tenant` | write |
| `tenant.role.grant` | `role-grant` | | `tenant.role.grant` | `role-grant` | write |
| `tenant.role.revoke` | `role-grant` | | `tenant.role.revoke` | `role-grant` | write |
| `tenant.plan.assign` | `plan-assignment` | | `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 ## What tenant-engine expects back
A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`. A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`.
Only `effect: "allow"` authorizes the write. Every other `effect` Only `effect: "allow"` authorizes the action. Every other `effect`, a
(`deny`/`redact`/`audit_only`/`not_applicable`), a non-200 response, a non-200 response, a malformed body, or a transport failure/timeout all
malformed body, or a transport failure/timeout all resolve to **deny** resolve to **deny**. `FlexAuthCheckClient.check()` never raises past its
`FlexAuthCheckClient.is_allowed()` never raises past its own boundary; it own boundary; it returns a `CheckResult` that is either a decision or a
always returns a plain `bool`. 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` ## Live-lookup is not cyclic (TEN-WP-0011-T03)
lands (resource/action vocabulary + an authored policy package), **every
check resolves to deny or not_applicable** — verified against real `GET /tenants/{id}/roles/live` authorizes via `tenant.role.read.live` then
`flex-auth` behavior, not assumed. This is correct fail-closed behavior, reads the store. That check does **not** re-enter tenant-engine:
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 1. The CheckRequest this engine sends has empty `context` and does not
HTTP double standing in for `flex-auth` (deny → `403`, allow → `201`, carry `tenant_roles`.
both over real HTTP between two processes) — see `TEN-WP-0003`'s closure 2. `FlexAuthCheckClient` POSTs only `/v1/check`. Proven by
notes for the exact commands. `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 ## Configuration
| Env var | Default | Meaning | | 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_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 write path — no retries, so a slow deny doesn't become a hang. | | `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 ## 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` - `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md`
— the flex-auth-side work this client depends on for real `allow` - `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md`
decisions, and (separately) flex-auth's own live-lookup consumption of - `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md`
`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.

View file

@ -13,7 +13,7 @@ kind: intake
title: "Externalize tenant-engine audit evidence to audit-core" title: "Externalize tenant-engine audit evidence to audit-core"
lane: yellow lane: yellow
status: closed status: closed
outcome: promoted outcome: completed
promoted_to: TEN-WP-0011-T04 promoted_to: TEN-WP-0011-T04
priority: high priority: high
owner: tenant-engine owner: tenant-engine
@ -58,7 +58,7 @@ kind: intake
title: "Remove or authorize the tenant-engine unfiltered event-read interface" title: "Remove or authorize the tenant-engine unfiltered event-read interface"
lane: red lane: red
status: closed status: closed
outcome: promoted outcome: completed
promoted_to: TEN-WP-0011-T05 promoted_to: TEN-WP-0011-T05
priority: high priority: high
owner: tenant-engine 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 = [ dev = [
"pytest>=8.2,<9.0", "pytest>=8.2,<9.0",
"ruff>=0.6,<1.0", "ruff>=0.6,<1.0",
"pyyaml>=6.0,<7.0",
] ]
[project.scripts] [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 pydantic import BaseModel, Field
from tenant_engine import __version__ from tenant_engine import __version__
from tenant_engine.audit_core import AuditCoreClient
from tenant_engine.authz import ( from tenant_engine.authz import (
AuthorizationOutcome,
DefaultDenyWriteAuthorizer, DefaultDenyWriteAuthorizer,
FlexAuthWriteAuthorizer, FlexAuthWriteAuthorizer,
WriteAuthorizationDeniedError, WriteAuthorizationDeniedError,
@ -44,6 +46,7 @@ from tenant_engine.guardrail import (
) )
from tenant_engine.guardrail.serde import UNLIMITED_TOKEN from tenant_engine.guardrail.serde import UNLIMITED_TOKEN
from tenant_engine.store import ( from tenant_engine.store import (
AuthorizationRecord,
GrantNotFoundError, GrantNotFoundError,
IdempotencyConflictError, IdempotencyConflictError,
InMemoryTenantStore, InMemoryTenantStore,
@ -185,9 +188,11 @@ def create_app(
app = FastAPI(title="tenant-engine", version=__version__) app = FastAPI(title="tenant-engine", version=__version__)
app.state.store = store app.state.store = store
app.state.authorizer = authorizer app.state.authorizer = authorizer
app.state.audit_core = _build_audit_client(settings)
@app.exception_handler(WriteAuthorizationDeniedError) @app.exception_handler(WriteAuthorizationDeniedError)
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse: async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
_persist_authz(store, exc.outcome)
return JSONResponse( return JSONResponse(
status_code=403, status_code=403,
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason}, content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
@ -233,7 +238,7 @@ def create_app(
response: Response, response: Response,
actor: str = Query(min_length=1), actor: str = Query(min_length=1),
) -> dict: ) -> 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: try:
tenant = store.get_tenant(tenant_id) tenant = store.get_tenant(tenant_id)
except TenantNotFoundError as exc: except TenantNotFoundError as exc:
@ -249,7 +254,7 @@ def create_app(
@app.get("/tenants/{tenant_id}/roles") @app.get("/tenants/{tenant_id}/roles")
async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict: 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) return _read_roles(store, tenant_id)
# -- Live-lookup API (flex-auth, for aal2-class decisions) ----------- # -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
@ -261,7 +266,9 @@ def create_app(
@app.get("/tenants/{tenant_id}/roles/live") @app.get("/tenants/{tenant_id}/roles/live")
async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict: 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) return _read_roles(store, tenant_id)
# -- Write API (grant/revoke/plan mutation) --------------------------- # -- Write API (grant/revoke/plan mutation) ---------------------------
@ -270,7 +277,13 @@ def create_app(
@app.post("/tenants", status_code=201) @app.post("/tenants", status_code=201)
async def create_tenant(payload: CreateTenantRequest) -> dict: 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: try:
tenant = Tenant.create( tenant = Tenant.create(
tenant_id=payload.tenant_id, tenant_id=payload.tenant_id,
@ -279,7 +292,8 @@ def create_app(
contact_email=payload.contact_email, contact_email=payload.contact_email,
created_at=datetime.now(UTC), 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: except InvalidTenantIdentifierError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except TenantAlreadyExistsError as exc: except TenantAlreadyExistsError as exc:
@ -290,7 +304,9 @@ def create_app(
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201) @app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict: 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: try:
tenant = store.get_tenant(tenant_id) tenant = store.get_tenant(tenant_id)
grant = create_role_grant( grant = create_role_grant(
@ -303,7 +319,8 @@ def create_app(
correlation_id=payload.correlation_id, correlation_id=payload.correlation_id,
granted_at=datetime.now(UTC), 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: except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc: except TenantRetiredError as exc:
@ -314,9 +331,17 @@ def create_app(
@app.post("/tenants/{tenant_id}/roles/revoke") @app.post("/tenants/{tenant_id}/roles/revoke")
async def revoke_role(tenant_id: str, payload: RevokeRoleRequest) -> dict: 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: 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: except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except GrantNotFoundError as exc: except GrantNotFoundError as exc:
@ -327,11 +352,17 @@ def create_app(
@app.post("/tenants/{tenant_id}/plan") @app.post("/tenants/{tenant_id}/plan")
async def assign_plan(tenant_id: str, payload: AssignPlanRequest) -> dict: 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: try:
store.assign_plan( 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: except TenantNotFoundError as exc:
raise HTTPException(status_code=404, detail="tenant_not_found") from exc raise HTTPException(status_code=404, detail="tenant_not_found") from exc
except TenantRetiredError as exc: except TenantRetiredError as exc:
@ -366,6 +397,7 @@ def create_app(
event_type="tenant_updated", event_type="tenant_updated",
extra_fingerprint=changes, extra_fingerprint=changes,
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at), mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
audit_core=app.state.audit_core,
) )
@app.post("/tenants/{tenant_id}/retire") @app.post("/tenants/{tenant_id}/retire")
@ -390,6 +422,7 @@ def create_app(
event_type="tenant_retired", event_type="tenant_retired",
extra_fingerprint={}, extra_fingerprint={},
mutate=lambda tenant, at: tenant.retire(at=at), mutate=lambda tenant, at: tenant.retire(at=at),
audit_core=app.state.audit_core,
) )
@app.post("/tenants/{tenant_id}/reactivate") @app.post("/tenants/{tenant_id}/reactivate")
@ -414,6 +447,7 @@ def create_app(
event_type="tenant_reactivated", event_type="tenant_reactivated",
extra_fingerprint={}, extra_fingerprint={},
mutate=lambda tenant, at: tenant.reactivate(at=at), mutate=lambda tenant, at: tenant.reactivate(at=at),
audit_core=app.state.audit_core,
) )
# -- Reclassification (TEN-WP-0010) ----------------------------------- # -- Reclassification (TEN-WP-0010) -----------------------------------
@ -443,6 +477,7 @@ def create_app(
event_type="tenant_grouping_changed", event_type="tenant_grouping_changed",
extra_fingerprint={"grouping": payload.grouping}, extra_fingerprint={"grouping": payload.grouping},
mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at), mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at),
audit_core=app.state.audit_core,
) )
# -- Guardrail API (TEN-WP-0006) -------------------------------------- # -- Guardrail API (TEN-WP-0006) --------------------------------------
@ -451,7 +486,9 @@ def create_app(
@app.get("/tenants/{tenant_id}/guardrails") @app.get("/tenants/{tenant_id}/guardrails")
async def read_guardrails(tenant_id: str, actor: str) -> dict: 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: try:
tenant = store.get_tenant(tenant_id) tenant = store.get_tenant(tenant_id)
overrides = store.guardrail_overrides(tenant_id) overrides = store.guardrail_overrides(tenant_id)
@ -487,9 +524,7 @@ def create_app(
try: try:
value = load_limit(payload.limit.model_dump()) value = load_limit(payload.limit.model_dump())
except (InvalidLimitError, ValueError) as exc: except (InvalidLimitError, ValueError) as exc:
raise LifecycleError( raise LifecycleError(400, "invalid_limit", str(exc), payload.correlation_id) from exc
400, "invalid_limit", str(exc), payload.correlation_id
) from exc
return _guardrail_mutation( return _guardrail_mutation(
store=store, store=store,
authorizer=authorizer, authorizer=authorizer,
@ -502,6 +537,7 @@ def create_app(
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
if_match=if_match, if_match=if_match,
response=response, response=response,
audit_core=app.state.audit_core,
) )
@app.delete("/tenants/{tenant_id}/guardrails/{limit_key}") @app.delete("/tenants/{tenant_id}/guardrails/{limit_key}")
@ -525,6 +561,7 @@ def create_app(
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
if_match=if_match, if_match=if_match,
response=response, response=response,
audit_core=app.state.audit_core,
) )
return app return app
@ -553,6 +590,7 @@ def _guardrail_mutation(
idempotency_key: str | None, idempotency_key: str | None,
if_match: str | None, if_match: str | None,
response: Response, response: Response,
audit_core: AuditCoreClient | None = None,
) -> dict: ) -> dict:
"""Shared spine for setting and clearing an override. """Shared spine for setting and clearing an override.
@ -566,7 +604,9 @@ def _guardrail_mutation(
) )
expected_version = _parse_if_match(if_match, correlation_id) 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( fingerprint = hashlib.sha256(
json.dumps( json.dumps(
@ -585,9 +625,9 @@ def _guardrail_mutation(
# Derived, not random: a genuine retry must produce the same change_id so # 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 # the replay path returns the original audit record rather than minting a
# second one for a mutation that happened once. # second one for a mutation that happened once.
change_id = hashlib.sha256( change_id = hashlib.sha256(f"{tenant_id}:{limit_key}:{idempotency_key}".encode()).hexdigest()[
f"{tenant_id}:{limit_key}:{idempotency_key}".encode() :32
).hexdigest()[:32] ]
try: try:
tenant, change, replayed = store.set_guardrail_override( tenant, change, replayed = store.set_guardrail_override(
@ -602,7 +642,9 @@ def _guardrail_mutation(
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
request_fingerprint=fingerprint, request_fingerprint=fingerprint,
at=datetime.now(UTC), at=datetime.now(UTC),
authz=outcome.as_payload(),
) )
_drain_outbox(store, audit_core)
except UnknownLimitKeyError as exc: except UnknownLimitKeyError as exc:
raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc
except TenantNotFoundError as exc: except TenantNotFoundError as exc:
@ -622,9 +664,7 @@ def _guardrail_mutation(
correlation_id, correlation_id,
) from exc ) from exc
except TenantRetiredError as exc: except TenantRetiredError as exc:
raise LifecycleError( raise LifecycleError(409, "guardrail_loosening_denied", str(exc), correlation_id) from exc
409, "guardrail_loosening_denied", str(exc), correlation_id
) from exc
except (InvalidLimitError, ConflictingLimitError) as exc: except (InvalidLimitError, ConflictingLimitError) as exc:
raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc
except StoreUnavailableError as exc: except StoreUnavailableError as exc:
@ -704,6 +744,7 @@ def _lifecycle_mutation(
event_type: str, event_type: str,
extra_fingerprint: dict, extra_fingerprint: dict,
mutate, mutate,
audit_core: AuditCoreClient | None = None,
) -> dict: ) -> dict:
"""Shared spine for update/retire/reactivate: authorize, then CAS. """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) 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( fingerprint = hashlib.sha256(
json.dumps( json.dumps(
@ -738,10 +779,16 @@ def _lifecycle_mutation(
expected_version=expected_version, expected_version=expected_version,
mutate=lambda current: mutate(current, now), mutate=lambda current: mutate(current, now),
event_type=event_type, 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, idempotency_key=idempotency_key,
request_fingerprint=fingerprint, request_fingerprint=fingerprint,
) )
_drain_outbox(store, audit_core)
except TenantNotFoundError as exc: except TenantNotFoundError as exc:
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
except IdempotencyConflictError as exc: except IdempotencyConflictError as exc:
@ -787,6 +834,69 @@ def _build_authorizer(settings: Settings) -> WriteAuthorizer:
return FlexAuthWriteAuthorizer(client=client) 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: def _read_roles(store: TenantStore, tenant_id: str) -> dict:
try: try:
roles = store.active_roles(tenant_id) 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 __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.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] = { _RESOURCE_TYPES: dict[str, str] = {
"tenant.read": "tenant", "tenant.read": "tenant",
"tenant.create": "tenant", "tenant.create": "tenant",
@ -15,63 +14,88 @@ _RESOURCE_TYPES: dict[str, str] = {
"tenant.role.read": "role-grant", "tenant.role.read": "role-grant",
"tenant.role.read.live": "role-grant", "tenant.role.read.live": "role-grant",
"tenant.plan.assign": "plan-assignment", "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.update": "tenant",
"tenant.retire": "tenant", "tenant.retire": "tenant",
"tenant.reactivate": "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.read": "guardrail",
"tenant.guardrail.set": "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", "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): class WriteAuthorizationDeniedError(Exception):
def __init__(self, action: str, reason: str = "denied") -> None: def __init__(self, outcome: AuthorizationOutcome) -> None:
super().__init__(f"{action}: {reason}") super().__init__(f"{outcome.action}: {outcome.reason}")
self.action = action self.action = outcome.action
self.reason = reason self.reason = outcome.reason
self.outcome = outcome
class WriteAuthorizer(Protocol): 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 -- def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
flex-auth is meant to gate them. A real flex-auth integration is an """Return an allow record, or raise WriteAuthorizationDeniedError."""
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."""
... ...
class DefaultDenyWriteAuthorizer: 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: def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
raise WriteAuthorizationDeniedError( outcome = AuthorizationOutcome(
action, "no flex-auth integration configured (default-deny stub)" 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: 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 Does not cache verdicts. A previous allow cannot authorize a later
check resolves to deny -- that's the correct fail-closed behavior, not request every call is a new check.
a bug in this client (see flex_auth.FlexAuthCheckClient).
""" """
def __init__(self, *, client: FlexAuthCheckClient) -> None: def __init__(self, *, client: FlexAuthCheckClient) -> None:
self._client = client 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") resource_type = _RESOURCE_TYPES.get(action, "tenant")
request = CheckRequest( request = CheckRequest(
request_id=new_request_id(), request_id=new_request_id(),
@ -82,5 +106,19 @@ class FlexAuthWriteAuthorizer:
resource_id=tenant_id, resource_id=tenant_id,
resource_type=resource_type, resource_type=resource_type,
) )
if not self._client.is_allowed(request): result = self._client.check(request)
raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check") 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_path: str | None = None
database_url_file: str | None = None database_url_file: str | None = None
flex_auth_token_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 @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
return cls( return cls(
flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None, 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"), host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")), port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None, database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None,
database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") 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, 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 # resolve guardrails through the reserved profile. Giving one a
# grouping would silently move the platform's own identity onto # grouping would silently move the platform's own identity onto
# the grouping ladder. # the grouping ladder.
raise ImmutableFieldError( raise ImmutableFieldError("reserved tenants are ungrouped and cannot be reclassified")
"reserved tenants are ungrouped and cannot be reclassified"
)
if self.lifecycle is not TenantLifecycle.ACTIVE: if self.lifecycle is not TenantLifecycle.ACTIVE:
raise InvalidLifecycleTransitionError( raise InvalidLifecycleTransitionError(
"grouping of a retired tenant cannot be changed; reactivate first" "grouping of a retired tenant cannot be changed; reactivate first"

View file

@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
import httpx import httpx
# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json) from tenant_engine.stance import FAIL_CLOSED
# allows five effects; only "allow" authorizes anything.
ALLOW_EFFECT = "allow" ALLOW_EFFECT = "allow"
@ -45,15 +48,35 @@ class CheckRequest:
"context": self.context, "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: class FlexAuthCheckClient:
"""Client for flex-auth's POST /v1/check. """Client for flex-auth's POST /v1/check.
Fail-closed by construction: every non-"allow" effect, every non-2xx Fail-closed by construction. Every non-allow effect, every non-2xx,
response, every malformed body, and every transport failure (timeout, every malformed body, and every transport failure resolves to a
connection error) resolves to `False` from `is_allowed()`. Nothing CheckResult with allowed=False. Nothing raises past this boundary.
raises past this boundary -- callers (the WriteAuthorizer seam) get a Verdicts are never cached: every call is a new POST.
plain deny, not an exception to handle inconsistently.
""" """
def __init__( def __init__(
@ -73,33 +96,85 @@ class FlexAuthCheckClient:
transport=transport, transport=transport,
) )
def is_allowed(self, request: CheckRequest) -> bool: def check(self, request: CheckRequest) -> CheckResult:
digest = request.digest()
try: try:
headers: dict[str, str] = {} headers: dict[str, str] = {}
if self.bearer_token_file: 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: with open(self.bearer_token_file, encoding="utf-8") as token_file:
token = token_file.read().strip() token = token_file.read().strip()
if not token: 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}" headers["Authorization"] = f"Bearer {token}"
response = self._client.post("/v1/check", json=request.to_json(), headers=headers) response = self._client.post("/v1/check", json=request.to_json(), headers=headers)
except (httpx.HTTPError, OSError): 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: 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: try:
envelope = response.json() envelope = response.json()
except ValueError: 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): 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: def close(self) -> None:
self._client.close() self._client.close()

View file

@ -19,6 +19,7 @@ except ImportError: # pragma: no cover - exercised by the deployment guard
ConnectionPool = None ConnectionPool = None
PoolTimeout = None PoolTimeout = None
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import ( from tenant_engine.domain import (
CapabilityRole, CapabilityRole,
PlanAssignment, PlanAssignment,
@ -35,9 +36,11 @@ from tenant_engine.guardrail import (
load_limit, load_limit,
) )
from tenant_engine.store import ( from tenant_engine.store import (
AuthorizationRecord,
DomainEvent, DomainEvent,
GrantNotFoundError, GrantNotFoundError,
IdempotencyConflictError, IdempotencyConflictError,
OutboxRow,
StoreUnavailableError, StoreUnavailableError,
TenantAlreadyExistsError, TenantAlreadyExistsError,
TenantNotFoundError, TenantNotFoundError,
@ -118,7 +121,7 @@ class PostgresTenantStore:
with self._pool.connection() as conn: with self._pool.connection() as conn:
conn.execute("SELECT 1").fetchone() 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: try:
with self._pool.connection() as conn, conn.transaction(): with self._pool.connection() as conn, conn.transaction():
conn.execute( conn.execute(
@ -144,7 +147,7 @@ class PostgresTenantStore:
conn, conn,
"tenant_created", "tenant_created",
tenant.tenant_id, tenant.tenant_id,
{"identifier": tenant.identifier, "grouping": tenant.grouping}, {"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
) )
except StoreUnavailableError as exc: except StoreUnavailableError as exc:
if isinstance(exc.__cause__, UniqueViolation): if isinstance(exc.__cause__, UniqueViolation):
@ -204,15 +207,13 @@ class PostgresTenantStore:
updated.tenant_id, updated.tenant_id,
), ),
) )
self._record_receipt( self._record_receipt(conn, updated, idempotency_key, request_fingerprint)
conn, updated, idempotency_key, request_fingerprint
)
self._emit( self._emit(
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version} conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
) )
return updated, False 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) tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role") self._require_active(tenant, "grant a role")
with self._pool.connection() as conn, conn.transaction(): with self._pool.connection() as conn, conn.transaction():
@ -248,10 +249,13 @@ class PostgresTenantStore:
"role": grant.role.value, "role": grant.role.value,
"grant_reason": grant.grant_reason, "grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id, "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) tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn, conn.transaction(): with self._pool.connection() as conn, conn.transaction():
row = conn.execute( row = conn.execute(
@ -266,7 +270,7 @@ class PostgresTenantStore:
conn, conn,
"role_revoked", "role_revoked",
tenant.tenant_id, tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value}, {"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
) )
return grant return grant
@ -279,7 +283,9 @@ class PostgresTenantStore:
).fetchall() ).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows) 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) tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan") self._require_active(tenant, "assign a plan")
with self._pool.connection() as conn, conn.transaction(): with self._pool.connection() as conn, conn.transaction():
@ -290,17 +296,89 @@ class PostgresTenantStore:
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at), (tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
) )
self._emit( 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: 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 [ return [
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"]) DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
for row in rows 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]: def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
tenant = self.get_tenant(tenant_id) tenant = self.get_tenant(tenant_id)
with self._pool.connection() as conn: with self._pool.connection() as conn:
@ -333,6 +411,7 @@ class PostgresTenantStore:
idempotency_key: str, idempotency_key: str,
request_fingerprint: str, request_fingerprint: str,
at: datetime, at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]: ) -> tuple[Tenant, GuardrailChange | None, bool]:
DEFAULT_REGISTRY.get(limit_key) DEFAULT_REGISTRY.get(limit_key)
with self._pool.connection() as conn, conn.transaction(): with self._pool.connection() as conn, conn.transaction():
@ -428,6 +507,7 @@ class PostgresTenantStore:
"cleared": value is None, "cleared": value is None,
"correlation_id": correlation_id, "correlation_id": correlation_id,
"version": updated.version, "version": updated.version,
**(authz or {}),
}, },
) )
return updated, change, False return updated, change, False
@ -467,9 +547,24 @@ class PostgresTenantStore:
@staticmethod @staticmethod
def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None: 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( conn.execute(
"INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)", "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, "created_at": tenant.created_at.isoformat() if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() if tenant.updated_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, "retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
"reactivated_at": ( "reactivated_at": (tenant.reactivated_at.isoformat() if tenant.reactivated_at else None),
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"], correlation_id=row["correlation_id"],
changed_at=_dt(row["changed_at"]), 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 threading import RLock
from typing import Any from typing import Any
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import ( from tenant_engine.domain import (
CapabilityRole, CapabilityRole,
PlanAssignment, PlanAssignment,
@ -24,9 +25,11 @@ from tenant_engine.guardrail import (
load_limit, load_limit,
) )
from tenant_engine.store import ( from tenant_engine.store import (
AuthorizationRecord,
DomainEvent, DomainEvent,
GrantNotFoundError, GrantNotFoundError,
IdempotencyConflictError, IdempotencyConflictError,
OutboxRow,
TenantAlreadyExistsError, TenantAlreadyExistsError,
TenantNotFoundError, TenantNotFoundError,
VersionConflictError, VersionConflictError,
@ -62,6 +65,19 @@ class SQLiteTenantStore:
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL,
tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload 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 ( CREATE TABLE IF NOT EXISTS idempotency_receipts (
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL, request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
@ -110,7 +126,7 @@ class SQLiteTenantStore:
with self._lock: with self._lock:
self._db.execute("SELECT 1").fetchone() 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: with self._lock, self._db:
try: try:
self._db.execute( self._db.execute(
@ -118,16 +134,31 @@ class SQLiteTenantStore:
contact_email, lifecycle, version, created_at, updated_at, contact_email, lifecycle, version, created_at, updated_at,
retired_at, reactivated_at) retired_at, reactivated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(tenant.tenant_id, tenant.identifier, tenant.grouping, tenant.display_name, (
tenant.contact_email, tenant.lifecycle.value, tenant.version, tenant.tenant_id,
_iso(tenant.created_at), _iso(tenant.updated_at), tenant.identifier,
_iso(tenant.retired_at), _iso(tenant.reactivated_at)), 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: except sqlite3.IntegrityError as exc:
raise TenantAlreadyExistsError(tenant.identifier) from exc raise TenantAlreadyExistsError(tenant.identifier) from exc
self._emit("tenant_created", tenant.tenant_id, { self._emit(
"identifier": tenant.identifier, "grouping": tenant.grouping, "tenant_created",
}) tenant.tenant_id,
{
"identifier": tenant.identifier,
"grouping": tenant.grouping,
**(authz or {}),
},
)
def get_tenant(self, tenant_id: str) -> Tenant: def get_tenant(self, tenant_id: str) -> Tenant:
# Reads take the same lock as writes. One sqlite3 connection is shared # Reads take the same lock as writes. One sqlite3 connection is shared
@ -189,15 +220,27 @@ class SQLiteTenantStore:
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?, contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
retired_at = ?, reactivated_at = ? retired_at = ?, reactivated_at = ?
WHERE tenant_id = ?""", WHERE tenant_id = ?""",
(updated.grouping, updated.display_name, updated.contact_email, (
updated.lifecycle.value, updated.version, _iso(updated.updated_at), updated.grouping,
_iso(updated.retired_at), _iso(updated.reactivated_at), updated.display_name,
updated.tenant_id), 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( self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)", "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}) self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version})
except BaseException: except BaseException:
@ -251,6 +294,7 @@ class SQLiteTenantStore:
idempotency_key: str, idempotency_key: str,
request_fingerprint: str, request_fingerprint: str,
at: datetime, at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]: ) -> tuple[Tenant, GuardrailChange | None, bool]:
tenant = self.get_tenant(tenant_id) tenant = self.get_tenant(tenant_id)
DEFAULT_REGISTRY.get(limit_key) DEFAULT_REGISTRY.get(limit_key)
@ -274,8 +318,11 @@ class SQLiteTenantStore:
replayed = _tenant(json.loads(receipt["result"])) replayed = _tenant(json.loads(receipt["result"]))
self._db.rollback() self._db.rollback()
prior = next( 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, None,
) )
return replayed, prior, True return replayed, prior, True
@ -317,16 +364,29 @@ class SQLiteTenantStore:
dumped = dump_limit(value) dumped = dump_limit(value)
self._db.execute( self._db.execute(
"INSERT OR REPLACE INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)", "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( self._db.execute(
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "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, change.change_id,
json.dumps(dump_limit(change.current)) if change.current else None, change.tenant_id,
change.changed_by, change.reason, change.correlation_id, _iso(at)), 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) updated = replace(current, version=current.version + 1, updated_at=at)
@ -336,35 +396,65 @@ class SQLiteTenantStore:
) )
self._db.execute( self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)", "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: except BaseException:
self._db.rollback() self._db.rollback()
raise raise
self._db.commit() self._db.commit()
return updated, change, False 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) tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role") self._require_active(tenant, "grant a role")
with self._lock, self._db: with self._lock, self._db:
self._db.execute( self._db.execute(
"INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "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.grant_id,
grant.correlation_id, grant.revoked_at.isoformat() if grant.revoked_at else None), 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) tenant = self.get_tenant(tenant_id)
with self._lock: with self._lock:
row = self._db.execute( row = self._db.execute(
@ -375,10 +465,14 @@ class SQLiteTenantStore:
raise GrantNotFoundError(grant_id) raise GrantNotFoundError(grant_id)
grant = self._grant(row).revoke(at=at) grant = self._grant(row).revoke(at=at)
with self._lock, self._db: with self._lock, self._db:
self._db.execute("UPDATE grants SET revoked_at = ? WHERE grant_id = ?", self._db.execute(
(at.isoformat(), grant_id)) "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._emit(
"role_revoked",
tenant.tenant_id,
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
)
return grant return grant
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
@ -390,7 +484,9 @@ class SQLiteTenantStore:
).fetchall() ).fetchall()
return frozenset(CapabilityRole(row["role"]) for row in rows) 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) tenant = self.get_tenant(assignment.tenant_id)
self._require_active(tenant, "assign a plan") self._require_active(tenant, "assign a plan")
with self._lock, self._db: with self._lock, self._db:
@ -398,14 +494,94 @@ class SQLiteTenantStore:
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)", "INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at.isoformat()), (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: with self._lock:
rows = self._db.execute("SELECT * FROM events ORDER BY seq").fetchall() rows = self._db.execute(
return [DomainEvent(row["event_type"], row["tenant_id"], "SELECT * FROM events WHERE tenant_id = ? ORDER BY seq", (tenant.tenant_id,)
datetime.fromisoformat(row["at"]), json.loads(row["payload"])) ).fetchall()
for row in rows] 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 @staticmethod
def _require_active(tenant: Tenant, what: str) -> None: def _require_active(tenant: Tenant, what: str) -> None:
@ -413,22 +589,74 @@ class SQLiteTenantStore:
raise TenantRetiredError(f"cannot {what} on a retired tenant") raise TenantRetiredError(f"cannot {what} on a retired tenant")
def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None: 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() now = datetime.now().astimezone()
self._db.execute("INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)", self._db.execute(
(event_type, tenant_id, now.isoformat(), json.dumps(payload))) "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 @staticmethod
def _grant(row: sqlite3.Row) -> RoleGrant: def _grant(row: sqlite3.Row) -> RoleGrant:
return RoleGrant(row["grant_id"], row["tenant_id"], CapabilityRole(row["role"]), return RoleGrant(
row["grant_reason"], row["plan_id"], row["granted_by"], row["grant_id"],
datetime.fromisoformat(row["granted_at"]), row["correlation_id"], row["tenant_id"],
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None) 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: def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else 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: def _dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else 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 datetime import UTC, datetime
from typing import Any, Protocol from typing import Any, Protocol
from tenant_engine.audit_core import envelope_for, new_event_id
from tenant_engine.domain import ( from tenant_engine.domain import (
CapabilityRole, CapabilityRole,
PlanAssignment, PlanAssignment,
@ -64,6 +65,37 @@ class DomainEvent:
payload: dict[str, Any] 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): class TenantStore(Protocol):
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend. """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). 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 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 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( def mutate_tenant(
self, self,
@ -202,12 +246,14 @@ class InMemoryTenantStore:
self._grants: dict[str, dict[str, RoleGrant]] = {} self._grants: dict[str, dict[str, RoleGrant]] = {}
self._plans: dict[str, PlanAssignment] = {} self._plans: dict[str, PlanAssignment] = {}
self._events: list[DomainEvent] = [] self._events: list[DomainEvent] = []
self._authz: list[AuthorizationRecord] = []
self._outbox: dict[str, OutboxRow] = {}
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot) # (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {} self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
self._overrides: dict[str, dict[str, LimitValue]] = {} self._overrides: dict[str, dict[str, LimitValue]] = {}
self._guardrail_changes: list[GuardrailChange] = [] 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: if tenant.tenant_id in self._tenants:
raise TenantAlreadyExistsError(tenant.tenant_id) raise TenantAlreadyExistsError(tenant.tenant_id)
if tenant.identifier in self._by_identifier: if tenant.identifier in self._by_identifier:
@ -218,13 +264,13 @@ class InMemoryTenantStore:
self._emit( self._emit(
"tenant_created", "tenant_created",
tenant.tenant_id, 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: def get_tenant(self, tenant_id: str) -> Tenant:
return self._tenants[self._resolve(tenant_id)] 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) resolved = self._resolve(grant.tenant_id)
self._require_active(resolved, "grant a role") self._require_active(resolved, "grant a role")
self._grants[resolved][grant.grant_id] = grant self._grants[resolved][grant.grant_id] = grant
@ -236,10 +282,18 @@ class InMemoryTenantStore:
"role": grant.role.value, "role": grant.role.value,
"grant_reason": grant.grant_reason, "grant_reason": grant.grant_reason,
"correlation_id": grant.correlation_id, "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) resolved = self._resolve(tenant_id)
try: try:
grant = self._grants[resolved][grant_id] grant = self._grants[resolved][grant_id]
@ -250,7 +304,7 @@ class InMemoryTenantStore:
self._emit( self._emit(
"role_revoked", "role_revoked",
resolved, resolved,
{"grant_id": grant_id, "role": revoked.role.value}, {"grant_id": grant_id, "role": revoked.role.value, **(authz or {})},
) )
return revoked return revoked
@ -260,14 +314,48 @@ class InMemoryTenantStore:
grant.role for grant in self._grants.get(resolved, {}).values() if grant.active 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) resolved = self._resolve(assignment.tenant_id)
self._require_active(resolved, "assign a plan") self._require_active(resolved, "assign a plan")
self._plans[resolved] = assignment 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]: def events_for(self, tenant_id: str) -> list[DomainEvent]:
return list(self._events) 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( def mutate_tenant(
self, self,
@ -320,6 +408,7 @@ class InMemoryTenantStore:
idempotency_key: str, idempotency_key: str,
request_fingerprint: str, request_fingerprint: str,
at: datetime, at: datetime,
authz: dict[str, Any] | None = None,
) -> tuple[Tenant, GuardrailChange | None, bool]: ) -> tuple[Tenant, GuardrailChange | None, bool]:
resolved = self._resolve(tenant_id) resolved = self._resolve(tenant_id)
@ -342,9 +431,7 @@ class InMemoryTenantStore:
raise VersionConflictError(expected=expected_version, actual=current.version) raise VersionConflictError(expected=expected_version, actual=current.version)
overrides = self._overrides.setdefault(resolved, {}) overrides = self._overrides.setdefault(resolved, {})
guard_guardrail_write( guard_guardrail_write(tenant=current, overrides=overrides, limit_key=limit_key, value=value)
tenant=current, overrides=overrides, limit_key=limit_key, value=value
)
previous = overrides.get(limit_key) previous = overrides.get(limit_key)
change = GuardrailChange( change = GuardrailChange(
@ -377,6 +464,7 @@ class InMemoryTenantStore:
"cleared": value is None, "cleared": value is None,
"correlation_id": correlation_id, "correlation_id": correlation_id,
"version": updated.version, "version": updated.version,
**(authz or {}),
}, },
) )
return updated, change, False return updated, change, False
@ -405,6 +493,19 @@ class InMemoryTenantStore:
return resolved return resolved
def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None: 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( 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: except ImportError:
pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance") 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: 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( connection.execute(
"""TRUNCATE guardrail_changes, guardrail_overrides, """TRUNCATE audit_outbox, authz_records, guardrail_changes, guardrail_overrides,
idempotency_receipts, events, plans, grants, tenants idempotency_receipts, events, plans, grants, tenants
RESTART IDENTITY CASCADE""" RESTART IDENTITY CASCADE"""
) )

View file

@ -2,9 +2,10 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer, deny
from tenant_engine.app import create_app 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 from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
KEY = "spend.monthly" 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"} BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
class _AllowAllAuthorizer(WriteAuthorizer): class _TenantScopedAuthorizer:
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: """Permits guardrail work on exactly one tenant."""
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.
"""
def __init__(self, permitted_tenant: str) -> None: def __init__(self, permitted_tenant: str) -> None:
self._permitted = permitted_tenant 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": if action == "tenant.create":
return return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
if tenant_id != self._permitted: 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): class _BrokenStore(InMemoryTenantStore):
@ -57,7 +40,7 @@ class _BrokenWriteStore(InMemoryTenantStore):
def make_client(authorizer=None, store=None) -> TestClient: def make_client(authorizer=None, store=None) -> TestClient:
app = create_app( app = create_app(
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer() store=store or InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer()
) )
client = TestClient(app) client = TestClient(app)
client.post( 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(): def test_read_is_authorized_separately_from_write():
# a PDP gets the read and nothing else # 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 read(client).status_code == 200
assert put(client).status_code == 403 assert put(client).status_code == 403
def test_write_permission_does_not_confer_read_permission(): 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 read(client).status_code == 403
assert put(client).status_code == 200 assert put(client).status_code == 200
def test_an_unauthorized_read_cannot_probe_tenant_existence(): 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"}) known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"})
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"}) unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
assert known.status_code == unknown.status_code == 403 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(): def test_errors_never_reflect_policy_internals():
client = make_client(_ScopedAuthorizer("tenant.create")) client = make_client(ScopedAuthorizer("tenant.create"))
body = put(client).json() body = put(client).json()
assert "tenant.db" not in str(body) assert "tenant.db" not in str(body)
assert body["error_code"] == "write_denied" assert body["error_code"] == "write_denied"

View file

@ -2,32 +2,15 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer
from tenant_engine.app import create_app from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'} HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-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): class _BrokenStore(InMemoryTenantStore):
def mutate_tenant(self, **kwargs): def mutate_tenant(self, **kwargs):
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused") raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
@ -38,7 +21,7 @@ class _BrokenStore(InMemoryTenantStore):
@pytest.fixture @pytest.fixture
def client() -> TestClient: def client() -> TestClient:
app = create_app(store=InMemoryTenantStore(), authorizer=_AllowAllAuthorizer()) app = create_app(store=InMemoryTenantStore(), authorizer=AllowAllAuthorizer())
test_client = TestClient(app) test_client = TestClient(app)
test_client.post( test_client.post(
"/tenants", "/tenants",
@ -79,9 +62,7 @@ def test_get_tenant_returns_record_and_etag(client) -> None:
def test_get_tenant_resolves_by_identifier(client) -> None: def test_get_tenant_resolves_by_identifier(client) -> None:
response = client.get( response = client.get("/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"})
"/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["tenant_id"] == "t-1" 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: def test_update_permission_does_not_imply_retire_permission() -> None:
app = create_app( app = create_app(
store=InMemoryTenantStore(), store=InMemoryTenantStore(),
authorizer=_ScopedAuthorizer("tenant.create", "tenant.update"), authorizer=ScopedAuthorizer("tenant.create", "tenant.update"),
) )
client = TestClient(app) client = TestClient(app)
client.post( 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: 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"}) read = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
write = client.patch( write = client.patch(

View file

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

View file

@ -1,18 +1,13 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizer
from tenant_engine.store import InMemoryTenantStore 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: def _client(*, allow: bool = False) -> TestClient:
store = InMemoryTenantStore() store = InMemoryTenantStore()
authorizer = _AllowAllAuthorizer() if allow else None authorizer = AllowAllAuthorizer() if allow else None
return TestClient(create_app(store=store, authorizer=authorizer)) 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: def test_create_tenant_duplicate_is_409() -> None:
client = _client(allow=True) 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( response = client.post(
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"} "/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: def test_grant_role_plan_assignment_without_plan_id_is_400() -> None:
client = _client(allow=True) 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( response = client.post(
"/tenants/t-1/roles/grant", "/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 test_flex_auth_authorizer_denies_on_deny_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response: 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( client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) 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: def handler(request: httpx.Request) -> httpx.Response:
return 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( 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 test_flex_auth_authorizer_allows_on_allow_effect() -> None:
def handler(request: httpx.Request) -> httpx.Response: 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( client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) 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: 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( client = FlexAuthCheckClient(
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) 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 test_allow_effect_authorizes() -> None:
def handler(request: httpx.Request) -> httpx.Response: 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 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"]) @pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
def test_non_allow_effects_deny(effect: str) -> None: def test_non_allow_effects_deny(effect: str) -> None:
def handler(request: httpx.Request) -> httpx.Response: 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 assert _client(handler).is_allowed(_request()) is False
@ -83,7 +89,10 @@ def test_request_body_matches_schema_shape() -> None:
import json import json
seen.update(json.loads(request.content)) 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()) _client(handler).is_allowed(_request())

View file

@ -9,9 +9,9 @@ from datetime import UTC, datetime
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer, ScopedAuthorizer
from tenant_engine.app import create_app from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.domain import ( from tenant_engine.domain import (
CapabilityRole, CapabilityRole,
EmptyUpdateError, EmptyUpdateError,
@ -148,22 +148,8 @@ def test_a_new_platform_default_grant_is_refused_after_moving_off_trial():
# --- API ---------------------------------------------------------------- # --- 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: 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 = TestClient(app)
client.post( client.post(
"/tenants", "/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(): def test_the_new_ceiling_is_visible_through_the_guardrail_read():
client = make_client(identifier="tenant:trial:acme") client = make_client(identifier="tenant:trial:acme")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[ assert (
"limits" client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
][KEY]["amount"] == 0 "amount"
]
== 0
)
post_grouping(client, "medium") post_grouping(client, "medium")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[ assert (
"limits" client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
][KEY]["amount"] == 100_000 "amount"
]
== 100_000
)
def test_reclassification_is_authorized_separately_from_a_rename(): def test_reclassification_is_authorized_separately_from_a_rename():
# policy can permit a display-name edit without permitting a move that # policy can permit a display-name edit without permitting a move that
# changes the spend ceiling # 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 post_grouping(client).status_code == 403
assert client.patch( assert (
"/tenants/t-1", client.patch(
json={"metadata": {"display_name": "Acme"}, **BODY}, "/tenants/t-1",
headers=HEADERS, json={"metadata": {"display_name": "Acme"}, **BODY},
).status_code == 200 headers=HEADERS,
).status_code
== 200
)
def test_renaming_permission_is_not_conferred_by_reclassification_permission(): 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 post_grouping(client).status_code == 200
assert client.patch( assert (
"/tenants/t-1", client.patch(
json={"metadata": {"display_name": "Acme"}, **BODY}, "/tenants/t-1",
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'}, json={"metadata": {"display_name": "Acme"}, **BODY},
).status_code == 403 headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
).status_code
== 403
)
def test_an_unknown_grouping_is_a_distinct_error_code(): 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() client = make_client()
post_grouping(client) post_grouping(client)
store = client.app.state.store 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 len(events) == 1
assert events[0].payload["actor"] == "ops" assert events[0].payload["actor"] == "ops"
assert events[0].payload["reason"] == "grew past the band" 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) floor = eur(0)
assert eur(500).reduced_to_floor(floor) == floor assert eur(500).reduced_to_floor(floor) == floor
assert eur(0).reduced_to_floor(eur(500)) == eur(0) assert eur(0).reduced_to_floor(eur(500)) == eur(0)
assert LimitValue( assert (
kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)).amount == 3 .reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3))
.amount
== 3
)
def test_clamping_across_kinds_is_a_conflict(): 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) grouping_only = resolve_limit("spend.monthly", tenant=t)
assert grouping_only.provenance is Provenance.GROUPING assert grouping_only.provenance is Provenance.GROUPING
with_plan = resolve_limit( with_plan = resolve_limit("spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)})
"spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)}
)
assert with_plan.provenance is Provenance.PLAN assert with_plan.provenance is Provenance.PLAN
assert with_plan.value.amount == 60_000 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): def test_a_guardrail_change_emits_a_domain_event(store, tenant):
set_override(store, tenant, eur(9_000)) 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 len(events) == 1
assert events[0].payload["limit_key"] == KEY assert events[0].payload["limit_key"] == KEY
assert events[0].payload["correlation_id"] == "corr-1" 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) entity = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
# spend.monthly is the only registered key, so use it to prove the # spend.monthly is the only registered key, so use it to prove the
# sentinel serialises; the kind check lives in the domain tests # sentinel serialises; the kind check lives in the domain tests
set_override(store, tenant, LimitValue( set_override(
kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M" store,
)) tenant,
LimitValue(kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"),
)
stored = store.guardrail_overrides(tenant.tenant_id)[KEY] stored = store.guardrail_overrides(tenant.tenant_id)[KEY]
assert stored.is_unlimited assert stored.is_unlimited
assert entity.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): def test_a_stale_version_conflicts(store, tenant):
set_override(store, tenant, eur(9_000)) set_override(store, tenant, eur(9_000))
with pytest.raises(VersionConflictError): with pytest.raises(VersionConflictError):
set_override(store, tenant, eur(1_000), version=1, change_id="c-2", set_override(
idempotency_key="idem-2") 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): 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): def test_a_failed_write_leaves_no_audit_record_and_no_version_bump(store, tenant):
set_override(store, tenant, eur(9_000)) set_override(store, tenant, eur(9_000))
with pytest.raises(VersionConflictError): with pytest.raises(VersionConflictError):
set_override(store, tenant, eur(1), version=99, change_id="c-2", set_override(store, tenant, eur(1), version=99, change_id="c-2", idempotency_key="idem-2")
idempotency_key="idem-2")
assert store.get_tenant(tenant.tenant_id).version == 2 assert store.get_tenant(tenant.tenant_id).version == 2
assert len(store.guardrail_changes(tenant.tenant_id)) == 1 assert len(store.guardrail_changes(tenant.tenant_id)) == 1
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000 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 # clearing would fall back to small's 25_000 default -- a loosening
with pytest.raises(TenantRetiredError): with pytest.raises(TenantRetiredError):
set_override(store, tenant, None, version=3, change_id="c-3", set_override(store, tenant, None, version=3, change_id="c-3", idempotency_key="idem-3")
idempotency_key="idem-3")
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 100 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 # Retirement is not a revocation: history stays queryable for audit and
# so reactivation does not have to reconstruct anything. # so reactivation does not have to reconstruct anything.
assert store.active_roles("t-1") == frozenset({CapabilityRole.CUS}) 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: def test_mutation_emits_a_correlated_audit_event(store, tenant) -> None:
_rename(store, key="k1", version=1) _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["actor"] == "ops"
assert event.payload["reason"] == "rename" assert event.payload["reason"] == "rename"
assert event.payload["correlation_id"] == "corr-1" 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() store = InMemoryTenantStore()
tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}") tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}")
store.create_tenant(tenant) 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: def test_assign_plan() -> None:
store, tenant = _store_with_tenant() 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() 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) 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: 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.grant_role(grant)
store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC)) 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"] 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] [package.optional-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pyyaml" },
{ name = "ruff" }, { name = "ruff" },
] ]
postgres = [ postgres = [
@ -496,6 +497,7 @@ requires-dist = [
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" },
{ name = "psycopg-pool", 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 = "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 = "ruff", marker = "extra == 'dev'", specifier = ">=0.6,<1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<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" title: "Align tenant-engine with the accepted security layer model"
domain: infotech domain: infotech
repo: tenant-engine repo: tenant-engine
status: ready status: finished
owner: grok owner: grok
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-08-29" created: "2026-08-29"
@ -58,7 +58,7 @@ code.
```task ```task
id: TEN-WP-0011-T01 id: TEN-WP-0011-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72" state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72"
``` ```
@ -81,7 +81,7 @@ in `src/`.
```task ```task
id: TEN-WP-0011-T02 id: TEN-WP-0011-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "62659305-8283-594e-8670-f9335f5c74a0" 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 ```task
id: TEN-WP-0011-T03 id: TEN-WP-0011-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111" state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111"
``` ```
@ -145,7 +145,7 @@ evidence, not a comment.
```task ```task
id: TEN-WP-0011-T04 id: TEN-WP-0011-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "cfab6837-5f62-5beb-b99f-943d39b5b94d" 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 the trade (atomic vs non-blocking) is declared; `TEN-IN-0001` can close as
promoted and completed. 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 ## T05 — Remove or authorize the unfiltered event-read interface
```task ```task
id: TEN-WP-0011-T05 id: TEN-WP-0011-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "7540049b-9b76-5d16-9ce4-57395a53ecbc" 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 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. 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 ## T06 — Request the boundary-contract amendment
```task ```task
id: TEN-WP-0011-T06 id: TEN-WP-0011-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "ffd2cc53-bde2-5e3c-a144-ab565bc12776" 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 Done when: the intake exists on the canon side with a named id, and this
task records it. task records it.
**Done 2026-08-29:** filed `NET-IN-0002` on `net-kingdom`
(`net-kingdom/intakes/intakes.md`).