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
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 notsubject.issuer— allowlist for public registration; a fact only if the directory wrote itsubject.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.