flex-auth/docs/canonical-request-digest.md
tegwick 56940727bf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 57s
Finish FLEX-WP-0019 layer-model v0.7 conformance
Close the remaining PDP obligations: mechanical layer declaration check,
registry-snapshot digest in provenance, explicit allow TTL, per-input-class
freshness deadlines, and the published decision-record contract. Document
the canonical request digest as the §6.4.2 replay test.

Assistant: grok
Assistant-Session: 01a06256-fb71-7102-b3a9-27e6734257d0
2026-09-03 23:48:45 +02:00

98 lines
3.6 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.