Name the execute-time digest target; record the tenant collision

flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after
secrets-engine found that a claim-bearing request's request_digest covers the
carried claim, so it can never equal a pdp_digest recorded before that claim
existed. A consumer obeying GH-DEC-2026-008 against request_digest would have
failed closed permanently on every claim rather than on a bad one.

The value recorded at issue was already correct, so no code changes. What was
wrong was this repo's description of the comparison target: a reader would
reach for request_digest and fail closed forever. The schema and
docs/approval-claim.md now name approval_binding_digest as the execute-time
target and state that request_digest is never it, while leaving the issue-time
description as it stood.

Separately, docs/keycape-service-registrations.md now records a live collision
in the deployment inputs: the manifest serves --tenant platform while the
requested registrations issue tenant:coulomb, and ApiApplication.identity
compares them with exact string equality before any object lookup, so those
tokens would be denied 403 on every non-health route. flex-auth's
tenant:platform is a PDP subject this engine never reads and cannot bridge the
two. The values are left as-is on purpose — resolving it needs an owner
statement on whether the two name the same layer, and guessing grants
cross-tenant access to the approval store. The doc's stale issuer is corrected
to the live https://kc.coulomb.social from 06544b0.

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 20:35:59 +02:00
parent 06544b0fb4
commit 5c87ba8610
4 changed files with 115 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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