Set the approval store tenant to exact tenant:platform

Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management, administration and services tenant, with no alias
to platform or tenant:coulomb and no implicit cross-tenant grant. This closes
the collision recorded in 5c87ba8, where the manifest served --tenant platform
while the requested registrations issued tenant:coulomb.

The store tenant is now exactly tenant:platform in the manifest, the CLI
default, and the Engine default, and the requested client registrations ask for
the same spelling. Exact JWT/store equality is retained: no mapping table, no
normalisation, no prefix handling.

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 exact failure this decision exists to prevent.

That default change broke ten tests whose identity fixtures hard-coded
platform. This is the hazard flex-auth reported as FLEX-DEC-2026-008: fixtures
that all carry one tenant prove nothing about the tenant field. Fixtures are
aligned to the exact spelling, and the field is now varied rather than merely
present. test_near_miss_tenant_spellings_are_forbidden refuses platform,
tenant:coulomb, case variants, whitespace variants and empty against a
tenant:platform store; test_exact_sanctioned_tenant_is_admitted pins the other
half so a reject-everything bug cannot pass it. 111 tests pass.

Also records the credential-independent half of the GLAS-WP-0015 image request:
the image builds non-root uid 10001 off the pinned base, carries schema v3 and
the new tenant default, migrates and verifies a fresh store to schema_version 3
with integrity ok, and refuses production without a persistent database or
authenticated audit delivery. No scan was run -- no scanner is installed here --
and no release digest exists, so T01 and T03 both stay open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM5HnEAhokxdfcPqBNpT7D

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715850@bnt-lap001
Assistant-Session: eb557e93-7cb1-45d0-9e57-7d15b3edc60e
This commit is contained in:
tegwick 2026-09-06 22:33:50 +02:00
parent 5c87ba8610
commit 6d18f62a90
8 changed files with 182 additions and 38 deletions

View file

@ -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")

View file

@ -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)

View file

@ -64,7 +64,7 @@ spec:
- --port
- "8080"
- --tenant
- platform
- tenant:platform
- --jwt-issuer
- https://kc.coulomb.social
- --jwt-audience

View file

@ -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
```

View file

@ -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(
{

View file

@ -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"},

View file

@ -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(
{

View file

@ -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