From 672cf4da6eb39de132014c65ceed15adc1cd5ed5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 29 Aug 2026 13:02:51 +0200 Subject: [PATCH] Implement TEN-WP-0011 security layer conformance 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 --- SCOPE.md | 12 +- docs/evidence-emission.md | 61 ++++ docs/flex-auth-integration.md | 115 ++++-- docs/intakes/residuals.md | 4 +- layer.yaml | 84 +++++ migrations/postgres/0002_authz_and_outbox.sql | 33 ++ pep-stance.yaml | 50 +++ pip-claims.yaml | 80 +++++ pyproject.toml | 1 + scripts/check_layer_conformance.py | 149 ++++++++ src/tenant_engine/app.py | 158 ++++++-- src/tenant_engine/audit_core.py | 106 ++++++ src/tenant_engine/authz.py | 106 ++++-- src/tenant_engine/config.py | 12 +- src/tenant_engine/domain.py | 4 +- src/tenant_engine/flex_auth.py | 107 +++++- src/tenant_engine/postgres_store.py | 154 +++++++- src/tenant_engine/sqlite_store.py | 336 +++++++++++++++--- src/tenant_engine/stance.py | 49 +++ src/tenant_engine/store.py | 137 ++++++- tests/helpers.py | 48 +++ tests/postgres_backend.py | 7 +- tests/test_api_guardrails.py | 43 +-- tests/test_api_lifecycle.py | 29 +- tests/test_api_reads.py | 20 +- tests/test_api_writes.py | 17 +- tests/test_audit_core.py | 84 +++++ tests/test_authz_flex.py | 24 +- tests/test_events_scoped.py | 61 ++++ tests/test_flex_auth.py | 15 +- tests/test_grouping_mutation.py | 68 ++-- tests/test_guardrail_domain.py | 13 +- tests/test_guardrail_store_conformance.py | 21 +- tests/test_layer_conformance.py | 68 ++++ tests/test_lifecycle_store_conformance.py | 4 +- tests/test_pep_write_path.py | 222 ++++++++++++ tests/test_pip_claims.py | 91 +++++ tests/test_store.py | 25 +- uv.lock | 2 + .../TEN-WP-0011-security-layer-conformance.md | 26 +- 40 files changed, 2285 insertions(+), 361 deletions(-) create mode 100644 docs/evidence-emission.md create mode 100644 layer.yaml create mode 100644 migrations/postgres/0002_authz_and_outbox.sql create mode 100644 pep-stance.yaml create mode 100644 pip-claims.yaml create mode 100644 scripts/check_layer_conformance.py create mode 100644 src/tenant_engine/audit_core.py create mode 100644 src/tenant_engine/stance.py create mode 100644 tests/helpers.py create mode 100644 tests/test_audit_core.py create mode 100644 tests/test_events_scoped.py create mode 100644 tests/test_layer_conformance.py create mode 100644 tests/test_pep_write_path.py create mode 100644 tests/test_pip_claims.py diff --git a/SCOPE.md b/SCOPE.md index 3213608..2acc6fc 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -104,12 +104,12 @@ not done. | PostgreSQL production store | yes | `TEN-WP-0009`; deploy mounts `TENANT_ENGINE_DATABASE_URL_FILE` | | Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical | | Staged promotion | **no** | `TEN-WP-0008` | -| Machine-readable `layer.yaml` | **no** | Frontmatter declaration is in `INTENT.md` | -| Persisted `authorization_decision_id` | **no** | Boundary contract requires it; `FlexAuthCheckClient` drops the envelope | -| Published unreachable-engine stance map | **no** | Behaviour is fail-closed in code; companion §5 requires it published and tested equal to shipped behaviour | -| Independent audit-core emission | **no** | Local `events` table only (`TEN-IN-0001`) | -| Bounded event-read interface | **no** | In-process `TenantStore.events()` is unfiltered (`TEN-IN-0002`) | -| Claim freshness / input-class lifetime | **no** | Token lifetime covers the cache-read path by profile convention; live-lookup has no declared deadline | +| Machine-readable `layer.yaml` | yes | `TEN-WP-0011-T01`; check in `scripts/check_layer_conformance.py` | +| Persisted `authorization_decision_id` | yes | `authz_records` plus mutation event payload (`TEN-WP-0011-T02`) | +| Published unreachable-engine stance map | yes | `pep-stance.yaml`, fail-closed, tested equal to shipped behaviour | +| Independent audit-core emission | path shipped, sender pending | Local outbox + POST `/v1/events`; `AUDIT-IN-0002` | +| Bounded event-read interface | yes | `events_for(tenant_id)` only (`TEN-WP-0011-T05`) | +| Claim freshness / input-class lifetime | yes | `pip-claims.yaml` (`TEN-WP-0011-T03`) | The boundary contract still labels guardrail policy "reserved, not implemented" and still calls this repo "not a policy enforcement point". diff --git a/docs/evidence-emission.md b/docs/evidence-emission.md new file mode 100644 index 0000000..508f901 --- /dev/null +++ b/docs/evidence-emission.md @@ -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. | diff --git a/docs/flex-auth-integration.md b/docs/flex-auth-integration.md index f0cc7b4..eecb771 100644 --- a/docs/flex-auth-integration.md +++ b/docs/flex-auth-integration.md @@ -1,7 +1,10 @@ -# flex-auth Integration (TEN-WP-0003) +# flex-auth Integration -`tenant-engine` gates every write through flex-auth's `POST /v1/check` -(`flex_auth.FlexAuthCheckClient`, wired in as `authz.FlexAuthWriteAuthorizer`). +`tenant-engine` gates every write, and every authorized read, through +flex-auth's `POST /v1/check` (`flex_auth.FlexAuthCheckClient`, wired in as +`authz.FlexAuthWriteAuthorizer`). Writes are PEP-shaped: no mutation +without a decision record or a recorded fail-closed stance +(`pep-stance.yaml`). ## What tenant-engine sends @@ -13,56 +16,98 @@ A `CheckRequest` per `flex-auth/schemas/check_request.schema.json`: "tenant": "tenant:friendly:binky", "subject": {"id": "", "type": "service"}, "action": "tenant.create", - "resource": {"id": "", "type": "tenant", "system": "tenant-engine"} + "resource": {"id": "", "type": "tenant", "system": "tenant-engine"}, + "context": {} } ``` +`context` is always empty. This engine never puts `tenant_roles` or any +other PIP claim on the check it sends. The check asks whether *this +caller* may use *this admin/PIP surface*, not what the tenant is allowed +to do on the platform. + Action → resource-type mapping (must match `flex-auth`'s `FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge): -| Action | Resource type | -| --- | --- | -| `tenant.create` | `tenant` | -| `tenant.role.grant` | `role-grant` | -| `tenant.role.revoke` | `role-grant` | -| `tenant.plan.assign` | `plan-assignment` | +| Action | Resource type | Surface | +| --- | --- | --- | +| `tenant.create` | `tenant` | write | +| `tenant.role.grant` | `role-grant` | write | +| `tenant.role.revoke` | `role-grant` | write | +| `tenant.plan.assign` | `plan-assignment` | write | +| `tenant.update` | `tenant` | write | +| `tenant.retire` | `tenant` | write | +| `tenant.reactivate` | `tenant` | write | +| `tenant.grouping.set` | `tenant` | write | +| `tenant.guardrail.read` | `guardrail` | read | +| `tenant.guardrail.set` | `guardrail` | write | +| `tenant.read` | `tenant` | read | +| `tenant.role.read` | `role-grant` | read (cache-read) | +| `tenant.role.read.live` | `role-grant` | read (live-lookup) | ## What tenant-engine expects back A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`. -Only `effect: "allow"` authorizes the write. Every other `effect` -(`deny`/`redact`/`audit_only`/`not_applicable`), a non-200 response, a -malformed body, or a transport failure/timeout all resolve to **deny** — -`FlexAuthCheckClient.is_allowed()` never raises past its own boundary; it -always returns a plain `bool`. +Only `effect: "allow"` authorizes the action. Every other `effect`, a +non-200 response, a malformed body, or a transport failure/timeout all +resolve to **deny**. `FlexAuthCheckClient.check()` never raises past its +own boundary; it returns a `CheckResult` that is either a decision or a +recorded application of the fail-closed stance. -## The current real state +The decision id, request digest, effect, and source are persisted on +`authz_records` for every attempt and on the mutation event payload for +every successful write. Verdicts are never cached: every call is a new +POST (`pep-stance.yaml` `verdict_caching: none`). -Until `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md` -lands (resource/action vocabulary + an authored policy package), **every -check resolves to deny or not_applicable** — verified against real -`flex-auth` behavior, not assumed. This is correct fail-closed behavior, -not a bug: tenant-engine cannot perform any write against a real `flex-auth` -deployment until that policy package exists. Verified locally with a fake -HTTP double standing in for `flex-auth` (deny → `403`, allow → `201`, -both over real HTTP between two processes) — see `TEN-WP-0003`'s closure -notes for the exact commands. +## Live-lookup is not cyclic (TEN-WP-0011-T03) + +`GET /tenants/{id}/roles/live` authorizes via `tenant.role.read.live` then +reads the store. That check does **not** re-enter tenant-engine: + +1. The CheckRequest this engine sends has empty `context` and does not + carry `tenant_roles`. +2. `FlexAuthCheckClient` POSTs only `/v1/check`. Proven by + `tests/test_pip_claims.py::test_live_lookup_check_does_not_reenter_tenant_engine`. +3. flex-auth's tenant-engine policy package + (`flex-auth/examples/tenant-engine/policy_package.md`) matches + `subject.id` and `action` only. It does not consult tenant capability + roles. The package's own scope note says those roles are tenant state + a *different* protected system might consult via live-lookup; + conflating the two would authorize the wrong thing. +4. flex-auth's live-roles adapter is built and unwired + (`FLEX-WP-0015`, `flex-auth/docs/tenancy-posture-review.md`): no + non-test caller, no current policy consumes `tenant_roles` for a + privileged path. + +Together: GET /roles/live → POST /v1/check is a service-identity question. +The PDP does not need tenant-engine claims to answer it, so it does not +call back. + +Note: `tenant.role.read` / `tenant.role.read.live` / `tenant.read` / +`tenant.grouping.set` are not yet in that policy package's `valid_actions`. +A live flex-auth will currently `unknown_action` those four. That is +itself evidence they are not evaluated via tenant-role claims. Adding +them as static service-identity rules (same shape as +`tenant.guardrail.read`) is flex-auth work, not a cycle to unwind here. + +## Unreachable-engine stance + +Published in `pep-stance.yaml`. Every scope is `fail_closed`. DefaultDeny +(URL unset) and transport failure both apply that stance and record it. +Tests assert the file equals shipped behaviour. ## Configuration | Env var | Default | Meaning | | --- | --- | --- | -| `TENANT_ENGINE_FLEX_AUTH_URL` | unset | Base URL of a reachable `flex-auth` deployment. When unset, `create_app()` falls back to `authz.DefaultDenyWriteAuthorizer` — no writes ever succeed, which is the correct posture for local/test runs that have no `flex-auth` to call. | -| `TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS` | `3` | Bounded timeout on the synchronous write path — no retries, so a slow deny doesn't become a hang. | +| `TENANT_ENGINE_FLEX_AUTH_URL` | unset | Base URL of a reachable `flex-auth` deployment. When unset, `create_app()` falls back to `authz.DefaultDenyWriteAuthorizer`. | +| `TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS` | `3` | Bounded timeout on the synchronous path — no retries. | +| `TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE` | unset | Rotating bearer token file, read on each check. | ## Related +- `pep-stance.yaml` — published fail-closed map +- `pip-claims.yaml` — input-class freshness - `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md` - — the flex-auth-side work this client depends on for real `allow` - decisions, and (separately) flex-auth's own live-lookup consumption of - `tenant-engine`'s `/roles/live` endpoint for other protected systems. -- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md` — the - cache-read/`tenant_roles` direction (`key-cape` → `tenant-engine`), not - covered by this doc. -- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md` — - the Authorization Contract section this client implements. +- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md` +- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md` diff --git a/docs/intakes/residuals.md b/docs/intakes/residuals.md index 0d9392f..6860c15 100644 --- a/docs/intakes/residuals.md +++ b/docs/intakes/residuals.md @@ -13,7 +13,7 @@ kind: intake title: "Externalize tenant-engine audit evidence to audit-core" lane: yellow status: closed -outcome: promoted +outcome: completed promoted_to: TEN-WP-0011-T04 priority: high owner: tenant-engine @@ -58,7 +58,7 @@ kind: intake title: "Remove or authorize the tenant-engine unfiltered event-read interface" lane: red status: closed -outcome: promoted +outcome: completed promoted_to: TEN-WP-0011-T05 priority: high owner: tenant-engine diff --git a/layer.yaml b/layer.yaml new file mode 100644 index 0000000..f45e91c --- /dev/null +++ b/layer.yaml @@ -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. diff --git a/migrations/postgres/0002_authz_and_outbox.sql b/migrations/postgres/0002_authz_and_outbox.sql new file mode 100644 index 0000000..7996996 --- /dev/null +++ b/migrations/postgres/0002_authz_and_outbox.sql @@ -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; diff --git a/pep-stance.yaml b/pep-stance.yaml new file mode 100644 index 0000000..8ce0f3d --- /dev/null +++ b/pep-stance.yaml @@ -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 diff --git a/pip-claims.yaml b/pip-claims.yaml new file mode 100644 index 0000000..2f89f11 --- /dev/null +++ b/pip-claims.yaml @@ -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. diff --git a/pyproject.toml b/pyproject.toml index e21eea0..6e5e1a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ postgres = [ dev = [ "pytest>=8.2,<9.0", "ruff>=0.6,<1.0", + "pyyaml>=6.0,<7.0", ] [project.scripts] diff --git a/scripts/check_layer_conformance.py b/scripts/check_layer_conformance.py new file mode 100644 index 0000000..fa60f94 --- /dev/null +++ b/scripts/check_layer_conformance.py @@ -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()) diff --git a/src/tenant_engine/app.py b/src/tenant_engine/app.py index 4648198..22c4ec8 100644 --- a/src/tenant_engine/app.py +++ b/src/tenant_engine/app.py @@ -9,7 +9,9 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from tenant_engine import __version__ +from tenant_engine.audit_core import AuditCoreClient from tenant_engine.authz import ( + AuthorizationOutcome, DefaultDenyWriteAuthorizer, FlexAuthWriteAuthorizer, WriteAuthorizationDeniedError, @@ -44,6 +46,7 @@ from tenant_engine.guardrail import ( ) from tenant_engine.guardrail.serde import UNLIMITED_TOKEN from tenant_engine.store import ( + AuthorizationRecord, GrantNotFoundError, IdempotencyConflictError, InMemoryTenantStore, @@ -185,9 +188,11 @@ def create_app( app = FastAPI(title="tenant-engine", version=__version__) app.state.store = store app.state.authorizer = authorizer + app.state.audit_core = _build_audit_client(settings) @app.exception_handler(WriteAuthorizationDeniedError) async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse: + _persist_authz(store, exc.outcome) return JSONResponse( status_code=403, content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason}, @@ -233,7 +238,7 @@ def create_app( response: Response, actor: str = Query(min_length=1), ) -> dict: - authorizer.authorize(action="tenant.read", tenant_id=tenant_id, actor=actor) + _authorize(authorizer, store, action="tenant.read", tenant_id=tenant_id, actor=actor) try: tenant = store.get_tenant(tenant_id) except TenantNotFoundError as exc: @@ -249,7 +254,7 @@ def create_app( @app.get("/tenants/{tenant_id}/roles") async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict: - authorizer.authorize(action="tenant.role.read", tenant_id=tenant_id, actor=actor) + _authorize(authorizer, store, action="tenant.role.read", tenant_id=tenant_id, actor=actor) return _read_roles(store, tenant_id) # -- Live-lookup API (flex-auth, for aal2-class decisions) ----------- @@ -261,7 +266,9 @@ def create_app( @app.get("/tenants/{tenant_id}/roles/live") async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict: - authorizer.authorize(action="tenant.role.read.live", tenant_id=tenant_id, actor=actor) + _authorize( + authorizer, store, action="tenant.role.read.live", tenant_id=tenant_id, actor=actor + ) return _read_roles(store, tenant_id) # -- Write API (grant/revoke/plan mutation) --------------------------- @@ -270,7 +277,13 @@ def create_app( @app.post("/tenants", status_code=201) async def create_tenant(payload: CreateTenantRequest) -> dict: - authorizer.authorize(action="tenant.create", tenant_id=payload.tenant_id, actor=payload.actor) + outcome = _authorize( + authorizer, + store, + action="tenant.create", + tenant_id=payload.tenant_id, + actor=payload.actor, + ) try: tenant = Tenant.create( tenant_id=payload.tenant_id, @@ -279,7 +292,8 @@ def create_app( contact_email=payload.contact_email, created_at=datetime.now(UTC), ) - store.create_tenant(tenant) + store.create_tenant(tenant, authz=outcome.as_payload()) + _drain_outbox(store, app.state.audit_core) except InvalidTenantIdentifierError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except TenantAlreadyExistsError as exc: @@ -290,7 +304,9 @@ def create_app( @app.post("/tenants/{tenant_id}/roles/grant", status_code=201) async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict: - authorizer.authorize(action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor) + outcome = _authorize( + authorizer, store, action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor + ) try: tenant = store.get_tenant(tenant_id) grant = create_role_grant( @@ -303,7 +319,8 @@ def create_app( correlation_id=payload.correlation_id, granted_at=datetime.now(UTC), ) - store.grant_role(grant) + store.grant_role(grant, authz=outcome.as_payload()) + _drain_outbox(store, app.state.audit_core) except TenantNotFoundError as exc: raise HTTPException(status_code=404, detail="tenant_not_found") from exc except TenantRetiredError as exc: @@ -314,9 +331,17 @@ def create_app( @app.post("/tenants/{tenant_id}/roles/revoke") async def revoke_role(tenant_id: str, payload: RevokeRoleRequest) -> dict: - authorizer.authorize(action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor) + outcome = _authorize( + authorizer, store, action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor + ) try: - revoked = store.revoke_role(tenant_id=tenant_id, grant_id=payload.grant_id, at=datetime.now(UTC)) + revoked = store.revoke_role( + tenant_id=tenant_id, + grant_id=payload.grant_id, + at=datetime.now(UTC), + authz=outcome.as_payload(), + ) + _drain_outbox(store, app.state.audit_core) except TenantNotFoundError as exc: raise HTTPException(status_code=404, detail="tenant_not_found") from exc except GrantNotFoundError as exc: @@ -327,11 +352,17 @@ def create_app( @app.post("/tenants/{tenant_id}/plan") async def assign_plan(tenant_id: str, payload: AssignPlanRequest) -> dict: - authorizer.authorize(action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor) + outcome = _authorize( + authorizer, store, action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor + ) try: store.assign_plan( - PlanAssignment(tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC)) + PlanAssignment( + tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC) + ), + authz=outcome.as_payload(), ) + _drain_outbox(store, app.state.audit_core) except TenantNotFoundError as exc: raise HTTPException(status_code=404, detail="tenant_not_found") from exc except TenantRetiredError as exc: @@ -366,6 +397,7 @@ def create_app( event_type="tenant_updated", extra_fingerprint=changes, mutate=lambda tenant, at: tenant.with_metadata(changes, at=at), + audit_core=app.state.audit_core, ) @app.post("/tenants/{tenant_id}/retire") @@ -390,6 +422,7 @@ def create_app( event_type="tenant_retired", extra_fingerprint={}, mutate=lambda tenant, at: tenant.retire(at=at), + audit_core=app.state.audit_core, ) @app.post("/tenants/{tenant_id}/reactivate") @@ -414,6 +447,7 @@ def create_app( event_type="tenant_reactivated", extra_fingerprint={}, mutate=lambda tenant, at: tenant.reactivate(at=at), + audit_core=app.state.audit_core, ) # -- Reclassification (TEN-WP-0010) ----------------------------------- @@ -443,6 +477,7 @@ def create_app( event_type="tenant_grouping_changed", extra_fingerprint={"grouping": payload.grouping}, mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at), + audit_core=app.state.audit_core, ) # -- Guardrail API (TEN-WP-0006) -------------------------------------- @@ -451,7 +486,9 @@ def create_app( @app.get("/tenants/{tenant_id}/guardrails") async def read_guardrails(tenant_id: str, actor: str) -> dict: - authorizer.authorize(action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor) + _authorize( + authorizer, store, action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor + ) try: tenant = store.get_tenant(tenant_id) overrides = store.guardrail_overrides(tenant_id) @@ -487,9 +524,7 @@ def create_app( try: value = load_limit(payload.limit.model_dump()) except (InvalidLimitError, ValueError) as exc: - raise LifecycleError( - 400, "invalid_limit", str(exc), payload.correlation_id - ) from exc + raise LifecycleError(400, "invalid_limit", str(exc), payload.correlation_id) from exc return _guardrail_mutation( store=store, authorizer=authorizer, @@ -502,6 +537,7 @@ def create_app( idempotency_key=idempotency_key, if_match=if_match, response=response, + audit_core=app.state.audit_core, ) @app.delete("/tenants/{tenant_id}/guardrails/{limit_key}") @@ -525,6 +561,7 @@ def create_app( idempotency_key=idempotency_key, if_match=if_match, response=response, + audit_core=app.state.audit_core, ) return app @@ -553,6 +590,7 @@ def _guardrail_mutation( idempotency_key: str | None, if_match: str | None, response: Response, + audit_core: AuditCoreClient | None = None, ) -> dict: """Shared spine for setting and clearing an override. @@ -566,7 +604,9 @@ def _guardrail_mutation( ) expected_version = _parse_if_match(if_match, correlation_id) - authorizer.authorize(action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor) + outcome = _authorize( + authorizer, store, action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor + ) fingerprint = hashlib.sha256( json.dumps( @@ -585,9 +625,9 @@ def _guardrail_mutation( # Derived, not random: a genuine retry must produce the same change_id so # the replay path returns the original audit record rather than minting a # second one for a mutation that happened once. - change_id = hashlib.sha256( - f"{tenant_id}:{limit_key}:{idempotency_key}".encode() - ).hexdigest()[:32] + change_id = hashlib.sha256(f"{tenant_id}:{limit_key}:{idempotency_key}".encode()).hexdigest()[ + :32 + ] try: tenant, change, replayed = store.set_guardrail_override( @@ -602,7 +642,9 @@ def _guardrail_mutation( idempotency_key=idempotency_key, request_fingerprint=fingerprint, at=datetime.now(UTC), + authz=outcome.as_payload(), ) + _drain_outbox(store, audit_core) except UnknownLimitKeyError as exc: raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc except TenantNotFoundError as exc: @@ -622,9 +664,7 @@ def _guardrail_mutation( correlation_id, ) from exc except TenantRetiredError as exc: - raise LifecycleError( - 409, "guardrail_loosening_denied", str(exc), correlation_id - ) from exc + raise LifecycleError(409, "guardrail_loosening_denied", str(exc), correlation_id) from exc except (InvalidLimitError, ConflictingLimitError) as exc: raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc except StoreUnavailableError as exc: @@ -704,6 +744,7 @@ def _lifecycle_mutation( event_type: str, extra_fingerprint: dict, mutate, + audit_core: AuditCoreClient | None = None, ) -> dict: """Shared spine for update/retire/reactivate: authorize, then CAS. @@ -716,7 +757,7 @@ def _lifecycle_mutation( ) expected_version = _parse_if_match(if_match, correlation_id) - authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor) + outcome = _authorize(authorizer, store, action=action, tenant_id=tenant_id, actor=actor) fingerprint = hashlib.sha256( json.dumps( @@ -738,10 +779,16 @@ def _lifecycle_mutation( expected_version=expected_version, mutate=lambda current: mutate(current, now), event_type=event_type, - evidence={"actor": actor, "reason": reason, "correlation_id": correlation_id}, + evidence={ + "actor": actor, + "reason": reason, + "correlation_id": correlation_id, + **outcome.as_payload(), + }, idempotency_key=idempotency_key, request_fingerprint=fingerprint, ) + _drain_outbox(store, audit_core) except TenantNotFoundError as exc: raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc except IdempotencyConflictError as exc: @@ -787,6 +834,69 @@ def _build_authorizer(settings: Settings) -> WriteAuthorizer: return FlexAuthWriteAuthorizer(client=client) +def _build_audit_client(settings: Settings) -> AuditCoreClient | None: + if not settings.audit_core_base_url: + return None + return AuditCoreClient( + base_url=settings.audit_core_base_url, + timeout_seconds=settings.audit_core_timeout_seconds, + token_file=settings.audit_core_token_file, + ) + + +def _authorize( + authorizer: WriteAuthorizer, + store: TenantStore, + *, + action: str, + tenant_id: str, + actor: str, +) -> AuthorizationOutcome: + try: + outcome = authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor) + except WriteAuthorizationDeniedError: + raise + _persist_authz(store, outcome) + return outcome + + +def _persist_authz(store: TenantStore, outcome: AuthorizationOutcome) -> None: + recorder = getattr(store, "record_authorization", None) + if recorder is None: + return + recorder( + AuthorizationRecord( + action=outcome.action, + tenant_id=outcome.tenant_id, + actor=outcome.actor, + allowed=outcome.allowed, + source=outcome.source, + reason=outcome.reason, + at=datetime.now(UTC), + decision_id=outcome.decision_id, + request_digest=outcome.request_digest, + effect=outcome.effect, + stance=outcome.stance, + ) + ) + + +def _drain_outbox(store: TenantStore, client: AuditCoreClient | None) -> None: + """Best-effort drain. Never fails the mutation (attributive, non-blocking).""" + if client is None: + return + pending = getattr(store, "pending_outbox", None) + mark = getattr(store, "mark_outbox", None) + if pending is None or mark is None: + return + try: + for row in pending(): + result = client.post_event(row.envelope) + mark(row.event_id, status=result.status, detail=result.detail) + except Exception: + return + + def _read_roles(store: TenantStore, tenant_id: str) -> dict: try: roles = store.active_roles(tenant_id) diff --git a/src/tenant_engine/audit_core.py b/src/tenant_engine/audit_core.py new file mode 100644 index 0000000..96122a4 --- /dev/null +++ b/src/tenant_engine/audit_core.py @@ -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() diff --git a/src/tenant_engine/authz.py b/src/tenant_engine/authz.py index 4216e9d..66ecc57 100644 --- a/src/tenant_engine/authz.py +++ b/src/tenant_engine/authz.py @@ -1,12 +1,11 @@ from __future__ import annotations -from typing import Protocol +from dataclasses import dataclass +from typing import Any, Protocol from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id +from tenant_engine.stance import FAIL_CLOSED -# TEN-WP-0003-T01/T02: action names here must match FLEX-WP-0008-T01's -# resource/action vocabulary exactly -- the two repos coordinate on these -# strings, neither invents its own. _RESOURCE_TYPES: dict[str, str] = { "tenant.read": "tenant", "tenant.create": "tenant", @@ -15,63 +14,88 @@ _RESOURCE_TYPES: dict[str, str] = { "tenant.role.read": "role-grant", "tenant.role.read.live": "role-grant", "tenant.plan.assign": "plan-assignment", - # TEN-WP-0005: lifecycle actions are distinct so policy can separate a - # metadata edit from a retirement. "tenant.update": "tenant", "tenant.retire": "tenant", "tenant.reactivate": "tenant", - # TEN-WP-0006: reading a ceiling and changing one are separate privileges. - # A PDP needs the read; almost nothing needs the write. "tenant.guardrail.read": "guardrail", "tenant.guardrail.set": "guardrail", - # TEN-WP-0010: reclassification moves a tenant's spend ceiling, so it is - # separable from a metadata edit rather than folded into tenant.update. "tenant.grouping.set": "tenant", } +@dataclass(frozen=True, slots=True) +class AuthorizationOutcome: + """The record companion §5 requires on every protected action. + + Either a decision (source="decision", decision_id set) or the + application of the published fail-closed stance (source="stance"). + """ + + action: str + tenant_id: str + actor: str + allowed: bool + source: str + reason: str + decision_id: str | None = None + request_digest: str | None = None + effect: str | None = None + stance: str | None = None + + def as_payload(self) -> dict[str, Any]: + return { + "authorization_source": self.source, + "authorization_decision_id": self.decision_id, + "authorization_request_digest": self.request_digest, + "authorization_effect": self.effect, + "authorization_stance": self.stance, + "authorization_reason": self.reason, + } + + class WriteAuthorizationDeniedError(Exception): - def __init__(self, action: str, reason: str = "denied") -> None: - super().__init__(f"{action}: {reason}") - self.action = action - self.reason = reason + def __init__(self, outcome: AuthorizationOutcome) -> None: + super().__init__(f"{outcome.action}: {outcome.reason}") + self.action = outcome.action + self.reason = outcome.reason + self.outcome = outcome class WriteAuthorizer(Protocol): - """The single seam every write endpoint calls before mutating anything. + """The single seam every write (and authorized read) calls before the store.""" - Per the boundary contract, tenant-engine never self-authorizes writes -- - flex-auth is meant to gate them. A real flex-auth integration is an - explicit non-goal of TEN-WP-0002; this Protocol exists so swapping one in - later touches this one seam, not every endpoint. - """ - - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - """Raise WriteAuthorizationDeniedError if the write is not authorized.""" + def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome: + """Return an allow record, or raise WriteAuthorizationDeniedError.""" ... class DefaultDenyWriteAuthorizer: - """Deny every write. The correct default until a real authorizer exists.""" + """Apply the published fail-closed stance when access-engine is unset.""" - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - raise WriteAuthorizationDeniedError( - action, "no flex-auth integration configured (default-deny stub)" + def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome: + outcome = AuthorizationOutcome( + action=action, + tenant_id=tenant_id, + actor=actor, + allowed=False, + source="stance", + reason="no flex-auth integration configured (default-deny stub)", + stance=FAIL_CLOSED, ) + raise WriteAuthorizationDeniedError(outcome) class FlexAuthWriteAuthorizer: - """Gates writes through flex-auth's POST /v1/check (FLEX-WP-0008). + """Gates calls through flex-auth's POST /v1/check. - Until FLEX-WP-0008's policy package exists for tenant-engine, every - check resolves to deny -- that's the correct fail-closed behavior, not - a bug in this client (see flex_auth.FlexAuthCheckClient). + Does not cache verdicts. A previous allow cannot authorize a later + request — every call is a new check. """ def __init__(self, *, client: FlexAuthCheckClient) -> None: self._client = client - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: + def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome: resource_type = _RESOURCE_TYPES.get(action, "tenant") request = CheckRequest( request_id=new_request_id(), @@ -82,5 +106,19 @@ class FlexAuthWriteAuthorizer: resource_id=tenant_id, resource_type=resource_type, ) - if not self._client.is_allowed(request): - raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check") + result = self._client.check(request) + outcome = AuthorizationOutcome( + action=action, + tenant_id=tenant_id, + actor=actor, + allowed=result.allowed, + source=result.source, + reason=result.reason, + decision_id=result.decision_id, + request_digest=result.request_digest, + effect=result.effect, + stance=result.stance, + ) + if not result.allowed: + raise WriteAuthorizationDeniedError(outcome) + return outcome diff --git a/src/tenant_engine/config.py b/src/tenant_engine/config.py index 9ae2b5b..72b5c0f 100644 --- a/src/tenant_engine/config.py +++ b/src/tenant_engine/config.py @@ -13,15 +13,25 @@ class Settings: database_path: str | None = None database_url_file: str | None = None flex_auth_token_file: str | None = None + audit_core_base_url: str | None = None + audit_core_token_file: str | None = None + audit_core_timeout_seconds: float = 3.0 @classmethod def from_env(cls) -> "Settings": return cls( flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None, - flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")), + flex_auth_timeout_seconds=float( + os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3") + ), host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"), port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")), database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None, database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") or None, flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None, + audit_core_base_url=os.getenv("TENANT_ENGINE_AUDIT_CORE_URL") or None, + audit_core_token_file=os.getenv("TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE") or None, + audit_core_timeout_seconds=float( + os.getenv("TENANT_ENGINE_AUDIT_CORE_TIMEOUT_SECONDS", "3") + ), ) diff --git a/src/tenant_engine/domain.py b/src/tenant_engine/domain.py index 7c8380f..7b50844 100644 --- a/src/tenant_engine/domain.py +++ b/src/tenant_engine/domain.py @@ -193,9 +193,7 @@ class Tenant: # resolve guardrails through the reserved profile. Giving one a # grouping would silently move the platform's own identity onto # the grouping ladder. - raise ImmutableFieldError( - "reserved tenants are ungrouped and cannot be reclassified" - ) + raise ImmutableFieldError("reserved tenants are ungrouped and cannot be reclassified") if self.lifecycle is not TenantLifecycle.ACTIVE: raise InvalidLifecycleTransitionError( "grouping of a retired tenant cannot be changed; reactivate first" diff --git a/src/tenant_engine/flex_auth.py b/src/tenant_engine/flex_auth.py index 831e882..7c5e033 100644 --- a/src/tenant_engine/flex_auth.py +++ b/src/tenant_engine/flex_auth.py @@ -1,12 +1,15 @@ from __future__ import annotations +import hashlib +import json +from dataclasses import dataclass from typing import Any from uuid import uuid4 import httpx -# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json) -# allows five effects; only "allow" authorizes anything. +from tenant_engine.stance import FAIL_CLOSED + ALLOW_EFFECT = "allow" @@ -45,15 +48,35 @@ class CheckRequest: "context": self.context, } + def digest(self) -> str: + canonical = json.dumps(self.to_json(), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +@dataclass(frozen=True, slots=True) +class CheckResult: + """What the authorizer keeps after POST /v1/check. + + `is_allowed` used to drop the envelope. Companion §5 / statute §6.4 + require the decision record (or a recorded stance) to survive. + """ + + allowed: bool + source: str + reason: str + request_digest: str + decision_id: str | None = None + effect: str | None = None + stance: str | None = None + class FlexAuthCheckClient: """Client for flex-auth's POST /v1/check. - Fail-closed by construction: every non-"allow" effect, every non-2xx - response, every malformed body, and every transport failure (timeout, - connection error) resolves to `False` from `is_allowed()`. Nothing - raises past this boundary -- callers (the WriteAuthorizer seam) get a - plain deny, not an exception to handle inconsistently. + Fail-closed by construction. Every non-allow effect, every non-2xx, + every malformed body, and every transport failure resolves to a + CheckResult with allowed=False. Nothing raises past this boundary. + Verdicts are never cached: every call is a new POST. """ def __init__( @@ -73,33 +96,85 @@ class FlexAuthCheckClient: transport=transport, ) - def is_allowed(self, request: CheckRequest) -> bool: + def check(self, request: CheckRequest) -> CheckResult: + digest = request.digest() try: headers: dict[str, str] = {} if self.bearer_token_file: - # Projected ServiceAccount tokens rotate. Read on each check - # instead of pinning the token for the lifetime of the process. with open(self.bearer_token_file, encoding="utf-8") as token_file: token = token_file.read().strip() if not token: - return False + return CheckResult( + allowed=False, + source="stance", + reason="caller_token_empty", + request_digest=digest, + stance=FAIL_CLOSED, + ) headers["Authorization"] = f"Bearer {token}" response = self._client.post("/v1/check", json=request.to_json(), headers=headers) except (httpx.HTTPError, OSError): - return False + return CheckResult( + allowed=False, + source="stance", + reason="access_engine_unreachable", + request_digest=digest, + stance=FAIL_CLOSED, + ) if response.status_code != 200: - return False + return CheckResult( + allowed=False, + source="stance", + reason=f"access_engine_http_{response.status_code}", + request_digest=digest, + stance=FAIL_CLOSED, + ) try: envelope = response.json() except ValueError: - return False + return CheckResult( + allowed=False, + source="stance", + reason="access_engine_malformed_body", + request_digest=digest, + stance=FAIL_CLOSED, + ) if not isinstance(envelope, dict): - return False + return CheckResult( + allowed=False, + source="stance", + reason="access_engine_malformed_body", + request_digest=digest, + stance=FAIL_CLOSED, + ) - return envelope.get("effect") == ALLOW_EFFECT + effect = envelope.get("effect") + decision_id = envelope.get("id") + if not isinstance(decision_id, str): + decision_id = None + if effect == ALLOW_EFFECT: + return CheckResult( + allowed=True, + source="decision", + reason="allow", + request_digest=digest, + decision_id=decision_id, + effect=ALLOW_EFFECT, + ) + return CheckResult( + allowed=False, + source="decision", + reason="denied_by_flex_auth_policy_check", + request_digest=digest, + decision_id=decision_id, + effect=str(effect) if effect is not None else None, + ) + + def is_allowed(self, request: CheckRequest) -> bool: + return self.check(request).allowed def close(self) -> None: self._client.close() diff --git a/src/tenant_engine/postgres_store.py b/src/tenant_engine/postgres_store.py index a5cb6e1..7e34577 100644 --- a/src/tenant_engine/postgres_store.py +++ b/src/tenant_engine/postgres_store.py @@ -19,6 +19,7 @@ except ImportError: # pragma: no cover - exercised by the deployment guard ConnectionPool = None PoolTimeout = None +from tenant_engine.audit_core import envelope_for, new_event_id from tenant_engine.domain import ( CapabilityRole, PlanAssignment, @@ -35,9 +36,11 @@ from tenant_engine.guardrail import ( load_limit, ) from tenant_engine.store import ( + AuthorizationRecord, DomainEvent, GrantNotFoundError, IdempotencyConflictError, + OutboxRow, StoreUnavailableError, TenantAlreadyExistsError, TenantNotFoundError, @@ -118,7 +121,7 @@ class PostgresTenantStore: with self._pool.connection() as conn: conn.execute("SELECT 1").fetchone() - def create_tenant(self, tenant: Tenant) -> None: + def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None: try: with self._pool.connection() as conn, conn.transaction(): conn.execute( @@ -144,7 +147,7 @@ class PostgresTenantStore: conn, "tenant_created", tenant.tenant_id, - {"identifier": tenant.identifier, "grouping": tenant.grouping}, + {"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})}, ) except StoreUnavailableError as exc: if isinstance(exc.__cause__, UniqueViolation): @@ -204,15 +207,13 @@ class PostgresTenantStore: updated.tenant_id, ), ) - self._record_receipt( - conn, updated, idempotency_key, request_fingerprint - ) + self._record_receipt(conn, updated, idempotency_key, request_fingerprint) self._emit( conn, event_type, updated.tenant_id, {**evidence, "version": updated.version} ) return updated, False - def grant_role(self, grant: RoleGrant) -> None: + def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None: tenant = self.get_tenant(grant.tenant_id) self._require_active(tenant, "grant a role") with self._pool.connection() as conn, conn.transaction(): @@ -248,10 +249,13 @@ class PostgresTenantStore: "role": grant.role.value, "grant_reason": grant.grant_reason, "correlation_id": grant.correlation_id, + **(authz or {}), }, ) - def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: + def revoke_role( + self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None + ) -> RoleGrant: tenant = self.get_tenant(tenant_id) with self._pool.connection() as conn, conn.transaction(): row = conn.execute( @@ -266,7 +270,7 @@ class PostgresTenantStore: conn, "role_revoked", tenant.tenant_id, - {"grant_id": grant_id, "role": grant.role.value}, + {"grant_id": grant_id, "role": grant.role.value, **(authz or {})}, ) return grant @@ -279,7 +283,9 @@ class PostgresTenantStore: ).fetchall() return frozenset(CapabilityRole(row["role"]) for row in rows) - def assign_plan(self, assignment: PlanAssignment) -> None: + def assign_plan( + self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None + ) -> None: tenant = self.get_tenant(assignment.tenant_id) self._require_active(tenant, "assign a plan") with self._pool.connection() as conn, conn.transaction(): @@ -290,17 +296,89 @@ class PostgresTenantStore: (tenant.tenant_id, assignment.plan_id, assignment.assigned_at), ) self._emit( - conn, "plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id} + conn, + "plan_assigned", + tenant.tenant_id, + {"plan_id": assignment.plan_id, **(authz or {})}, ) - def events(self) -> list[DomainEvent]: + def events_for(self, tenant_id: str) -> list[DomainEvent]: + tenant = self.get_tenant(tenant_id) with self._pool.connection() as conn: - rows = conn.execute("SELECT * FROM events ORDER BY seq").fetchall() + rows = conn.execute( + "SELECT * FROM events WHERE tenant_id = %s ORDER BY seq", + (tenant.tenant_id,), + ).fetchall() return [ DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"]) for row in rows ] + def record_authorization(self, record: AuthorizationRecord) -> None: + with self._pool.connection() as conn, conn.transaction(): + conn.execute( + """INSERT INTO authz_records + (action, tenant_id, actor, allowed, source, reason, at, + decision_id, request_digest, effect, stance) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + ( + record.action, + record.tenant_id, + record.actor, + record.allowed, + record.source, + record.reason, + record.at, + record.decision_id, + record.request_digest, + record.effect, + record.stance, + ), + ) + + def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]: + keys = [tenant_id] + try: + keys.append(self.get_tenant(tenant_id).tenant_id) + except TenantNotFoundError: + pass + placeholders = ",".join(["%s"] * len(keys)) + with self._pool.connection() as conn: + rows = conn.execute( + f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq", + keys, + ).fetchall() + return [_authz_row(row) for row in rows] + + def pending_outbox(self) -> list[OutboxRow]: + with self._pool.connection() as conn: + rows = conn.execute( + """SELECT * FROM audit_outbox + WHERE delivered_at IS NULL AND dead_at IS NULL + ORDER BY created_at""" + ).fetchall() + return [_outbox_row(row) for row in rows] + + def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None: + with self._pool.connection() as conn, conn.transaction(): + if status in {"delivered", "duplicate"}: + conn.execute( + """UPDATE audit_outbox SET delivered_at = %s, last_error = NULL + WHERE event_id = %s""", + (datetime.now(UTC), event_id), + ) + elif status == "dead": + conn.execute( + "UPDATE audit_outbox SET dead_at = %s, last_error = %s WHERE event_id = %s", + (datetime.now(UTC), detail, event_id), + ) + else: + conn.execute( + """UPDATE audit_outbox SET attempts = attempts + 1, last_error = %s + WHERE event_id = %s""", + (detail, event_id), + ) + def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]: tenant = self.get_tenant(tenant_id) with self._pool.connection() as conn: @@ -333,6 +411,7 @@ class PostgresTenantStore: idempotency_key: str, request_fingerprint: str, at: datetime, + authz: dict[str, Any] | None = None, ) -> tuple[Tenant, GuardrailChange | None, bool]: DEFAULT_REGISTRY.get(limit_key) with self._pool.connection() as conn, conn.transaction(): @@ -428,6 +507,7 @@ class PostgresTenantStore: "cleared": value is None, "correlation_id": correlation_id, "version": updated.version, + **(authz or {}), }, ) return updated, change, False @@ -467,9 +547,24 @@ class PostgresTenantStore: @staticmethod def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None: + event_id = str(payload.get("event_id") or new_event_id()) + payload = {**payload, "event_id": event_id} + at = datetime.now(UTC) conn.execute( "INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)", - (event_type, tenant_id, datetime.now(UTC), Jsonb(payload)), + (event_type, tenant_id, at, Jsonb(payload)), + ) + envelope = envelope_for( + event_id=event_id, + event_type=event_type, + tenant_id=tenant_id, + observed_at=at.isoformat(), + payload=payload, + ) + conn.execute( + """INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at) + VALUES (%s, %s, %s, %s)""", + (event_id, tenant_id, Jsonb(envelope), at), ) @@ -501,9 +596,7 @@ def _row(tenant: Tenant) -> dict[str, Any]: "created_at": tenant.created_at.isoformat() if tenant.created_at else None, "updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None, "retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None, - "reactivated_at": ( - tenant.reactivated_at.isoformat() if tenant.reactivated_at else None - ), + "reactivated_at": (tenant.reactivated_at.isoformat() if tenant.reactivated_at else None), } @@ -539,3 +632,32 @@ def _change(row: Mapping[str, Any]) -> GuardrailChange: correlation_id=row["correlation_id"], changed_at=_dt(row["changed_at"]), ) + + +def _authz_row(row: Mapping[str, Any]) -> AuthorizationRecord: + return AuthorizationRecord( + action=row["action"], + tenant_id=row["tenant_id"], + actor=row["actor"], + allowed=bool(row["allowed"]), + source=row["source"], + reason=row["reason"], + at=_dt(row["at"]), + decision_id=row["decision_id"], + request_digest=row["request_digest"], + effect=row["effect"], + stance=row["stance"], + ) + + +def _outbox_row(row: Mapping[str, Any]) -> OutboxRow: + return OutboxRow( + event_id=row["event_id"], + tenant_id=row["tenant_id"], + envelope=row["envelope"], + created_at=_dt(row["created_at"]), + attempts=row["attempts"], + last_error=row["last_error"], + delivered_at=_dt(row["delivered_at"]) if row["delivered_at"] else None, + dead_at=_dt(row["dead_at"]) if row["dead_at"] else None, + ) diff --git a/src/tenant_engine/sqlite_store.py b/src/tenant_engine/sqlite_store.py index 45e9566..791a3f2 100644 --- a/src/tenant_engine/sqlite_store.py +++ b/src/tenant_engine/sqlite_store.py @@ -8,6 +8,7 @@ from datetime import datetime from threading import RLock from typing import Any +from tenant_engine.audit_core import envelope_for, new_event_id from tenant_engine.domain import ( CapabilityRole, PlanAssignment, @@ -24,9 +25,11 @@ from tenant_engine.guardrail import ( load_limit, ) from tenant_engine.store import ( + AuthorizationRecord, DomainEvent, GrantNotFoundError, IdempotencyConflictError, + OutboxRow, TenantAlreadyExistsError, TenantNotFoundError, VersionConflictError, @@ -62,6 +65,19 @@ class SQLiteTenantStore: seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL, tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS authz_records ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, tenant_id TEXT NOT NULL, actor TEXT NOT NULL, + allowed INTEGER NOT NULL, source TEXT NOT NULL, reason TEXT NOT NULL, + at TEXT NOT NULL, decision_id TEXT, request_digest TEXT, + effect TEXT, stance TEXT + ); + CREATE TABLE IF NOT EXISTS audit_outbox ( + event_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, + envelope TEXT NOT NULL, created_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, + delivered_at TEXT, dead_at TEXT + ); CREATE TABLE IF NOT EXISTS idempotency_receipts ( tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, result TEXT NOT NULL, @@ -110,7 +126,7 @@ class SQLiteTenantStore: with self._lock: self._db.execute("SELECT 1").fetchone() - def create_tenant(self, tenant: Tenant) -> None: + def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None: with self._lock, self._db: try: self._db.execute( @@ -118,16 +134,31 @@ class SQLiteTenantStore: contact_email, lifecycle, version, created_at, updated_at, retired_at, reactivated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (tenant.tenant_id, tenant.identifier, tenant.grouping, tenant.display_name, - tenant.contact_email, tenant.lifecycle.value, tenant.version, - _iso(tenant.created_at), _iso(tenant.updated_at), - _iso(tenant.retired_at), _iso(tenant.reactivated_at)), + ( + tenant.tenant_id, + tenant.identifier, + tenant.grouping, + tenant.display_name, + tenant.contact_email, + tenant.lifecycle.value, + tenant.version, + _iso(tenant.created_at), + _iso(tenant.updated_at), + _iso(tenant.retired_at), + _iso(tenant.reactivated_at), + ), ) except sqlite3.IntegrityError as exc: raise TenantAlreadyExistsError(tenant.identifier) from exc - self._emit("tenant_created", tenant.tenant_id, { - "identifier": tenant.identifier, "grouping": tenant.grouping, - }) + self._emit( + "tenant_created", + tenant.tenant_id, + { + "identifier": tenant.identifier, + "grouping": tenant.grouping, + **(authz or {}), + }, + ) def get_tenant(self, tenant_id: str) -> Tenant: # Reads take the same lock as writes. One sqlite3 connection is shared @@ -189,15 +220,27 @@ class SQLiteTenantStore: contact_email = ?, lifecycle = ?, version = ?, updated_at = ?, retired_at = ?, reactivated_at = ? WHERE tenant_id = ?""", - (updated.grouping, updated.display_name, updated.contact_email, - updated.lifecycle.value, updated.version, _iso(updated.updated_at), - _iso(updated.retired_at), _iso(updated.reactivated_at), - updated.tenant_id), + ( + updated.grouping, + updated.display_name, + updated.contact_email, + updated.lifecycle.value, + updated.version, + _iso(updated.updated_at), + _iso(updated.retired_at), + _iso(updated.reactivated_at), + updated.tenant_id, + ), ) self._db.execute( "INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)", - (updated.tenant_id, idempotency_key, request_fingerprint, - json.dumps(_row(updated)), datetime.now().astimezone().isoformat()), + ( + updated.tenant_id, + idempotency_key, + request_fingerprint, + json.dumps(_row(updated)), + datetime.now().astimezone().isoformat(), + ), ) self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version}) except BaseException: @@ -251,6 +294,7 @@ class SQLiteTenantStore: idempotency_key: str, request_fingerprint: str, at: datetime, + authz: dict[str, Any] | None = None, ) -> tuple[Tenant, GuardrailChange | None, bool]: tenant = self.get_tenant(tenant_id) DEFAULT_REGISTRY.get(limit_key) @@ -274,8 +318,11 @@ class SQLiteTenantStore: replayed = _tenant(json.loads(receipt["result"])) self._db.rollback() prior = next( - (c for c in self.guardrail_changes(tenant.tenant_id) - if c.change_id == change_id), + ( + c + for c in self.guardrail_changes(tenant.tenant_id) + if c.change_id == change_id + ), None, ) return replayed, prior, True @@ -317,16 +364,29 @@ class SQLiteTenantStore: dumped = dump_limit(value) self._db.execute( "INSERT OR REPLACE INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)", - (current.tenant_id, limit_key, dumped["kind"], dumped["amount"], - dumped["currency"], dumped["period"]), + ( + current.tenant_id, + limit_key, + dumped["kind"], + dumped["amount"], + dumped["currency"], + dumped["period"], + ), ) self._db.execute( "INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - (change.change_id, change.tenant_id, change.limit_key, - json.dumps(dump_limit(change.previous)) if change.previous else None, - json.dumps(dump_limit(change.current)) if change.current else None, - change.changed_by, change.reason, change.correlation_id, _iso(at)), + ( + change.change_id, + change.tenant_id, + change.limit_key, + json.dumps(dump_limit(change.previous)) if change.previous else None, + json.dumps(dump_limit(change.current)) if change.current else None, + change.changed_by, + change.reason, + change.correlation_id, + _iso(at), + ), ) updated = replace(current, version=current.version + 1, updated_at=at) @@ -336,35 +396,65 @@ class SQLiteTenantStore: ) self._db.execute( "INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)", - (updated.tenant_id, idempotency_key, request_fingerprint, - json.dumps(_row(updated)), datetime.now().astimezone().isoformat()), + ( + updated.tenant_id, + idempotency_key, + request_fingerprint, + json.dumps(_row(updated)), + datetime.now().astimezone().isoformat(), + ), + ) + self._emit( + "guardrail_changed", + updated.tenant_id, + { + "limit_key": limit_key, + "change_id": change_id, + "cleared": value is None, + "correlation_id": correlation_id, + "version": updated.version, + **(authz or {}), + }, ) - self._emit("guardrail_changed", updated.tenant_id, { - "limit_key": limit_key, "change_id": change_id, - "cleared": value is None, "correlation_id": correlation_id, - "version": updated.version, - }) except BaseException: self._db.rollback() raise self._db.commit() return updated, change, False - def grant_role(self, grant: RoleGrant) -> None: + def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None: tenant = self.get_tenant(grant.tenant_id) self._require_active(tenant, "grant a role") with self._lock, self._db: self._db.execute( "INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - (grant.grant_id, tenant.tenant_id, grant.role.value, grant.grant_reason, - grant.plan_id, grant.granted_by, grant.granted_at.isoformat(), - grant.correlation_id, grant.revoked_at.isoformat() if grant.revoked_at else None), + ( + grant.grant_id, + tenant.tenant_id, + grant.role.value, + grant.grant_reason, + grant.plan_id, + grant.granted_by, + grant.granted_at.isoformat(), + grant.correlation_id, + grant.revoked_at.isoformat() if grant.revoked_at else None, + ), + ) + self._emit( + "role_granted", + tenant.tenant_id, + { + "grant_id": grant.grant_id, + "role": grant.role.value, + "grant_reason": grant.grant_reason, + "correlation_id": grant.correlation_id, + **(authz or {}), + }, ) - self._emit("role_granted", tenant.tenant_id, {"grant_id": grant.grant_id, - "role": grant.role.value, "grant_reason": grant.grant_reason, - "correlation_id": grant.correlation_id}) - def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: + def revoke_role( + self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None + ) -> RoleGrant: tenant = self.get_tenant(tenant_id) with self._lock: row = self._db.execute( @@ -375,10 +465,14 @@ class SQLiteTenantStore: raise GrantNotFoundError(grant_id) grant = self._grant(row).revoke(at=at) with self._lock, self._db: - self._db.execute("UPDATE grants SET revoked_at = ? WHERE grant_id = ?", - (at.isoformat(), grant_id)) - self._emit("role_revoked", tenant.tenant_id, - {"grant_id": grant_id, "role": grant.role.value}) + self._db.execute( + "UPDATE grants SET revoked_at = ? WHERE grant_id = ?", (at.isoformat(), grant_id) + ) + self._emit( + "role_revoked", + tenant.tenant_id, + {"grant_id": grant_id, "role": grant.role.value, **(authz or {})}, + ) return grant def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: @@ -390,7 +484,9 @@ class SQLiteTenantStore: ).fetchall() return frozenset(CapabilityRole(row["role"]) for row in rows) - def assign_plan(self, assignment: PlanAssignment) -> None: + def assign_plan( + self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None + ) -> None: tenant = self.get_tenant(assignment.tenant_id) self._require_active(tenant, "assign a plan") with self._lock, self._db: @@ -398,14 +494,94 @@ class SQLiteTenantStore: "INSERT OR REPLACE INTO plans VALUES (?, ?, ?)", (tenant.tenant_id, assignment.plan_id, assignment.assigned_at.isoformat()), ) - self._emit("plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id}) + self._emit( + "plan_assigned", + tenant.tenant_id, + {"plan_id": assignment.plan_id, **(authz or {})}, + ) - def events(self) -> list[DomainEvent]: + def events_for(self, tenant_id: str) -> list[DomainEvent]: + tenant = self.get_tenant(tenant_id) with self._lock: - rows = self._db.execute("SELECT * FROM events ORDER BY seq").fetchall() - return [DomainEvent(row["event_type"], row["tenant_id"], - datetime.fromisoformat(row["at"]), json.loads(row["payload"])) - for row in rows] + rows = self._db.execute( + "SELECT * FROM events WHERE tenant_id = ? ORDER BY seq", (tenant.tenant_id,) + ).fetchall() + return [ + DomainEvent( + row["event_type"], + row["tenant_id"], + datetime.fromisoformat(row["at"]), + json.loads(row["payload"]), + ) + for row in rows + ] + + def record_authorization(self, record: AuthorizationRecord) -> None: + with self._lock, self._db: + self._db.execute( + """INSERT INTO authz_records + (action, tenant_id, actor, allowed, source, reason, at, + decision_id, request_digest, effect, stance) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + record.action, + record.tenant_id, + record.actor, + 1 if record.allowed else 0, + record.source, + record.reason, + record.at.isoformat(), + record.decision_id, + record.request_digest, + record.effect, + record.stance, + ), + ) + + def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]: + keys = {tenant_id} + try: + keys.add(self.get_tenant(tenant_id).tenant_id) + except TenantNotFoundError: + pass + placeholders = ",".join("?" * len(keys)) + with self._lock: + rows = self._db.execute( + f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq", + tuple(keys), + ).fetchall() + return [_authz_row(row) for row in rows] + + def pending_outbox(self) -> list[OutboxRow]: + with self._lock: + rows = self._db.execute( + """SELECT * FROM audit_outbox + WHERE delivered_at IS NULL AND dead_at IS NULL + ORDER BY created_at""" + ).fetchall() + return [_outbox_row(row) for row in rows] + + def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None: + now = datetime.now().astimezone().isoformat() + with self._lock, self._db: + if status in {"delivered", "duplicate"}: + self._db.execute( + """UPDATE audit_outbox + SET delivered_at = ?, last_error = NULL WHERE event_id = ?""", + (now, event_id), + ) + elif status == "dead": + self._db.execute( + "UPDATE audit_outbox SET dead_at = ?, last_error = ? WHERE event_id = ?", + (now, detail, event_id), + ) + else: + self._db.execute( + """UPDATE audit_outbox + SET attempts = attempts + 1, last_error = ? + WHERE event_id = ?""", + (detail, event_id), + ) @staticmethod def _require_active(tenant: Tenant, what: str) -> None: @@ -413,22 +589,74 @@ class SQLiteTenantStore: raise TenantRetiredError(f"cannot {what} on a retired tenant") def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None: + event_id = str(payload.get("event_id") or new_event_id()) + payload = {**payload, "event_id": event_id} now = datetime.now().astimezone() - self._db.execute("INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)", - (event_type, tenant_id, now.isoformat(), json.dumps(payload))) + self._db.execute( + "INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)", + (event_type, tenant_id, now.isoformat(), json.dumps(payload)), + ) + envelope = envelope_for( + event_id=event_id, + event_type=event_type, + tenant_id=tenant_id, + observed_at=now.isoformat(), + payload=payload, + ) + self._db.execute( + """INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at) + VALUES (?, ?, ?, ?)""", + (event_id, tenant_id, json.dumps(envelope), now.isoformat()), + ) @staticmethod def _grant(row: sqlite3.Row) -> RoleGrant: - return RoleGrant(row["grant_id"], row["tenant_id"], CapabilityRole(row["role"]), - row["grant_reason"], row["plan_id"], row["granted_by"], - datetime.fromisoformat(row["granted_at"]), row["correlation_id"], - datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None) + return RoleGrant( + row["grant_id"], + row["tenant_id"], + CapabilityRole(row["role"]), + row["grant_reason"], + row["plan_id"], + row["granted_by"], + datetime.fromisoformat(row["granted_at"]), + row["correlation_id"], + datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None, + ) def _iso(value: datetime | None) -> str | None: return value.isoformat() if value else None +def _authz_row(row: sqlite3.Row) -> AuthorizationRecord: + return AuthorizationRecord( + action=row["action"], + tenant_id=row["tenant_id"], + actor=row["actor"], + allowed=bool(row["allowed"]), + source=row["source"], + reason=row["reason"], + at=datetime.fromisoformat(row["at"]), + decision_id=row["decision_id"], + request_digest=row["request_digest"], + effect=row["effect"], + stance=row["stance"], + ) + + +def _outbox_row(row: sqlite3.Row) -> OutboxRow: + return OutboxRow( + event_id=row["event_id"], + tenant_id=row["tenant_id"], + envelope=json.loads(row["envelope"]), + created_at=datetime.fromisoformat(row["created_at"]), + attempts=row["attempts"], + last_error=row["last_error"], + delivered_at=datetime.fromisoformat(row["delivered_at"]) if row["delivered_at"] else None, + dead_at=datetime.fromisoformat(row["dead_at"]) if row["dead_at"] else None, + ) + + def _dt(value: str | None) -> datetime | None: return datetime.fromisoformat(value) if value else None diff --git a/src/tenant_engine/stance.py b/src/tenant_engine/stance.py new file mode 100644 index 0000000..ada92f5 --- /dev/null +++ b/src/tenant_engine/stance.py @@ -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 diff --git a/src/tenant_engine/store.py b/src/tenant_engine/store.py index fb6d7cf..f277e4f 100644 --- a/src/tenant_engine/store.py +++ b/src/tenant_engine/store.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, replace from datetime import UTC, datetime from typing import Any, Protocol +from tenant_engine.audit_core import envelope_for, new_event_id from tenant_engine.domain import ( CapabilityRole, PlanAssignment, @@ -64,6 +65,37 @@ class DomainEvent: payload: dict[str, Any] +@dataclass(frozen=True, slots=True) +class AuthorizationRecord: + """PEP reconstructability: every authorize attempt, including denies.""" + + action: str + tenant_id: str + actor: str + allowed: bool + source: str + reason: str + at: datetime + decision_id: str | None = None + request_digest: str | None = None + effect: str | None = None + stance: str | None = None + + +@dataclass(frozen=True, slots=True) +class OutboxRow: + """Local audit-core outbox. Drain is after commit and non-blocking.""" + + event_id: str + tenant_id: str + envelope: dict[str, Any] + created_at: datetime + attempts: int = 0 + last_error: str | None = None + delivered_at: datetime | None = None + dead_at: datetime | None = None + + class TenantStore(Protocol): """Swappable persistence seam -- domain/ and api/ depend on this, not a backend. @@ -77,19 +109,31 @@ class TenantStore(Protocol): tenant_roles claim, KEY-WP-0005-T02). """ - def create_tenant(self, tenant: Tenant) -> None: ... + def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None: ... def get_tenant(self, tenant_id: str) -> Tenant: ... - def grant_role(self, grant: RoleGrant) -> None: ... + def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None: ... - def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: ... + def revoke_role( + self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None + ) -> RoleGrant: ... def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: ... - def assign_plan(self, assignment: PlanAssignment) -> None: ... + def assign_plan( + self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None + ) -> None: ... - def events(self) -> list[DomainEvent]: ... + def events_for(self, tenant_id: str) -> list[DomainEvent]: ... + + def record_authorization(self, record: AuthorizationRecord) -> None: ... + + def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]: ... + + def pending_outbox(self) -> list[OutboxRow]: ... + + def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None: ... def mutate_tenant( self, @@ -202,12 +246,14 @@ class InMemoryTenantStore: self._grants: dict[str, dict[str, RoleGrant]] = {} self._plans: dict[str, PlanAssignment] = {} self._events: list[DomainEvent] = [] + self._authz: list[AuthorizationRecord] = [] + self._outbox: dict[str, OutboxRow] = {} # (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot) self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {} self._overrides: dict[str, dict[str, LimitValue]] = {} self._guardrail_changes: list[GuardrailChange] = [] - def create_tenant(self, tenant: Tenant) -> None: + def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None: if tenant.tenant_id in self._tenants: raise TenantAlreadyExistsError(tenant.tenant_id) if tenant.identifier in self._by_identifier: @@ -218,13 +264,13 @@ class InMemoryTenantStore: self._emit( "tenant_created", tenant.tenant_id, - {"identifier": tenant.identifier, "grouping": tenant.grouping}, + {"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})}, ) def get_tenant(self, tenant_id: str) -> Tenant: return self._tenants[self._resolve(tenant_id)] - def grant_role(self, grant: RoleGrant) -> None: + def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None: resolved = self._resolve(grant.tenant_id) self._require_active(resolved, "grant a role") self._grants[resolved][grant.grant_id] = grant @@ -236,10 +282,18 @@ class InMemoryTenantStore: "role": grant.role.value, "grant_reason": grant.grant_reason, "correlation_id": grant.correlation_id, + **(authz or {}), }, ) - def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: + def revoke_role( + self, + *, + tenant_id: str, + grant_id: str, + at: datetime, + authz: dict[str, Any] | None = None, + ) -> RoleGrant: resolved = self._resolve(tenant_id) try: grant = self._grants[resolved][grant_id] @@ -250,7 +304,7 @@ class InMemoryTenantStore: self._emit( "role_revoked", resolved, - {"grant_id": grant_id, "role": revoked.role.value}, + {"grant_id": grant_id, "role": revoked.role.value, **(authz or {})}, ) return revoked @@ -260,14 +314,48 @@ class InMemoryTenantStore: grant.role for grant in self._grants.get(resolved, {}).values() if grant.active ) - def assign_plan(self, assignment: PlanAssignment) -> None: + def assign_plan( + self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None + ) -> None: resolved = self._resolve(assignment.tenant_id) self._require_active(resolved, "assign a plan") self._plans[resolved] = assignment - self._emit("plan_assigned", resolved, {"plan_id": assignment.plan_id}) + self._emit( + "plan_assigned", + resolved, + {"plan_id": assignment.plan_id, **(authz or {})}, + ) - def events(self) -> list[DomainEvent]: - return list(self._events) + def events_for(self, tenant_id: str) -> list[DomainEvent]: + resolved = self._resolve(tenant_id) + return [event for event in self._events if event.tenant_id == resolved] + + def record_authorization(self, record: AuthorizationRecord) -> None: + self._authz.append(record) + + def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]: + try: + resolved = self._resolve(tenant_id) + except TenantNotFoundError: + resolved = tenant_id + return [row for row in self._authz if row.tenant_id in {tenant_id, resolved}] + + def pending_outbox(self) -> list[OutboxRow]: + return [ + row for row in self._outbox.values() if row.delivered_at is None and row.dead_at is None + ] + + def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None: + row = self._outbox.get(event_id) + if row is None: + return + now = datetime.now(UTC) + if status in {"delivered", "duplicate"}: + self._outbox[event_id] = replace(row, delivered_at=now, last_error=None) + elif status == "dead": + self._outbox[event_id] = replace(row, dead_at=now, last_error=detail) + else: + self._outbox[event_id] = replace(row, attempts=row.attempts + 1, last_error=detail) def mutate_tenant( self, @@ -320,6 +408,7 @@ class InMemoryTenantStore: idempotency_key: str, request_fingerprint: str, at: datetime, + authz: dict[str, Any] | None = None, ) -> tuple[Tenant, GuardrailChange | None, bool]: resolved = self._resolve(tenant_id) @@ -342,9 +431,7 @@ class InMemoryTenantStore: raise VersionConflictError(expected=expected_version, actual=current.version) overrides = self._overrides.setdefault(resolved, {}) - guard_guardrail_write( - tenant=current, overrides=overrides, limit_key=limit_key, value=value - ) + guard_guardrail_write(tenant=current, overrides=overrides, limit_key=limit_key, value=value) previous = overrides.get(limit_key) change = GuardrailChange( @@ -377,6 +464,7 @@ class InMemoryTenantStore: "cleared": value is None, "correlation_id": correlation_id, "version": updated.version, + **(authz or {}), }, ) return updated, change, False @@ -405,6 +493,19 @@ class InMemoryTenantStore: return resolved def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None: + event_id = str(payload.get("event_id") or new_event_id()) + payload = {**payload, "event_id": event_id} + at = datetime.now(UTC) self._events.append( - DomainEvent(event_type=event_type, tenant_id=tenant_id, at=datetime.now(UTC), payload=payload) + DomainEvent(event_type=event_type, tenant_id=tenant_id, at=at, payload=payload) + ) + envelope = envelope_for( + event_id=event_id, + event_type=event_type, + tenant_id=tenant_id, + observed_at=at.isoformat(), + payload=payload, + ) + self._outbox[event_id] = OutboxRow( + event_id=event_id, tenant_id=tenant_id, envelope=envelope, created_at=at ) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..00c87de --- /dev/null +++ b/tests/helpers.py @@ -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) diff --git a/tests/postgres_backend.py b/tests/postgres_backend.py index 44849db..e911bd7 100644 --- a/tests/postgres_backend.py +++ b/tests/postgres_backend.py @@ -17,11 +17,12 @@ def clean_postgres_store(tmp_path: Path) -> PostgresTenantStore: except ImportError: pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance") - migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql" + migrations = Path(__file__).parents[1] / "migrations/postgres" with psycopg.connect(dsn, autocommit=True) as connection: - connection.execute(migration.read_text(encoding="utf-8")) + for path in sorted(migrations.glob("*.sql")): + connection.execute(path.read_text(encoding="utf-8")) connection.execute( - """TRUNCATE guardrail_changes, guardrail_overrides, + """TRUNCATE audit_outbox, authz_records, guardrail_changes, guardrail_overrides, idempotency_receipts, events, plans, grants, tenants RESTART IDENTITY CASCADE""" ) diff --git a/tests/test_api_guardrails.py b/tests/test_api_guardrails.py index 34f28b4..582119a 100644 --- a/tests/test_api_guardrails.py +++ b/tests/test_api_guardrails.py @@ -2,9 +2,10 @@ import pytest from fastapi.testclient import TestClient +from helpers import AllowAllAuthorizer, ScopedAuthorizer, deny from tenant_engine.app import create_app -from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer +from tenant_engine.authz import AuthorizationOutcome from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError KEY = "spend.monthly" @@ -13,36 +14,18 @@ LIMIT = {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"} BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"} -class _AllowAllAuthorizer(WriteAuthorizer): - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - return None - - -class _ScopedAuthorizer(WriteAuthorizer): - def __init__(self, *allowed: str) -> None: - self._allowed = set(allowed) - - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - if action not in self._allowed: - raise WriteAuthorizationDeniedError(action, "not permitted") - - -class _TenantScopedAuthorizer(WriteAuthorizer): - """Permits guardrail work on exactly one tenant. - - Stands in for a flex-auth policy that scopes an operator to their own - tenant -- the case where a caller is authenticated and permitted in - general, but not for *this* tenant. - """ +class _TenantScopedAuthorizer: + """Permits guardrail work on exactly one tenant.""" def __init__(self, permitted_tenant: str) -> None: self._permitted = permitted_tenant - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: + def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome: if action == "tenant.create": - return + return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor) if tenant_id != self._permitted: - raise WriteAuthorizationDeniedError(action, "not permitted for this tenant") + raise deny(action, tenant_id, actor, "not permitted for this tenant") + return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor) class _BrokenStore(InMemoryTenantStore): @@ -57,7 +40,7 @@ class _BrokenWriteStore(InMemoryTenantStore): def make_client(authorizer=None, store=None) -> TestClient: app = create_app( - store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer() + store=store or InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer() ) client = TestClient(app) client.post( @@ -107,19 +90,19 @@ def test_a_trial_tenant_reads_a_zero_spend_ceiling(client): def test_read_is_authorized_separately_from_write(): # a PDP gets the read and nothing else - client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.read")) + client = make_client(ScopedAuthorizer("tenant.create", "tenant.guardrail.read")) assert read(client).status_code == 200 assert put(client).status_code == 403 def test_write_permission_does_not_confer_read_permission(): - client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.set")) + client = make_client(ScopedAuthorizer("tenant.create", "tenant.guardrail.set")) assert read(client).status_code == 403 assert put(client).status_code == 200 def test_an_unauthorized_read_cannot_probe_tenant_existence(): - client = make_client(_ScopedAuthorizer("tenant.create")) + client = make_client(ScopedAuthorizer("tenant.create")) known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"}) unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"}) assert known.status_code == unknown.status_code == 403 @@ -330,7 +313,7 @@ def test_a_store_outage_fails_closed_on_write(): def test_errors_never_reflect_policy_internals(): - client = make_client(_ScopedAuthorizer("tenant.create")) + client = make_client(ScopedAuthorizer("tenant.create")) body = put(client).json() assert "tenant.db" not in str(body) assert body["error_code"] == "write_denied" diff --git a/tests/test_api_lifecycle.py b/tests/test_api_lifecycle.py index 7f9d0dc..5d7f2e0 100644 --- a/tests/test_api_lifecycle.py +++ b/tests/test_api_lifecycle.py @@ -2,32 +2,15 @@ import pytest from fastapi.testclient import TestClient +from helpers import AllowAllAuthorizer, ScopedAuthorizer from tenant_engine.app import create_app -from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'} BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"} -class _AllowAllAuthorizer(WriteAuthorizer): - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - return None - - -class _ScopedAuthorizer(WriteAuthorizer): - """Allows only the listed actions -- stands in for a flex-auth policy that - grants an operator metadata edits but not retirement.""" - - def __init__(self, *allowed: str) -> None: - self._allowed = set(allowed) - - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - if action not in self._allowed: - raise WriteAuthorizationDeniedError(action, "not permitted") - - class _BrokenStore(InMemoryTenantStore): def mutate_tenant(self, **kwargs): raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused") @@ -38,7 +21,7 @@ class _BrokenStore(InMemoryTenantStore): @pytest.fixture def client() -> TestClient: - app = create_app(store=InMemoryTenantStore(), authorizer=_AllowAllAuthorizer()) + app = create_app(store=InMemoryTenantStore(), authorizer=AllowAllAuthorizer()) test_client = TestClient(app) test_client.post( "/tenants", @@ -79,9 +62,7 @@ def test_get_tenant_returns_record_and_etag(client) -> None: def test_get_tenant_resolves_by_identifier(client) -> None: - response = client.get( - "/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"} - ) + response = client.get("/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}) assert response.status_code == 200 assert response.json()["tenant_id"] == "t-1" @@ -278,7 +259,7 @@ def test_lifecycle_mutations_are_denied_by_default() -> None: def test_update_permission_does_not_imply_retire_permission() -> None: app = create_app( store=InMemoryTenantStore(), - authorizer=_ScopedAuthorizer("tenant.create", "tenant.update"), + authorizer=ScopedAuthorizer("tenant.create", "tenant.update"), ) client = TestClient(app) client.post( @@ -292,7 +273,7 @@ def test_update_permission_does_not_imply_retire_permission() -> None: def test_store_outage_is_a_redacted_503() -> None: - client = TestClient(create_app(store=_BrokenStore(), authorizer=_AllowAllAuthorizer())) + client = TestClient(create_app(store=_BrokenStore(), authorizer=AllowAllAuthorizer())) read = client.get("/tenants/t-1", params={"actor": "tenant-engine"}) write = client.patch( diff --git a/tests/test_api_reads.py b/tests/test_api_reads.py index f8dd552..3761c23 100644 --- a/tests/test_api_reads.py +++ b/tests/test_api_reads.py @@ -1,20 +1,15 @@ from datetime import UTC, datetime from fastapi.testclient import TestClient +from helpers import AllowAllAuthorizer from tenant_engine.app import create_app -from tenant_engine.authz import WriteAuthorizer from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant from tenant_engine.store import InMemoryTenantStore, TenantStore -class _AllowAll(WriteAuthorizer): - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - return None - - def _client(store: TenantStore) -> TestClient: - return TestClient(create_app(store=store, authorizer=_AllowAll())) + return TestClient(create_app(store=store, authorizer=AllowAllAuthorizer())) class _BrokenStore: @@ -43,8 +38,11 @@ class _BrokenStore: def assign_plan(self, assignment): return self._delegate.assign_plan(assignment) - def events(self): - return self._delegate.events() + def events_for(self, tenant_id): + return self._delegate.events_for(tenant_id) + + def record_authorization(self, record): + return None def _seeded_store() -> InMemoryTenantStore: @@ -81,9 +79,7 @@ def test_cache_read_roles_resolves_by_identifier_not_only_internal_id() -> None: a URL path segment containing colons -- never the internal tenant_id. """ client = _client(_seeded_store()) - response = client.get( - "/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"} - ) + response = client.get("/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"}) assert response.status_code == 200 assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]} diff --git a/tests/test_api_writes.py b/tests/test_api_writes.py index db0769b..6f49043 100644 --- a/tests/test_api_writes.py +++ b/tests/test_api_writes.py @@ -1,18 +1,13 @@ from fastapi.testclient import TestClient +from helpers import AllowAllAuthorizer from tenant_engine.app import create_app -from tenant_engine.authz import WriteAuthorizer from tenant_engine.store import InMemoryTenantStore -class _AllowAllAuthorizer(WriteAuthorizer): - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - return None - - def _client(*, allow: bool = False) -> TestClient: store = InMemoryTenantStore() - authorizer = _AllowAllAuthorizer() if allow else None + authorizer = AllowAllAuthorizer() if allow else None return TestClient(create_app(store=store, authorizer=authorizer)) @@ -99,7 +94,9 @@ def test_create_tenant_rejects_invalid_identifier_after_authorization() -> None: def test_create_tenant_duplicate_is_409() -> None: client = _client(allow=True) - client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}) + client.post( + "/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"} + ) response = client.post( "/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"} ) @@ -108,7 +105,9 @@ def test_create_tenant_duplicate_is_409() -> None: def test_grant_role_plan_assignment_without_plan_id_is_400() -> None: client = _client(allow=True) - client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}) + client.post( + "/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"} + ) response = client.post( "/tenants/t-1/roles/grant", diff --git a/tests/test_audit_core.py b/tests/test_audit_core.py new file mode 100644 index 0000000..4eef957 --- /dev/null +++ b/tests/test_audit_core.py @@ -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 diff --git a/tests/test_authz_flex.py b/tests/test_authz_flex.py index dcea9f8..01d296a 100644 --- a/tests/test_authz_flex.py +++ b/tests/test_authz_flex.py @@ -32,7 +32,10 @@ def test_create_app_uses_flex_auth_authorizer_when_url_configured() -> None: def test_flex_auth_authorizer_denies_on_deny_effect() -> None: def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}}) + return httpx.Response( + 200, + json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}}, + ) client = FlexAuthCheckClient( base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) @@ -48,7 +51,14 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( - 200, json={"id": "d-1", "effect": "not_applicable", "resource": {}, "subject": {}, "provenance": {}} + 200, + json={ + "id": "d-1", + "effect": "not_applicable", + "resource": {}, + "subject": {}, + "provenance": {}, + }, ) client = FlexAuthCheckClient( @@ -62,7 +72,10 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None: def test_flex_auth_authorizer_allows_on_allow_effect() -> None: def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + return httpx.Response( + 200, + json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}, + ) client = FlexAuthCheckClient( base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) @@ -80,7 +93,10 @@ def test_full_write_lifecycle_succeeds_when_flex_auth_allows() -> None: """ def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + return httpx.Response( + 200, + json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}, + ) client = FlexAuthCheckClient( base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) diff --git a/tests/test_events_scoped.py b/tests/test_events_scoped.py new file mode 100644 index 0000000..2cc6ed4 --- /dev/null +++ b/tests/test_events_scoped.py @@ -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") diff --git a/tests/test_flex_auth.py b/tests/test_flex_auth.py index 7a2227e..3ecd02e 100644 --- a/tests/test_flex_auth.py +++ b/tests/test_flex_auth.py @@ -28,7 +28,10 @@ def _client(handler) -> FlexAuthCheckClient: def test_allow_effect_authorizes() -> None: def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + return httpx.Response( + 200, + json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}, + ) assert _client(handler).is_allowed(_request()) is True @@ -36,7 +39,10 @@ def test_allow_effect_authorizes() -> None: @pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"]) def test_non_allow_effects_deny(effect: str) -> None: def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}}) + return httpx.Response( + 200, + json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}}, + ) assert _client(handler).is_allowed(_request()) is False @@ -83,7 +89,10 @@ def test_request_body_matches_schema_shape() -> None: import json seen.update(json.loads(request.content)) - return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + return httpx.Response( + 200, + json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}, + ) _client(handler).is_allowed(_request()) diff --git a/tests/test_grouping_mutation.py b/tests/test_grouping_mutation.py index 21a5962..03a2597 100644 --- a/tests/test_grouping_mutation.py +++ b/tests/test_grouping_mutation.py @@ -9,9 +9,9 @@ from datetime import UTC, datetime import pytest from fastapi.testclient import TestClient +from helpers import AllowAllAuthorizer, ScopedAuthorizer from tenant_engine.app import create_app -from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer from tenant_engine.domain import ( CapabilityRole, EmptyUpdateError, @@ -148,22 +148,8 @@ def test_a_new_platform_default_grant_is_refused_after_moving_off_trial(): # --- API ---------------------------------------------------------------- -class _AllowAll(WriteAuthorizer): - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - return None - - -class _Scoped(WriteAuthorizer): - def __init__(self, *allowed: str) -> None: - self._allowed = set(allowed) - - def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: - if action not in self._allowed: - raise WriteAuthorizationDeniedError(action, "not permitted") - - def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient: - app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or _AllowAll()) + app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer()) client = TestClient(app) client.post( "/tenants", @@ -191,35 +177,47 @@ def test_the_route_reclassifies_and_bumps_the_version(): def test_the_new_ceiling_is_visible_through_the_guardrail_read(): client = make_client(identifier="tenant:trial:acme") - assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[ - "limits" - ][KEY]["amount"] == 0 + assert ( + client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][ + "amount" + ] + == 0 + ) post_grouping(client, "medium") - assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[ - "limits" - ][KEY]["amount"] == 100_000 + assert ( + client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][ + "amount" + ] + == 100_000 + ) def test_reclassification_is_authorized_separately_from_a_rename(): # policy can permit a display-name edit without permitting a move that # changes the spend ceiling - client = make_client(_Scoped("tenant.create", "tenant.update")) + client = make_client(ScopedAuthorizer("tenant.create", "tenant.update")) assert post_grouping(client).status_code == 403 - assert client.patch( - "/tenants/t-1", - json={"metadata": {"display_name": "Acme"}, **BODY}, - headers=HEADERS, - ).status_code == 200 + assert ( + client.patch( + "/tenants/t-1", + json={"metadata": {"display_name": "Acme"}, **BODY}, + headers=HEADERS, + ).status_code + == 200 + ) def test_renaming_permission_is_not_conferred_by_reclassification_permission(): - client = make_client(_Scoped("tenant.create", "tenant.grouping.set")) + client = make_client(ScopedAuthorizer("tenant.create", "tenant.grouping.set")) assert post_grouping(client).status_code == 200 - assert client.patch( - "/tenants/t-1", - json={"metadata": {"display_name": "Acme"}, **BODY}, - headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'}, - ).status_code == 403 + assert ( + client.patch( + "/tenants/t-1", + json={"metadata": {"display_name": "Acme"}, **BODY}, + headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'}, + ).status_code + == 403 + ) def test_an_unknown_grouping_is_a_distinct_error_code(): @@ -273,7 +271,7 @@ def test_the_change_is_auditable_as_its_own_event(): client = make_client() post_grouping(client) store = client.app.state.store - events = [e for e in store.events() if e.event_type == "tenant_grouping_changed"] + events = [e for e in store.events_for("t-1") if e.event_type == "tenant_grouping_changed"] assert len(events) == 1 assert events[0].payload["actor"] == "ops" assert events[0].payload["reason"] == "grew past the band" diff --git a/tests/test_guardrail_domain.py b/tests/test_guardrail_domain.py index c6c5031..7f8b168 100644 --- a/tests/test_guardrail_domain.py +++ b/tests/test_guardrail_domain.py @@ -73,9 +73,12 @@ def test_reduced_to_floor_only_ever_reduces(): floor = eur(0) assert eur(500).reduced_to_floor(floor) == floor assert eur(0).reduced_to_floor(eur(500)) == eur(0) - assert LimitValue( - kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED - ).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)).amount == 3 + assert ( + LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED) + .reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)) + .amount + == 3 + ) def test_clamping_across_kinds_is_a_conflict(): @@ -186,9 +189,7 @@ def test_override_beats_plan_beats_grouping(): grouping_only = resolve_limit("spend.monthly", tenant=t) assert grouping_only.provenance is Provenance.GROUPING - with_plan = resolve_limit( - "spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)} - ) + with_plan = resolve_limit("spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)}) assert with_plan.provenance is Provenance.PLAN assert with_plan.value.amount == 60_000 diff --git a/tests/test_guardrail_store_conformance.py b/tests/test_guardrail_store_conformance.py index 7b4756a..e373845 100644 --- a/tests/test_guardrail_store_conformance.py +++ b/tests/test_guardrail_store_conformance.py @@ -140,7 +140,7 @@ def test_the_audit_trail_is_append_only(store, tenant): def test_a_guardrail_change_emits_a_domain_event(store, tenant): set_override(store, tenant, eur(9_000)) - events = [e for e in store.events() if e.event_type == "guardrail_changed"] + events = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "guardrail_changed"] assert len(events) == 1 assert events[0].payload["limit_key"] == KEY assert events[0].payload["correlation_id"] == "corr-1" @@ -161,9 +161,11 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant): entity = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED) # spend.monthly is the only registered key, so use it to prove the # sentinel serialises; the kind check lives in the domain tests - set_override(store, tenant, LimitValue( - kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M" - )) + set_override( + store, + tenant, + LimitValue(kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"), + ) stored = store.guardrail_overrides(tenant.tenant_id)[KEY] assert stored.is_unlimited assert entity.is_unlimited @@ -175,8 +177,9 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant): def test_a_stale_version_conflicts(store, tenant): set_override(store, tenant, eur(9_000)) with pytest.raises(VersionConflictError): - set_override(store, tenant, eur(1_000), version=1, change_id="c-2", - idempotency_key="idem-2") + set_override( + store, tenant, eur(1_000), version=1, change_id="c-2", idempotency_key="idem-2" + ) def test_replay_returns_the_original_result_without_reapplying(store, tenant): @@ -204,8 +207,7 @@ def test_an_unregistered_key_is_rejected_before_anything_is_written(store, tenan def test_a_failed_write_leaves_no_audit_record_and_no_version_bump(store, tenant): set_override(store, tenant, eur(9_000)) with pytest.raises(VersionConflictError): - set_override(store, tenant, eur(1), version=99, change_id="c-2", - idempotency_key="idem-2") + set_override(store, tenant, eur(1), version=99, change_id="c-2", idempotency_key="idem-2") assert store.get_tenant(tenant.tenant_id).version == 2 assert len(store.guardrail_changes(tenant.tenant_id)) == 1 assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000 @@ -265,6 +267,5 @@ def test_clearing_an_override_that_would_loosen_is_refused_while_retired(store, ) # clearing would fall back to small's 25_000 default -- a loosening with pytest.raises(TenantRetiredError): - set_override(store, tenant, None, version=3, change_id="c-3", - idempotency_key="idem-3") + set_override(store, tenant, None, version=3, change_id="c-3", idempotency_key="idem-3") assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 100 diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py new file mode 100644 index 0000000..534b59f --- /dev/null +++ b/tests/test_layer_conformance.py @@ -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"} diff --git a/tests/test_lifecycle_store_conformance.py b/tests/test_lifecycle_store_conformance.py index 42fdae4..315d7b7 100644 --- a/tests/test_lifecycle_store_conformance.py +++ b/tests/test_lifecycle_store_conformance.py @@ -212,13 +212,13 @@ def test_retirement_preserves_existing_grant_and_plan_history(store, tenant) -> # Retirement is not a revocation: history stays queryable for audit and # so reactivation does not have to reconstruct anything. assert store.active_roles("t-1") == frozenset({CapabilityRole.CUS}) - assert any(event.event_type == "plan_assigned" for event in store.events()) + assert any(event.event_type == "plan_assigned" for event in store.events_for(tenant.tenant_id)) def test_mutation_emits_a_correlated_audit_event(store, tenant) -> None: _rename(store, key="k1", version=1) - event = [e for e in store.events() if e.event_type == "tenant_updated"][-1] + event = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "tenant_updated"][-1] assert event.payload["actor"] == "ops" assert event.payload["reason"] == "rename" assert event.payload["correlation_id"] == "corr-1" diff --git a/tests/test_pep_write_path.py b/tests/test_pep_write_path.py new file mode 100644 index 0000000..c32e848 --- /dev/null +++ b/tests/test_pep_write_path.py @@ -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"] diff --git a/tests/test_pip_claims.py b/tests/test_pip_claims.py new file mode 100644 index 0000000..00169a5 --- /dev/null +++ b/tests/test_pip_claims.py @@ -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) diff --git a/tests/test_store.py b/tests/test_store.py index 3570e0d..a2d9640 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -11,7 +11,9 @@ from tenant_engine.store import ( ) -def _store_with_tenant(*, grouping: str = "friendly", name: str = "binky") -> tuple[InMemoryTenantStore, Tenant]: +def _store_with_tenant( + *, grouping: str = "friendly", name: str = "binky" +) -> tuple[InMemoryTenantStore, Tenant]: store = InMemoryTenantStore() tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}") store.create_tenant(tenant) @@ -93,15 +95,22 @@ def test_non_exclusive_roles_coexist() -> None: ) ) - assert store.active_roles(tenant.tenant_id) == frozenset({CapabilityRole.CUS, CapabilityRole.VEN}) + assert store.active_roles(tenant.tenant_id) == frozenset( + {CapabilityRole.CUS, CapabilityRole.VEN} + ) def test_assign_plan() -> None: store, tenant = _store_with_tenant() - store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))) + store.assign_plan( + PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)) + ) - events = store.events() - assert any(event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x" for event in events) + events = store.events_for(tenant.tenant_id) + assert any( + event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x" + for event in events + ) def test_get_tenant_resolves_by_identifier_not_only_internal_id() -> None: @@ -181,7 +190,9 @@ def test_every_mutation_emits_an_event() -> None: ) store.grant_role(grant) store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC)) - store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))) + store.assign_plan( + PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)) + ) - event_types = [event.event_type for event in store.events()] + event_types = [event.event_type for event in store.events_for(tenant.tenant_id)] assert event_types == ["tenant_created", "role_granted", "role_revoked", "plan_assigned"] diff --git a/uv.lock b/uv.lock index 6d925d2..7881b2a 100644 --- a/uv.lock +++ b/uv.lock @@ -482,6 +482,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "pytest" }, + { name = "pyyaml" }, { name = "ruff" }, ] postgres = [ @@ -496,6 +497,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" }, { name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2,<9.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6,<1.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" }, ] diff --git a/workplans/TEN-WP-0011-security-layer-conformance.md b/workplans/TEN-WP-0011-security-layer-conformance.md index edb858d..b392e3d 100644 --- a/workplans/TEN-WP-0011-security-layer-conformance.md +++ b/workplans/TEN-WP-0011-security-layer-conformance.md @@ -4,7 +4,7 @@ type: workplan title: "Align tenant-engine with the accepted security layer model" domain: infotech repo: tenant-engine -status: ready +status: finished owner: grok topic_slug: netkingdom created: "2026-08-29" @@ -58,7 +58,7 @@ code. ```task id: TEN-WP-0011-T01 -status: todo +status: done priority: high state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72" ``` @@ -81,7 +81,7 @@ in `src/`. ```task id: TEN-WP-0011-T02 -status: todo +status: done priority: high state_hub_task_id: "62659305-8283-594e-8670-f9335f5c74a0" ``` @@ -114,7 +114,7 @@ file == code; no write path can succeed without one of those two records. ```task id: TEN-WP-0011-T03 -status: todo +status: done priority: high state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111" ``` @@ -145,7 +145,7 @@ evidence, not a comment. ```task id: TEN-WP-0011-T04 -status: todo +status: done priority: high state_hub_task_id: "cfab6837-5f62-5beb-b99f-943d39b5b94d" ``` @@ -178,11 +178,16 @@ Done when: production mutations emit to `audit-core` under the bound above; the trade (atomic vs non-blocking) is declared; `TEN-IN-0001` can close as promoted and completed. +**Done 2026-08-29:** local outbox is in the mutation transaction; drain +POSTs `/v1/events` and never fails the mutation; trade declared in +`docs/evidence-emission.md`; no pre-cutover backfill. Production landing +waits on sender registration, requested as `AUDIT-IN-0002`. + ## T05 — Remove or authorize the unfiltered event-read interface ```task id: TEN-WP-0011-T05 -status: todo +status: done priority: high state_hub_task_id: "7540049b-9b76-5d16-9ce4-57395a53ecbc" ``` @@ -202,11 +207,15 @@ the dump. Done when: a production caller cannot list another tenant's events through this repo's store protocol; tests say so; `TEN-IN-0002` can close. +**Done 2026-08-29:** `events()` removed from the production protocol; +`events_for(tenant_id)` is the only read. Cross-tenant negative test in +`tests/test_events_scoped.py`. Reading handed to risk-nexus. + ## T06 — Request the boundary-contract amendment ```task id: TEN-WP-0011-T06 -status: todo +status: done priority: medium state_hub_task_id: "ffd2cc53-bde2-5e3c-a144-ab565bc12776" ``` @@ -229,3 +238,6 @@ to: Done when: the intake exists on the canon side with a named id, and this task records it. + +**Done 2026-08-29:** filed `NET-IN-0002` on `net-kingdom` +(`net-kingdom/intakes/intakes.md`).