diff --git a/docs/approval-claim.md b/docs/approval-claim.md index 3a99d2d..7bf83bd 100644 --- a/docs/approval-claim.md +++ b/docs/approval-claim.md @@ -47,7 +47,7 @@ contract. | --- | --- | | `approval_id` | Reconstructable from the decision record. | | `binding.digest` | Distinguishes *approved* from *approved for this exact request*. | -| `binding.pdp_digest` | Optional. When recorded at issue, compare to `NewDecisionBinding.request_digest`. | +| `binding.pdp_digest` | Optional. When recorded at issue, compare to the PDP's claim-excluded digest (`binding.approval_binding_digest`), not `request_digest`. | | `issuer` | Always `approval-engine`. | | `freshness` | So the PDP can state a deadline for this input class (§9.7.2), not a single fiction covering every source. | | `valid_now` | Current-state predicate. Not permission. | @@ -106,16 +106,18 @@ finds itself wanting one should read that as a signal it is about to compare the wrong two things. **`binding.pdp_digest` is the correspondence.** It sidesteps vocabulary -entirely: it is the PDP's own `NewDecisionBinding.request_digest`, recorded at -issue time, so comparing +entirely: it is the PDP's own `NewDecisionBinding` digest, recorded at issue +time, so comparing ```text -claim.binding.pdp_digest == decision.binding.request_digest +claim.binding.pdp_digest == decision.binding.approval_binding_digest ``` compares the PDP's digest to the PDP's digest, in one vocabulary, with no translation by anyone. That is strictly stronger than a name-to-name mapping -could be. +could be. On the execute-time side the target is the PDP's *claim-excluded* +digest, not `request_digest` — see "The comparison target at execute time" +below. `pdp_digest` is **always present** on the claim and is `null` when the approval was not issued against a PDP decision — a stated fact rather than a missing @@ -146,6 +148,38 @@ produces a confident wrong answer, and comparing two digests derived under different rules fails open in the direction of accepting a claim bound to a different request. +### The comparison target at execute time + +The issue-time description above is unchanged: this engine records the +`NewDecisionBinding` digest it was handed at issue. What changed is the field a +consumer compares it *against* at execute time. + +flex-auth's `request_digest` hashes `context`, and the dual-control pattern +carries the claim in `context.approval`. So a claim-bearing request's +`request_digest` covers the claim itself, and can never equal a digest recorded +before that claim existed. A consumer obeying `GH-DEC-2026-008` literally +against `request_digest` would fail closed permanently — not on a bad claim, but +on every claim, forever. + +flex-auth resolved this by publishing `binding.approval_binding_digest` +(`FLEX-DEC-2026-007`): the same digest material with `context.approval` +removed, emitted only when a claim was carried. An approval issued against a +claim-free Check records that Check's `request_digest`, and the claim-bearing +request reproduces the identical value in the new field. So: + +```text +claim.binding.pdp_digest == decision.binding.approval_binding_digest +``` + +`request_digest` keeps covering the claim on purpose — it is the replay +identity, and two requests differing only in which approval was presented must +not share one when their decisions differ. + +**No change is required in this engine.** The value recorded at issue was +already the right one; only the consumer-side target needed naming. This +section names it so a reader of `pdp_digest` does not reach for +`request_digest` and fail closed forever. + ### Declared path intent — `binding.pdp_path` `GH-DEC-2026-008` requires `pdp_digest` on the `GH-DEC-2026-003` path. This @@ -178,7 +212,7 @@ When the issuer recorded the PDP digest at issue time, it is in `binding.pdp_digest`. **Prefer that comparison** at decision time: ```text -claim.binding.pdp_digest == decision.binding.request_digest +claim.binding.pdp_digest == decision.binding.approval_binding_digest ``` and still require `claim.approval_id` to match the approval named on the @@ -204,10 +238,12 @@ A production consumer of this claim, before treating it as an input, checks: 2. `issuer` is `approval-engine`. 3. `valid_now` is true and `consumed` is false. 4. `binding.digest` equals the digest of the binding the consumer computed - from the proposed action, **or** `binding.pdp_digest` equals the - `NewDecisionBinding` digest of that request. On a privileged lane, take the - second: require `binding.pdp_digest` to be non-null and equal, and refuse - the claim otherwise. See "Action and target vocabulary" above. + from the proposed action, **or** `binding.pdp_digest` equals the PDP's + claim-excluded digest for that request — `binding.approval_binding_digest` + on a claim-bearing flex-auth decision, never `binding.request_digest`. On a + privileged lane, take the second: require `binding.pdp_digest` to be + non-null and equal, and refuse the claim otherwise. See "Action and target + vocabulary" and "The comparison target at execute time" above. 5. `freshness.not_after` is still in the future. 6. `reason_code` is `ok`. diff --git a/docs/keycape-service-registrations.md b/docs/keycape-service-registrations.md index f4e2b5b..7fc0b7b 100644 --- a/docs/keycape-service-registrations.md +++ b/docs/keycape-service-registrations.md @@ -17,10 +17,49 @@ Required claims remain those in `docs/caller-authentication.md`: `iss`, `sub`, | Field | Value | | --- | --- | | Audience | `approval-engine` | -| Issuer | the deployed KeyCape issuer (manifest uses `https://auth.netkingdom.local`) | +| Issuer | the deployed KeyCape issuer (manifest uses `https://kc.coulomb.social`) | | 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 + +**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 +(`approval_engine/api.py:66`). There is no mapping table, no normalisation, and +no prefix handling anywhere in this engine. + +The three values currently in play: + +| Value | Where it is set | Current 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** | + +`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. + +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. + +**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. + +Tracked against `APPROVAL-WP-0002-T01`; related `KEY-WP-0013-T02`, +`SECRETS-WP-0009-T03`. + ## Clients Confidential client secrets stay in OpenBao/operator custody. `secretRef` diff --git a/schemas/approval_claim.schema.json b/schemas/approval_claim.schema.json index d913205..bb005ae 100644 --- a/schemas/approval_claim.schema.json +++ b/schemas/approval_claim.schema.json @@ -147,7 +147,7 @@ "null" ], "pattern": "^sha256:[0-9a-f]{64}$", - "description": "The flex-auth NewDecisionBinding request_digest recorded at issue time, or null when the approval was not issued against a PDP decision. Always present so its absence is a stated fact rather than a missing key. When non-null, access-engine MUST compare this to the digest it already computes and MUST NOT re-derive the native digest as a substitute. A PEP on a privileged lane MUST refuse a claim whose pdp_digest is null." + "description": "The flex-auth NewDecisionBinding request_digest recorded at issue time, or null when the approval was not issued against a PDP decision. Always present so its absence is a stated fact rather than a missing key. Recorded at issue, so it is the digest of the action request as it stands before this claim is embedded in it. At execute time on a claim-bearing request the comparison target is the PDP's claim-excluded digest -- flex-auth publishes it as binding.approval_binding_digest (FLEX-DEC-2026-007) -- and NOT binding.request_digest, which covers the carried claim and therefore can never equal a digest recorded before that claim existed. When non-null, a consumer MUST compare this to the PDP's published exclusion-scoped digest and MUST NOT re-derive the native digest as a substitute, and MUST NOT guess the exclusion rule. A PEP on a privileged lane MUST refuse a claim whose pdp_digest is null." }, "pdp_path": { "type": "boolean", 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 f60cde4..dba5265 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -75,6 +75,21 @@ delivery. Requested KeyCape registrations are in `approval-engine`, not the OAuth client id). Remains `progress` until KeyCape owns and proves those audience/client/scope registrations. +2026-09-06 follow-up: a tenant collision in the deployment inputs is now +recorded in `docs/keycape-service-registrations.md`. The manifest serves +`--tenant platform` while the requested client registrations issue +`tenant: tenant:coulomb`, and `ApiApplication.identity` compares the two with +exact string equality before any object lookup — so tokens issued under the +current registrations would be denied `403` on every non-health route. +flex-auth's `tenant:platform` CheckRequest subject is a PDP input this engine +never reads and cannot participate in the comparison. Denial evidence: +`tests/test_auth.py::test_wrong_tenant_is_forbidden`. The values are left as-is +deliberately: resolving it requires an owner statement on whether `platform` and +`tenant:coulomb` name the same layer, and guessing grants cross-tenant access to +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`. + ## Harden durable storage and migrations ```task @@ -249,6 +264,19 @@ never inferred from an incidental digest, and never back-filled: legacy rows migrate to `false` and a successor inherits its predecessor's declaration. Schema, both examples, and a v2→v3 migration test cover it (102 tests). +2026-09-06 follow-up (envelope target): flex-auth published +`binding.approval_binding_digest` (`FLEX-DEC-2026-007`) — the request digest +material with `context.approval` removed — because a claim-bearing request's +`request_digest` covers the carried claim and can therefore never equal a +`pdp_digest` recorded at issue. A consumer obeying `GH-DEC-2026-008` against +`request_digest` would have failed closed permanently on every claim, which is +the defect secrets-engine and flex-auth both hit. The value this engine records +at issue is unchanged and correct; only the consumer-side comparison target +needed naming. `schemas/approval_claim.schema.json` and `docs/approval-claim.md` +now name `approval_binding_digest` as the execute-time target and state that +`request_digest` is never it. No code change was required, so T05 stays `wait` +on the deployed base URL (T03). + ## Production preflight — 2026-09-06 Glas deployment session User authorized production deployment. Live cluster inspection confirms no