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`