flex-auth/docs/canonical-request-digest.md
tegwick 0bc624ba62
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Build and Publish Container Image / build-and-push (push) Successful in 57s
fix(decision): registry facts win over caller-supplied attributes
secrets-engine's first live request rejected our allow: binding.
request_digest is computed over material they never sent, because we
enrich subject and resource from the registry before hashing. Answering
that meant reading the enrichment path, which had a worse defect in it.

Enrichment was additive-if-absent — addAttribute wrote a registry value
only where the request had no value for that key. So where a caller
supplied a key, the caller's value won and the registry's never applied.
Every registry ceiling and allowlist was advisory. Verified against the
shipped ops-warden package, each one added key on an otherwise-denied
request:

  max_ttl_hours: 99      registry says 8    -> allowed a 12h certificate
  allowed_principals     registry allowlist -> disallowed_principal bypassed
  allowed_subjects       registry allowlist -> unknown_subject bypassed

The third is the one to read twice: a subject the registry does not know
authorized itself by naming itself in the allowlist it was being checked
against.

Not remotely reachable today — the PEP builds the CheckRequest,
ops-warden sends no resource.attributes, and enforce admits one identity.
It is a defence-in-depth failure: any path that lets attacker-influenced
data into a CheckRequest field became a full policy bypass rather than a
bounded input problem. Callers sending resource.attributes is not
hypothetical; secrets-engine does it on every request.

Registry facts now win, and diagnostics.registry_overrode names every
displaced key, because a registry that silently discards a contradicting
claim hides that a caller asserted authority it did not have.

subject.type is carved out, and the reason is a finding of its own.
Making the registry win there denied every secrets-engine allow: the
registry's type is CARING vocabulary (Human, Agent, Automation, Service)
and the request's is the protected system's actor vocabulary (service,
adm, agt, atm). Two fields sharing a name; substituting one for the other
is translation rather than identity, which GH-DEC-2026-008 ruled against.
Note what surfaced it — the registry's type had been dead data since the
field existed, because the caller's value always won.

Also publishes binding.submitted_request_digest, over the request exactly
as sent. request_digest was published as the consumer replay test and
cannot be one. Nothing is lost hashing the pre-enrichment form:
enrichment is a function of the request and the snapshot, and
registry_snapshot_digest already pins the snapshot.

Existing pins do not move. All three replay fixtures' request_digest and
approval_binding_digest values are byte-identical — those requests
contradict no registry fact. A field to add, not a value to correct.

Regression tests verified failing against the old behaviour before being
kept. FLEX-DEC-2026-012; FLEX-WP-0025 carries the residual, that a policy
still cannot tell a fact from an assertion.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715613@bnt-lap001
Assistant-Session: fabd95c1-4c9e-4080-8849-8707ae025f80
2026-09-07 13:43:33 +02:00

161 lines
6.4 KiB
Markdown

# Canonical request digest
Status: published
Date: 2026-09-02
Standard: security-layer-model_v0.7 §6.4.2
The digest is the mechanical test for replaying a flex-auth verdict. A consumer
may reuse an allow **iff** a new request produces the same digest **and** the
decision's lifetime still holds. Negative caching of a deny uses the same test.
Constructor: `api.NewDecisionBinding` / `api.RequestDigest`.
Field: `DecisionEnvelope.binding.request_digest`.
Format: `sha256:` plus 64 lowercase hex characters.
## What is hashed
Canonical JSON (Go `encoding/json`, which sorts map keys) of:
| Field | Source |
| --- | --- |
| `tenant` | `CheckRequest.tenant` |
| `subject` | normalized `CheckRequest.subject` |
| `action` | `CheckRequest.action` |
| `resource` | normalized `CheckRequest.resource` |
| `context` | `CheckRequest.context` |
Empty optional maps and omitempty strings are omitted. Attribute maps inside
subject and resource are part of the material once the evaluator has enriched
them from the registry.
**Not hashed:** `id`, `policy_version`, and `caring_context`. The request id is
correlation only. Policy version is recorded in provenance. CARING context is
hashed separately as `provenance.input_claim_digests.caring_context`.
## Normalization
1. Tenant on the request is copied onto subject and resource when those refs
omit it.
2. A registry hit copies type, tenant, and selected attributes onto the refs
the digest sees.
3. Context is a shallow copy. Key order is not significant.
A consumer that re-hashes the **original** unenriched request will not match a
decision that turned on registry attributes. Compare structured `binding`
fields to the proposed action, and treat `request_digest` as the evaluator's
statement of what it hashed. To recompute independently, hash the same
normalized tuple the binding carries (tenant, subject, action, resource,
context).
## Stability
The digest is stable across process restarts for the same normalized tuple.
It is **not** stable across:
- a change to any hashed field, including registry-enriched attributes
- a change to this contract (would require a new `contract_version`)
It does not include the policy package, snapshot, or clock. Those live in
provenance and `lifetime`. Two allows over the same tuple but different
snapshots share a digest and differ in `registry_snapshot_digest`.
## Worked example — permitted retry
Request (after enrichment):
```json
{
"tenant": "tenant:alpha",
"subject": {"id": "user:alice", "type": "Human", "tenant": "tenant:alpha"},
"action": "read",
"resource": {
"id": "document:internal-note",
"type": "document",
"system": "markitect-tool",
"tenant": "tenant:alpha"
},
"context": {"purpose": "project-delivery"}
}
```
The evaluator stores `binding.request_digest` for that tuple and
`lifetime.expires_at` in the future. The same subject, action, resource, and
context presented again before expiry is a permitted retry of that allow.
## Worked example — refused replay
Any of the following refuses replay:
- `action` changes from `read` to `destroy` (digest mismatch)
- `resource.attributes.stage` changes from `build` to `production` (digest mismatch)
- `lifetime.expires_at` is in the past (lifetime does not hold)
- the stored effect is `deny` and the consumer is attempting to treat a cached
refusal as still binding after a policy change — negative cache is valid only
for the same digest **and** while the recorded lifetime would have held; a
new `Check` is required once either fails
Local fixtures, State Hub `/decisions/{uuid}` records, and free-form
`decided_by` values are not this test.
## The approval-binding digest
`binding.approval_binding_digest` is this same digest computed with an approval
claim removed from `context`. It appears only when the request carried one on
`context.approval`.
### Why a second digest exists
An approval's `pdp_digest` is recorded **at issue time**. The request that later
carries that claim inside its hashed context has a different `request_digest` by
construction, because the claim is part of the context that is hashed. So:
> A `pdp_digest` recorded at issue time can never equal the `request_digest` of
> the request that carries the claim.
`secrets-engine` found this while verifying a replay fixture, and it is not a
fixture defect — it is circular by construction. Left unresolved it would make
`GH-DEC-2026-008` unimplementable for exactly the case it was written for: a
dual-control action whose claim travels in context.
`approval_binding_digest` is stable across attaching the claim. An approval
issued against a claim-free Check records that Check's `request_digest`, and the
later claim-bearing request reproduces the same value here.
### The comparison
```text
claim.binding.pdp_digest == decision.binding.approval_binding_digest
```
Never against `request_digest`. Comparing to `request_digest` cannot succeed on a
claim-bearing request.
### It is not a replay identity
`request_digest` remains the §6.4.2 replay test and still covers the approval
claim. `approval_binding_digest` deliberately does not: two requests differing
only in which approval was presented share it, **and their decisions do not**
one allows, the other denies `dual_control_required`.
Collapsing the two would let an allow obtained with a valid claim be replayed
against a request carrying none. That is why this is an additional field rather
than a redefinition of the existing one, and why `pkg/api/approval_binding_test.go`
asserts the two functions **disagree** on a claim-bearing request. A test that a
distinction is real is how a distinction that looks like duplication is defended.
## Correction, 2026-09-07: which digest a consumer computes
This document describes the canonical form. It does **not** follow that
`binding.request_digest` is the value a consumer reproduces.
`request_digest` is computed over the **enriched** request — after the evaluator
overlays registry facts onto subject and resource — so a consumer recomputing it
over what it sent gets a different value on every request whose subject or
resource the registry knows. `secrets-engine` found this on its first live
request.
**The consumer's §6.4.2 replay test is `binding.submitted_request_digest`**: this
same canonical form applied to the request exactly as received. Everything below
about canonicalisation is unchanged and applies to both; what changed is which
*material* a consumer hashes. See
[`request-enrichment.md`](request-enrichment.md) and `FLEX-DEC-2026-012`.