fix(decision): registry facts win over caller-supplied attributes
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

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
This commit is contained in:
tegwick 2026-09-07 13:43:33 +02:00
parent c861d75703
commit 0bc624ba62
14 changed files with 822 additions and 34 deletions

View file

@ -1643,3 +1643,157 @@ PEP obligations complete, and the fix is substantially ours: `FLEX-WP-0024`
signs the envelope, and `FLEX-DEC-2026-009` puts the authenticated caller in
`provenance` so a record eventually attests both ends of the channel rather
than neither.
---
## FLEX-DEC-2026-012 — A caller could raise its own ceiling: registry facts now win, and the replay digest a consumer was told to compute was never computable
**Date:** 2026-09-07
**Status:** accepted
**Workplan:** `FLEX-WP-0025`
**Raised by:** `secrets-engine` (the digest half, on its first live request); flex-auth (the escalation half, found while answering it)
## Context
`secrets-engine` adopted the operator access path, sent a real `CheckRequest`
through the enforcing pin, and its validator **rejected the allow**. Not a near
miss — unsatisfiable. `binding.request_digest` is computed over material the
consumer never sent: `subject.attributes`, `subject.tenant`, `resource.tenant`,
all contributed by flex-auth's registry.
They declined to fix it locally. Their reasoning is the reason this record exists:
they could have compared only the fields they sent and treated additions as
benign, but *"additions I decided were benign"* is a rule authored over flex-auth's
registry semantics, and it fails open by accepting a binding that differs from
the proposal in some way they did not think to check.
Answering it meant reading the enrichment path, and the enrichment path had a
worse defect in it.
## The escalation
**Enrichment was additive-if-absent.** `addAttribute` wrote a registry value only
where the request had no value for that key:
```go
if _, exists := target[key]; !exists { target[key] = value }
```
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 is one added key on a request
that otherwise denies:
| Sent by the caller | Registry says | Result before |
| --- | --- | --- |
| `resource.attributes.max_ttl_hours: 99` | `8` | `allow` — a 12-hour certificate the registry caps at 8 |
| `resource.attributes.allowed_principals: [root, …]` | `[platform, root]` | `allow``disallowed_principal` bypassed |
| `resource.attributes.allowed_subjects: [<caller>]` | `[platform-steward, …]` | `allow``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.**
**Exploitability, stated honestly.** This is not remotely reachable today. The
PEP builds the `CheckRequest`, `ops-warden` sends no `resource.attributes`, and
`callerAuth: enforce` admits only its bound identity. It is a defence-in-depth
failure: the registry's ceilings were not binding, so any path that lets
attacker-influenced data into a `CheckRequest` field becomes a full policy
bypass rather than a bounded input problem. And callers sending
`resource.attributes` is not hypothetical — `secrets-engine` does it on every
request.
**Decision: 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.
`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.
## The exception that had to be carved out, and it is a finding of its own
Making the registry win on `subject.type` **denied every `secrets-engine` allow**,
and the reason is worth recording rather than patching around.
The registry's `type` is CARING vocabulary — `Human`, `Agent`, `Automation`,
`Service`. The request's `subject.type` is the protected system's actor
vocabulary — `service`, `adm`, `agt`, `atm`. Policies compare against the
latter. **`"Service"` is not `"service"`.** These are two different fields
sharing a name, and overwriting one with the other is translation rather than
identity, which is exactly what `GH-DEC-2026-008` ruled against.
So `subject.type` keeps fill-if-empty. The residual is real and stated: a caller
can still assert its own `subject.type`, and packages branch on it. It is not
fixable by substituting a value from another vocabulary — that is strictly worse,
as the denials proved. `FLEX-WP-0025` separates the two fields.
Note what surfaced this: the registry's `type` had been **dead data** for as long
as the field has existed. Nothing read it, because the caller's value always won.
A defect is invisible while the value it produces is never used.
## The digest
`binding.request_digest` is over the **enriched** request, and was published as
"the canonical request digest as the published replay test for consumers"
(`SCOPE.md`, §6.4.2). It cannot be that. The registry is flex-auth's, so a
consumer recomputing over what it sent gets a different value on every request
whose subject or resource the registry knows.
**`binding.submitted_request_digest` is published**: `RequestDigest` over the
request exactly as received, before any enrichment. This is the digest a PEP
compares for §6.4 obligation 2.
**Nothing is lost by hashing the pre-enrichment form.** 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 exactly this.
`request_digest` keeps its value and its meaning: what the decision was actually
a function of, and flex-auth's own audit-replay identity. It is now documented as
**not** a consumer check.
`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 — a flex-auth value against a flex-auth value. **But a consumer
must not compute either side itself**, and `secrets-engine`'s described pre-flight
step does exactly that, which reintroduces this defect on the dual-control path.
Flagged to them.
## Why three rounds of digest work missed it
`secrets-engine`'s replay tests rebuilt the request from `envelope["binding"]`
the **enriched** form. So every digest assertion passed by hashing our output and
comparing it to our output. It survived the excluded-fields fix, the
`approval_binding_digest` fix, and the tenant fix, because all three were tested
the same way.
That is the fourth instance this week of one mechanism: **a self-consistent
artifact agreeing with itself.** `FLEX-DEC-2026-008` was a fixture suite that
never varied a field; `approval-engine`'s was a fixture built by the function
that omitted the field; ours was a registry value nothing read. Each time the
thing that caught it was a real artifact crossing a repository boundary, not a
review of the assertions.
## Consequences
- **Existing pins do not move.** The three replay fixtures' `request_digest` and
`approval_binding_digest` values are byte-identical after this change — those
requests contradict no registry fact, so nothing they hash moved.
`submitted_request_digest` is a field to add, not a value to correct. Stated
explicitly because the last two corrections both prompted re-pinning.
- **`ops-warden` should be told directly.** All three demonstrated escalations
are against its package and its pin is enforcing in production.
- Regression tests assert the registry value reaches policy on a contradicting
request, against attribute keys real packages branch on — a test over an unused
key would pass while the escalation stayed open. Verified failing against the
old behaviour before being kept.
- The residual is published in `docs/request-enrichment.md`: a caller-supplied
attribute for a key the registry does not define still reaches policy. **A
policy enforcing a ceiling or an allowlist MUST read a key its manifest
declares**, and that is now a review obligation on every package.
- Bears on `FLEX-DEC-2026-011` F1. A PEP cannot verify who answered; it now at
least can verify that the answer is about the request it sent. Those are
different properties and neither substitutes for the other.