flex-auth/docs/request-enrichment.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

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.