flex-auth/docs/request-enrichment.md
tegwick c074237aac Make undeclared policy attribute reads a validate error.
FLEX-WP-0025-T03: flex-auth validate flags input.*.attributes keys that
no sibling registry or manifest supplies. A broken testdata package
proves tests can pass while the ceiling remains caller-only.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 09:54:09 +02:00

11 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.

Package attribute-read audit (FLEX-WP-0025-T02)

Read every input.{resource,subject}.attributes.<key> (including object.get) in the published packages. A ceiling or allowlist must be declared on every resource/subject that package can be asked about. First-class registry fields that enrichment copies into attributes (roles, groups, labels, trust_zone, owner, path, parent, plus subject metadata/claims) count as declared.

Classification: ceiling/allowlist must be facts; caller-proposed is genuinely the request; ambiguous is the T01 input.

Package Key Kind Role Declared
ops-warden resource.max_ttl_hours ceiling ceiling yes (manifest + snapshots)
ops-warden resource.allowed_subjects allowlist allowlist yes
ops-warden resource.allowed_principals allowlist allowlist yes
ops-warden resource.actor_id identity allowlist yes
ops-warden resource.actor_type identity allowlist yes
ops-warden resource.security_zone posture ceiling yes (snapshots; added to resource_manifest.yaml 2026-09-14)
ops-warden resource.security_zone_admission posture ceiling yes
ops-warden subject.actor_type identity allowlist yes (metadata.actor_type, copied into attributes)
railiance-platform resource.max_ttl_seconds ceiling ceiling yes
railiance-platform resource.allowed_actor_types allowlist allowlist yes
railiance-platform resource.allowed_purposes allowlist allowlist yes
railiance-platform resource.allowed_delivery_modes allowlist allowlist yes
railiance-platform resource.grant_id identity allowlist yes
secrets-engine no attribute reads
tenant-engine no attribute reads
qonto-assistant no attribute reads
user-engine subject.roles allowlist allowlist yes (first-class roles; snapshot now names a registration-applicant)
user-engine subject.issuer allowlist allowlist yes (claims.issuer on that subject)
markitect (example) resource.labels classification allowlist yes (first-class labels)
markitect (example) subject.groups membership allowlist yes (first-class groups)
markitect (example) subject.roles membership allowlist yes (first-class roles)
informed-decision-t03 (fixture) subject.assurance, groups, principal_type_source, tenant_source mixed see T01 fixture-only; not a published consumer package

No published-package ceiling or allowlist remains unbacked after the two declaration fixes above. ttl_hours, purpose, requested_ttl_seconds, and principals are context fields (caller-proposed) and are not in this table.

Chosen input shape (FLEX-WP-0025-T01)

Keep the merged attributes map. Add parallel namespaces. Do not break the six packages.

input.resource.attributes     # merged view (status quo)
input.resource.registry.*     # registry facts only
input.resource.asserted.*     # caller-supplied only
input.subject.attributes      # merged view (status quo)
input.subject.registry.*      # registry facts only
input.subject.asserted.*      # caller-supplied only

Why not stop merging: three of the six published packages (secrets-engine, tenant-engine, qonto-assistant) read no attributes at all; the other three already have their ceilings declared. A breaking change across six packages buys nothing on day one and invites a silent miss on a package that still reads attributes by habit. The merged view stays so existing rules keep their meaning; a ceiling rule opts in to registry.*.

Vocabulary collision on subject.type: do not overwrite it with the CARING registry type (Human/Agent/Automation/Service). Packages compare against the protected system's actor vocabulary (service, adm, agt, atm). Put the CARING type at input.subject.registry.type (and the caller value at input.subject.asserted.type). input.subject.type remains the request's actor vocabulary, filled from the caller, defaulting only when empty — the exception already in enrichSubjectRef.

Ambiguous set that T03's validate check must surface, because intent cannot be inferred from the key name:

  • subject.roles / subject.groups — first-class registry facts when the subject is registered; caller input when it is not
  • subject.issuer — allowlist for public registration; a fact only if the directory wrote it
  • subject.type — two vocabularies, one field (handled above, not by precedence)

Package changes wait until T03 makes "read a declared registry key" a flex-auth validate finding rather than a review note.

Validate flags undeclared attribute reads (FLEX-WP-0025-T03)

flex-auth validate --kind policy now emits POLICY-ATTRIBUTE-UNDECLARED (error) for every input.{resource,subject}.attributes.<key> the package reads that no sibling registry_snapshot.json, production_registry_snapshot.json, resource_manifest.yaml, or subject_manifest.yaml supplies. First-class fields enrichment copies (labels, roles, groups, claims.*, metadata.*, …) count as supplied.

It cannot tell a ceiling from a lookup. The finding is the set a reviewer must look at. Demonstrated against internal/policy/testdata/undeclared-ceiling/: tests and fixtures pass, the package does not, because max_ttl_hours is caller-only.

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.