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:
parent
06544b0fb4
commit
5c87ba8610
4 changed files with 115 additions and 12 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue