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
This commit is contained in:
parent
c861d75703
commit
0bc624ba62
14 changed files with 822 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue