diff --git a/approval_engine/cli.py b/approval_engine/cli.py index 1d44e14..05802af 100644 --- a/approval_engine/cli.py +++ b/approval_engine/cli.py @@ -37,7 +37,7 @@ def _parser() -> argparse.ArgumentParser: serve.add_argument("--host", default="127.0.0.1") serve.add_argument("--port", type=int, default=8787) serve.add_argument("--production", action="store_true") - serve.add_argument("--tenant", default="platform") + serve.add_argument("--tenant", default="tenant:platform") serve.add_argument("--jwt-issuer") serve.add_argument("--jwt-audience") serve.add_argument("--jwks-url") diff --git a/approval_engine/store.py b/approval_engine/store.py index e77da4f..455e8d4 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -165,7 +165,7 @@ class Engine: clock: Callable[[], datetime] | None = None, freshness_ttl: int = DEFAULT_FRESHNESS_TTL, fail_outbox: bool = False, - tenant: str = "platform", + tenant: str = "tenant:platform", auto_migrate: bool = True, ) -> None: self.path = str(path) diff --git a/deploy/approval-engine.yaml b/deploy/approval-engine.yaml index 36649f8..3a549ae 100644 --- a/deploy/approval-engine.yaml +++ b/deploy/approval-engine.yaml @@ -64,7 +64,7 @@ spec: - --port - "8080" - --tenant - - platform + - tenant:platform - --jwt-issuer - https://kc.coulomb.social - --jwt-audience diff --git a/docs/keycape-service-registrations.md b/docs/keycape-service-registrations.md index 7fc0b7b..6ac629f 100644 --- a/docs/keycape-service-registrations.md +++ b/docs/keycape-service-registrations.md @@ -21,44 +21,57 @@ Required claims remain those in `docs/caller-authentication.md`: `iss`, `sub`, | JWKS | `GET /jwks` on the KeyCape service | | Scopes | `approval:create`, `approval:read`, `approval:approve`, `approval:revoke`, `approval:supersede`, `approval:consume`, `approval:observe`, `approval:emit` | -## Tenant reconciliation — unresolved, blocks token issuance +## Tenant — resolved: exact `tenant:platform` -**This is a live collision, not a naming preference.** `ApiApplication.identity` -compares the verified JWT `tenant` claim to the engine's configured store tenant -with exact string equality and raises `Forbidden` before any object lookup +The operator accepted `tenant:platform` as the platform management, +administration and services tenant (the landlord zone). Decision +`5ed3fb35-eca9-413a-82b9-95171ba85bf6`, recorded in +`glas-harness/docs/platform-tenant-decision.md` and relayed by `glas-harness` +2026-09-06. + +**The spelling is the contract.** `ApiApplication.identity` compares the +verified JWT `tenant` claim to the engine's configured store tenant with exact +string equality and raises `Forbidden` before any object lookup (`approval_engine/api.py:66`). There is no mapping table, no normalisation, and -no prefix handling anywhere in this engine. +no prefix handling anywhere in this engine — deliberately. `platform` is **not** +an accepted alias for `tenant:platform`, and neither is `tenant:coulomb`. -The three values currently in play: +Rendered values, all three now exactly `tenant:platform`: -| Value | Where it is set | Current content | +| Value | Where it is set | Content | | --- | --- | --- | -| Store tenant | `deploy/approval-engine.yaml` `--tenant` (CLI default `platform`) | `platform` | -| JWT `tenant` claim | the client registrations below | `tenant:coulomb` | -| CheckRequest tenant | flex-auth policy subject | `tenant:platform` — **never read by this engine** | +| Store tenant | `deploy/approval-engine.yaml` `--tenant` | `tenant:platform` | +| Store tenant default | `approval_engine/cli.py` `--tenant`, `Engine(tenant=…)` | `tenant:platform` | +| JWT `tenant` claim | the client registrations below | `tenant:platform` | +| CheckRequest tenant | flex-auth policy subject | `tenant:platform` | -`platform` != `tenant:coulomb`, so tokens issued under the registrations below -would be denied `403` on every non-health route. Spelling similarity between -`platform` and `tenant:platform` is not a mapping either; the flex-auth policy -subject is a PDP input this engine never inspects, so it cannot participate in -the comparison at all. +The CLI and `Engine` defaults were moved off `platform` in the same change. A +default that differs from the sanctioned value is a trap: a `serve` invocation +that omits `--tenant` would have come up healthy and then refused every +authenticated call, which is the failure this decision exists to prevent. -Denial evidence: `tests/test_auth.py::test_wrong_tenant_is_forbidden` — a -signature-valid token whose tenant differs from the store tenant is refused -without mutation. +The flex-auth CheckRequest tenant now matches by spelling, but note it still +does not participate in this comparison — it is a PDP policy subject this engine +never reads. Alignment there is a property of the decision, not a mechanism +here. -**Resolution is an owner decision and is deliberately not taken here.** Either -KeyCape issues `tenant: platform` to match the store, or this repo's manifest -sets `--tenant tenant:coulomb` to match the registration. Which is correct -depends on whether `platform` and `tenant:coulomb` name the same layer — a -question this engine cannot answer, and answering it wrongly grants a token -cross-tenant access to the approval store. The values below are left as -requested until that mapping is stated by an owner, so the mismatch stays -visible rather than being silently resolved by whichever document was edited -last. +Denial evidence: + +- `tests/test_auth.py::test_wrong_tenant_is_forbidden` — a signature-valid token + whose tenant differs from the store tenant is refused `403` without mutation. +- `tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden` — pins the + decision's "no alias" clause directly: `platform`, `tenant:coulomb`, + `TENANT:PLATFORM`, `tenant:platform ` (trailing space) and `` (empty) are each + refused against a `tenant:platform` store, while the exact spelling is + admitted. Without a test that *varies* the tenant, a suite of fixtures all + carrying the sanctioned value proves nothing about the field. + +This resolves the choice of value only. Verification and the remaining admission +gates — KeyCape actually owning these registrations, credential materialization, +and the `T03` rollout evidence — are unchanged and still open. Tracked against `APPROVAL-WP-0002-T01`; related `KEY-WP-0013-T02`, -`SECRETS-WP-0009-T03`. +`SECRETS-WP-0009-T03`, `GLAS-WP-0015`. ## Clients @@ -76,7 +89,7 @@ clients: secretRef: env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET serviceSubject: service:secrets-engine principal_type: service - tenant: tenant:coulomb + tenant: tenant:platform roles: [secrets-engine] tokenLifetime: 15m @@ -96,7 +109,7 @@ clients: secretRef: env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET serviceSubject: service:approval-engine-operator principal_type: service - tenant: tenant:coulomb + tenant: tenant:platform roles: [approval-operator] tokenLifetime: 15m ``` diff --git a/tests/conftest.py b/tests/conftest.py index 473fad6..e35226b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ def app(engine): issuer="https://keycape.example", audiences=("approval-engine",), principal_type="service", - tenant="platform", + tenant="tenant:platform", roles=frozenset({"secrets-engine"}), scopes=frozenset( { diff --git a/tests/test_auth.py b/tests/test_auth.py index ee786c7..bfda2a3 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -118,7 +118,7 @@ def test_api_requires_scope_and_binds_create_actor(engine): issuer="test", audiences=("approval-engine",), principal_type="service", - tenant="platform", + tenant="tenant:platform", roles=frozenset(), scopes=frozenset({"approval:create"}), assurance={"level": "aal1"}, @@ -201,6 +201,72 @@ def test_wrong_tenant_is_forbidden(engine): assert body["error"] == "forbidden" +@pytest.mark.parametrize( + "tenant", + [ + "platform", + "tenant:coulomb", + "TENANT:PLATFORM", + "Tenant:Platform", + "tenant:platform ", + " tenant:platform", + "tenant:platform:", + "", + ], +) +def test_near_miss_tenant_spellings_are_forbidden(engine, tenant): + """Decision 5ed3fb35 accepted exactly `tenant:platform` with no alias. + + The store comparison is exact string equality, so every near miss below must + be refused: the bare `platform` this repo used to serve, the `tenant:coulomb` + the registrations used to request, case variants, and whitespace. Varying the + field is the point — a suite whose fixtures all carry the sanctioned value + proves nothing about the tenant check. + """ + identity = Identity( + subject="agt-secrets-engine", + issuer="test", + audiences=("approval-engine",), + principal_type="service", + tenant=tenant, + roles=frozenset(), + scopes=frozenset({"approval:observe"}), + assurance={}, + evidence_ref="test", + ) + from approval_engine.api import App + + app = App(engine, StaticTokenAuthenticator({"near": identity})) + status, body = call(app, "GET", "/v1/cadence", authorization="Bearer near") + assert status == 403, f"{tenant!r} was admitted as an alias" + assert body["error"] == "forbidden" + + +def test_exact_sanctioned_tenant_is_admitted(engine): + """The other half of the pin: the exact spelling must actually work. + + Without this, a bug that rejected every tenant would pass the near-miss test + above while denying the sanctioned caller too. + """ + assert engine.tenant == "tenant:platform" + identity = Identity( + subject="agt-secrets-engine", + issuer="test", + audiences=("approval-engine",), + principal_type="service", + tenant="tenant:platform", + roles=frozenset(), + scopes=frozenset({"approval:observe"}), + assurance={}, + evidence_ref="test", + ) + from approval_engine.api import App + + app = App(engine, StaticTokenAuthenticator({"exact": identity})) + status, _ = call(app, "GET", "/v1/cadence", authorization="Bearer exact") + assert status == 200 + + def test_deny_all_default_does_not_mutate(engine): from approval_engine.api import App @@ -225,7 +291,7 @@ def test_human_principal_cannot_consume(engine): issuer="test", audiences=("approval-engine",), principal_type="service", - tenant="platform", + tenant="tenant:platform", roles=frozenset(), scopes=frozenset( {"approval:create", "approval:approve", "approval:read", "approval:consume"} @@ -238,7 +304,7 @@ def test_human_principal_cannot_consume(engine): issuer="test", audiences=("approval-engine",), principal_type="human", - tenant="platform", + tenant="tenant:platform", roles=frozenset(), scopes=frozenset({"approval:consume"}), assurance={"level": "aal2"}, diff --git a/tests/test_pep.py b/tests/test_pep.py index f496fc6..5a2659c 100644 --- a/tests/test_pep.py +++ b/tests/test_pep.py @@ -185,7 +185,7 @@ def live_http(tmp_path): issuer="https://keycape.example", audiences=("approval-engine",), principal_type="service", - tenant="platform", + tenant="tenant:platform", roles=frozenset({"secrets-engine"}), scopes=frozenset( { diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md index dba5265..795d6c7 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -90,6 +90,37 @@ the approval store. The registrations doc's stale issuer (`https://auth.netkingdom.local`) is corrected to the live `https://kc.coulomb.social` from `06544b0`. T01 stays `progress`. +2026-09-06 resolution: the operator accepted `tenant:platform` as the platform +management/administration/services tenant (landlord zone) — decision +`5ed3fb35-eca9-413a-82b9-95171ba85bf6`, +`glas-harness/docs/platform-tenant-decision.md`, relayed by `glas-harness`. The +collision above is closed by setting the store tenant to exactly +`tenant:platform`: `deploy/approval-engine.yaml` `--tenant`, the +`approval_engine/cli.py` `--tenant` default, and the `Engine(tenant=…)` default +all move off bare `platform`, and the requested client registrations now ask for +`tenant: tenant:platform`. Exact JWT/store equality is retained — no alias, no +normalisation, no prefix handling, and no implicit cross-tenant grant. + +Moving the *defaults* rather than only the manifest is deliberate: a default of +`platform` under a sanctioned value of `tenant:platform` is a trap, because a +`serve` that omits `--tenant` would come up healthy and then refuse every +authenticated call. + +The default change broke ten tests whose identity fixtures hard-coded +`platform`, which is the fixture-consistency hazard flex-auth reported as +`FLEX-DEC-2026-008` (29 fixtures all carrying one tenant proved nothing about +the field). Fixtures are aligned to the exact spelling, and the field is now +*varied* rather than merely present: +`tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden` refuses +`platform`, `tenant:coulomb`, case variants, whitespace variants and empty +against a `tenant:platform` store, and +`test_exact_sanctioned_tenant_is_admitted` pins the other half so a +reject-everything bug cannot pass. 111 tests pass. + +This resolves the choice of value only. T01 stays `progress`: KeyCape still has +to own and prove these registrations, and credential materialization is +unchanged. + ## Harden durable storage and migrations ```task @@ -138,6 +169,40 @@ read-only root, resources, probes, and default-deny policies pass client dry-run Waiting on release digest, KeyCape/audit registrations and credentials, rollout, restart, and restore evidence. +2026-09-06 image preparation (GLAS-WP-0015 request; credential-independent half): +built and validated locally against the agreed tenant and current schema. + +- Build: `make image-build` off the digest-pinned base + `python:3.12-slim@sha256:d764629c…`. Local manifest-list digest + `sha256:85e46ddf47b3ac0cdab620163a7b034027451dff286b63ecff6a2493d7a57ecc`. + **This is a local build digest, not a release digest** — the manifest still + carries `REPLACE_WITH_RELEASE_DIGEST` because pinning requires a push to + `forgejo.coulomb.social`, which needs registry credentials this session does + not hold. +- Runtime identity: `uid=10001(approval) gid=10001(approval)`, non-root as + required. +- Schema: image carries `LATEST_SCHEMA_VERSION = 3`, matching the migrated + store, and `Engine` tenant default `tenant:platform`. +- First-install migration (no prior DB): `migrate` then `verify` on a fresh + volume both report `schema_version: 3`, `schema_current: true`, + `integrity: ["ok"]`, `foreign_key_violations: 0`, `persistent: true`. +- Fail-closed configuration proven in the image, not only in tests: + `serve --production --db :memory:` refuses with "production requires a + persistent database"; `serve --production` on a real DB without audit + configuration refuses with "production requires authenticated audit + delivery". +- Manifest inputs: `kubectl apply --dry-run=client` passes for the namespace, + service, and StatefulSet with the new `--tenant tenant:platform`. + +Scan gate NOT met: no scanner (`trivy`, `grype`, `docker scout`) is installed on +this workstation, so no vulnerability scan was run and none is claimed. The +inventory a scanner needs is the pinned base above plus +`cryptography==50.0.1`, `PyJWT==2.13.0`, `waitress==3.0.2`, `cffi==2.1.1`, +`pycparser==3.0`, `pip==25.0.1`. + +T03 stays `wait`: still no release digest, no KeyCape/audit credentials, no +rollout, and no restart/restore evidence. Nothing here is a deploy. + ## Wire outbox delivery and reconciliation ```task