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
5.5 KiB
Request enrichment
Published under FLEX-DEC-2026-012. What the evaluator adds to a request
before policy sees it, which value wins where the two disagree, and which digest
a consumer can reproduce.
Nothing here was previously written down. Both defects the decision record covers were consequences of that: a consumer could not compute the digest it was told to compute, and a caller could contradict a registry fact and be believed.
The evaluator overlays registry facts
POST /v1/check receives a CheckRequest. Before policy evaluates it, the
evaluator resolves the subject and the resource in the registry snapshot and
overlays what it finds:
| Target | Keys the registry contributes |
|---|---|
subject |
type, tenant, and attributes display_name, organization_relation, roles, groups, plus every subject claim and metadata key |
resource |
type, and attributes path, parent, labels, trust_zone, owner, plus every manifest attribute |
Separately, and before either lookup, a non-empty top-level tenant propagates
into subject.tenant and resource.tenant when those are empty.
An unregistered subject or resource is enriched with nothing. diagnostics
reports registry_subject and registry_resource for exactly this reason.
The registry wins
A registry fact is an authority statement. The same key on the request is the caller's proposal about itself. Where they disagree, the registry's value reaches policy and the caller's is discarded.
This was the other way round until 2026-09-07, and it made every registry ceiling
and allowlist advisory. Three verified escalations against the shipped
ops-warden package, each a single added key on an otherwise-denied request:
| Sent by the caller | Registry says | Was | Now |
|---|---|---|---|
resource.attributes.max_ttl_hours: 99 |
8 |
allow a 12h certificate |
deny ttl_out_of_bounds |
resource.attributes.allowed_principals: [root, …] |
[platform, root] |
allow |
deny disallowed_principal |
resource.attributes.allowed_subjects: [<caller>] |
[platform-steward, …] |
allow |
deny unknown_subject |
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.
internal/decision/enrichment_precedence_test.go asserts the registry value
reaches policy on a request that contradicts it, against attribute keys real
packages branch on — a test over an unused key would pass while the escalation
stayed open.
Overrides are reported, not silent
diagnostics.registry_overrode lists the keys whose caller-supplied value was
displaced, as subject.<key> or resource.<key>. Empty on an honest request.
A registry that quietly discards a contradicting claim hides that a caller asserted authority it did not have. The decision is correct either way; the attempt is worth seeing.
The residual: keys the registry does not define
A caller-supplied attribute for a key the registry does not define still reaches policy. This is stated rather than fixed.
So a package branching on an attribute its manifest omits is reading caller input, and a manifest that stops declaring a key silently hands that key to the caller. Registering the resource is not sufficient — the specific key must be present in the manifest.
The structural fix is that a policy should be able to tell a registry fact from a
caller assertion at all, which one merged attributes map makes impossible.
FLEX-WP-0025 carries it. Until then, a policy that enforces a ceiling or an
allowlist MUST read a key its manifest declares, and that is a review
obligation on every package.
Which digest a consumer can reproduce
| Field | Over | Consumer-computable |
|---|---|---|
binding.request_digest |
the enriched request | No |
binding.submitted_request_digest |
the request exactly as sent | Yes — this is the §6.4.2 replay test |
binding.approval_binding_digest |
the enriched request minus context.approval |
No, and it does not need to be |
request_digest was published as the replay test for consumers. It is not one:
the evaluator hashes the enriched request, the registry is flex-auth's, and a
consumer recomputing 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.
Use submitted_request_digest for §6.4 obligation 2. Nothing is lost.
Enrichment is a function of the request and the registry snapshot, and
provenance.registry_snapshot_digest already pins the snapshot — so the
submitted digest together with that digest identifies the evaluated request
completely. Registry staleness between issue and replay is bounded by the
decision's own lifetime and by §9.7.2's per-input-class visibility deadline,
which is the mechanism that already covers it.
request_digest keeps its value and meaning: it pins what the decision was
actually a function of, and it is what makes a decision replayable by
flex-auth for audit. It is not a consumer check and is now documented as not
being one.
approval_binding_digest needs no submitted form. It is compared to
claim.binding.pdp_digest, which approval-engine recorded from flex-auth's own
output at issue time — a flex-auth value against a flex-auth value, consistent on
both sides. A consumer must not compute either side of that comparison
itself; doing so reintroduces exactly this defect on the dual-control path.