From 0bc624ba629487de5b0889c2e14088bb29f12cb5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 7 Sep 2026 13:43:33 +0200 Subject: [PATCH] fix(decision): registry facts win over caller-supplied attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- SCOPE.md | 6 +- decisions/decisions.md | 154 ++++++++++++++ docs/canonical-request-digest.md | 17 ++ docs/decision-record-contract.md | 38 ++++ docs/request-enrichment.md | 108 ++++++++++ examples/secrets-engine/replay/README.md | 24 +++ .../replay/decision_destroy_dual_control.json | 10 +- .../replay/decision_rotate.json | 10 +- .../replay/decision_wrong_tenant_deny.json | 6 +- internal/decision/engine.go | 119 +++++++++-- .../decision/enrichment_precedence_test.go | 198 ++++++++++++++++++ pkg/api/canonical.go | 36 ++++ schemas/decision_envelope.schema.json | 8 +- .../FLEX-WP-0025-fact-versus-assertion.md | 122 +++++++++++ 14 files changed, 822 insertions(+), 34 deletions(-) create mode 100644 docs/request-enrichment.md create mode 100644 internal/decision/enrichment_precedence_test.go create mode 100644 workplans/FLEX-WP-0025-fact-versus-assertion.md diff --git a/SCOPE.md b/SCOPE.md index 3b7491e..3bcb08b 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -38,7 +38,9 @@ can be coordinated behind a stable flex-auth API. - The **decision-record schema**, published as flex-auth's own contract (`security-layer-model_v0.7` §17). - The **canonical request digest** as the published replay test for consumers - (§6.4.2). + (§6.4.2) — `binding.submitted_request_digest`, over the request as sent. + `request_digest` is over the *enriched* request and is flex-auth's own + audit-replay identity, not a consumer check (`FLEX-DEC-2026-012`). - **Decision lifetimes** on every allow, and **revocation visibility deadlines stated per input class** (§9.7). - **Registry-snapshot provenance**, so a decision that turned on registry @@ -295,7 +297,7 @@ description: Workplan for Topaz evaluation, relationship PDP adapters, rule PDP type: contract title: Decision-record contract status: current -description: Published decision-record schema, canonical request digest as the replay test for consumers, and decision provenance — flex-auth's own contract under security-layer-model v0.7 section 17. +description: Published decision-record schema, the submitted-request digest as the replay test for consumers, request-enrichment precedence, and decision provenance — flex-auth's own contract under security-layer-model v0.7 section 17. ``` ```capability diff --git a/decisions/decisions.md b/decisions/decisions.md index 8bf091d..e2c6b91 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -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: []` | `[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. diff --git a/docs/canonical-request-digest.md b/docs/canonical-request-digest.md index 0c9213b..d7df5c2 100644 --- a/docs/canonical-request-digest.md +++ b/docs/canonical-request-digest.md @@ -142,3 +142,20 @@ against a request carrying none. That is why this is an additional field rather than a redefinition of the existing one, and why `pkg/api/approval_binding_test.go` asserts the two functions **disagree** on a claim-bearing request. A test that a distinction is real is how a distinction that looks like duplication is defended. + +## Correction, 2026-09-07: which digest a consumer computes + +This document describes the canonical form. It does **not** follow that +`binding.request_digest` is the value a consumer reproduces. + +`request_digest` is computed over the **enriched** request — after the evaluator +overlays registry facts onto subject and resource — so a consumer recomputing it +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. + +**The consumer's §6.4.2 replay test is `binding.submitted_request_digest`**: this +same canonical form applied to the request exactly as received. Everything below +about canonicalisation is unchanged and applies to both; what changed is which +*material* a consumer hashes. See +[`request-enrichment.md`](request-enrichment.md) and `FLEX-DEC-2026-012`. diff --git a/docs/decision-record-contract.md b/docs/decision-record-contract.md index e731a5a..d7b7933 100644 --- a/docs/decision-record-contract.md +++ b/docs/decision-record-contract.md @@ -29,6 +29,8 @@ same shape. | --- | --- | | `contract_version` | `flex-auth.decision-record.v1` | | `binding` | Structured subject, action, resource, context, and `request_digest` | +| `binding.submitted_request_digest` | Digest over the request **exactly as sent**, before registry enrichment. **This is the consumer's replay test** for §6.4 obligation 2 | +| `binding.submitted_request_digest` | Digest over the request **exactly as sent**, before registry enrichment. **This is the consumer's replay test** for §6.4 obligation 2 | | `binding.approval_binding_digest` | Present only when the request carried `context.approval`. The digest an approval's `pdp_digest` must equal — see [`canonical-request-digest.md`](canonical-request-digest.md). Not a replay identity | | `lifetime` | **Required on every allow.** A TTL with `not_before` and `expires_at` | | `provenance.policy_package` / `policy_version` | Named package pin | @@ -81,3 +83,39 @@ A detached signature over the canonical envelope is the intended fix (`FLEX-WP-0024`). Until it lands, responder authenticity comes from the channel alone — and of the available channels only `kubectl port-forward` supplies it, by targeting one named pod over the API server's TLS with no DNS name resolved. + +## `request_digest` is not the consumer's replay test + +Correction, 2026-09-07. `request_digest` is computed over the **enriched** +request — after the evaluator overlays registry facts onto subject and resource — +so a consumer recomputing it over what it sent gets a different value on every +request whose subject or resource the registry knows. It was published as the +§6.4.2 replay test and cannot be one, because the registry is flex-auth's. + +**Compare `binding.submitted_request_digest` instead.** It is `RequestDigest` +over the request exactly as received, and together with +`provenance.registry_snapshot_digest` it identifies the evaluated request +completely — enrichment is a function of those two. `request_digest` keeps its +value and its meaning as flex-auth's own audit-replay identity. + +What the evaluator may add, which value wins where the two disagree, and the +stated residual are in [`request-enrichment.md`](request-enrichment.md) +(`FLEX-DEC-2026-012`). + +## `request_digest` is not the consumer's replay test + +Correction, 2026-09-07. `request_digest` is computed over the **enriched** +request — after the evaluator overlays registry facts onto subject and resource — +so a consumer recomputing it over what it sent gets a different value on every +request whose subject or resource the registry knows. It was published as the +§6.4.2 replay test and cannot be one, because the registry is flex-auth's. + +**Compare `binding.submitted_request_digest` instead.** It is `RequestDigest` +over the request exactly as received, and together with +`provenance.registry_snapshot_digest` it identifies the evaluated request +completely — enrichment is a function of those two. `request_digest` keeps its +value and its meaning as flex-auth's own audit-replay identity. + +What the evaluator may add, which value wins where the two disagree, and the +stated residual are in [`request-enrichment.md`](request-enrichment.md) +(`FLEX-DEC-2026-012`). diff --git a/docs/request-enrichment.md b/docs/request-enrichment.md new file mode 100644 index 0000000..d5059cd --- /dev/null +++ b/docs/request-enrichment.md @@ -0,0 +1,108 @@ +# 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: []` | `[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.` or `resource.`. 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. diff --git a/examples/secrets-engine/replay/README.md b/examples/secrets-engine/replay/README.md index 00dcab1..3955eb9 100644 --- a/examples/secrets-engine/replay/README.md +++ b/examples/secrets-engine/replay/README.md @@ -31,6 +31,30 @@ go run ./cmd/flex-auth check \ Verified identical across two runs. +**Regenerated 2026-09-07 — `submitted_request_digest` added; every existing +digest unchanged.** From `FLEX-DEC-2026-012`, which `secrets-engine` found by +running against the live pin. + +`request_digest` is computed over the **enriched** request — after the evaluator +overlays registry facts onto subject and resource — so it is not +consumer-computable and never was. Pin **`submitted_request_digest`** instead: +it is the digest over the request exactly as sent, and it is the §6.4.2 replay +test. `request_digest` keeps both its meaning as flex-auth's own audit-replay +identity and its exact value here — `de67324f…`, `c749ee2d…`, `c9c6e6f8…` are +unchanged, as is `approval_binding_digest` at `fa07bec…`. The enrichment +precedence changed in the same decision but these three requests contradict no +registry fact, so nothing they hash moved. **Nothing you have pinned needs +re-pinning; there is a new field to add, not an old one to correct.** + +| Field | `rotate` | `destroy` | `wrong-tenant` | +| --- | --- | --- | --- | +| `binding.submitted_request_digest` | `sha256:41c8fc08…` | `sha256:c605a9ec…` | `sha256:9aab6de9…` | + +A consumer that pinned `request_digest` from these files was pinning a value it +could not have computed itself — it matched because it was copied from our +output, not derived. That is the defect `submitted_request_digest` closes: the +same assertion now has a value you can recompute from the request you sent. + **Regenerated at `v2`, 2026-09-06.** The package gained the tenant rule it had been missing, so `provenance.policy_version` is now `v2` and `policy_package_digest` moved from `sha256:fe0070b7…bd8c` to diff --git a/examples/secrets-engine/replay/decision_destroy_dual_control.json b/examples/secrets-engine/replay/decision_destroy_dual_control.json index 9f20bd6..cda690e 100644 --- a/examples/secrets-engine/replay/decision_destroy_dual_control.json +++ b/examples/secrets-engine/replay/decision_destroy_dual_control.json @@ -100,19 +100,21 @@ } }, "request_digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a", - "approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56" + "approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56", + "submitted_request_digest": "sha256:c605a9ecd5711d0a1d59e7b29f3a16b53fd09d104dd077766f098e7bc5895435" }, "lifetime": { "kind": "ttl", "ttl": "15m", - "not_before": "2026-09-06T18:35:19Z", - "expires_at": "2026-09-06T18:50:19Z" + "not_before": "2026-09-07T07:10:23Z", + "expires_at": "2026-09-07T07:25:23Z" }, "diagnostics": { "action": "destroy", "matched_relationship": "", "policy_package": "secrets-engine.catalog-lane.lifecycle", "policy_status": "ready", + "registry_overrode": [], "registry_resource": false, "registry_subject": true }, @@ -126,7 +128,7 @@ "input_claim_digests": { "context": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800" }, - "decision_time": "2026-09-06T18:35:19Z" + "decision_time": "2026-09-07T07:10:23Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/examples/secrets-engine/replay/decision_rotate.json b/examples/secrets-engine/replay/decision_rotate.json index 6ac8c8e..483eead 100644 --- a/examples/secrets-engine/replay/decision_rotate.json +++ b/examples/secrets-engine/replay/decision_rotate.json @@ -69,19 +69,21 @@ "stage": "prod" } }, - "request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345" + "request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345", + "submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a" }, "lifetime": { "kind": "ttl", "ttl": "15m", - "not_before": "2026-09-06T18:35:18Z", - "expires_at": "2026-09-06T18:50:18Z" + "not_before": "2026-09-07T07:10:23Z", + "expires_at": "2026-09-07T07:25:23Z" }, "diagnostics": { "action": "rotate", "matched_relationship": "", "policy_package": "secrets-engine.catalog-lane.lifecycle", "policy_status": "ready", + "registry_overrode": [], "registry_resource": false, "registry_subject": true }, @@ -92,7 +94,7 @@ "policy_version": "v2", "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", - "decision_time": "2026-09-06T18:35:18Z" + "decision_time": "2026-09-07T07:10:23Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/examples/secrets-engine/replay/decision_wrong_tenant_deny.json b/examples/secrets-engine/replay/decision_wrong_tenant_deny.json index 0f35353..05228c9 100644 --- a/examples/secrets-engine/replay/decision_wrong_tenant_deny.json +++ b/examples/secrets-engine/replay/decision_wrong_tenant_deny.json @@ -69,13 +69,15 @@ "stage": "prod" } }, - "request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20" + "request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20", + "submitted_request_digest": "sha256:9aab6de9069e1e811a52835ca00bc5e9cb38166444df068eb60a26923b1c9175" }, "diagnostics": { "action": "rotate", "matched_relationship": "", "policy_package": "secrets-engine.catalog-lane.lifecycle", "policy_status": "ready", + "registry_overrode": [], "registry_resource": false, "registry_subject": true }, @@ -86,7 +88,7 @@ "policy_version": "v2", "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", - "decision_time": "2026-09-06T18:35:20Z" + "decision_time": "2026-09-07T07:10:24Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 33d8dc6..2a54062 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -1,6 +1,7 @@ package decision import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -8,6 +9,7 @@ import ( "fmt" "reflect" "slices" + "sort" "strings" "sync" "time" @@ -110,7 +112,7 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis return api.DecisionEnvelope{}, err } - decision := e.envelope(normalized, expectation, facts) + decision := e.envelope(normalized, request, expectation, facts) if err := e.recordDecision(decision); err != nil { return api.DecisionEnvelope{}, err } @@ -202,12 +204,19 @@ type registryFacts struct { resource api.Resource matchedRelationship string descriptor *api.CaringAccessDescriptor + // overriddenByRegistry names request attribute keys whose caller-supplied + // value was displaced by a registry fact. Empty on an honest request; a + // non-empty list means the caller asserted something the registry contradicts, + // which is worth seeing even though the registry won (FLEX-DEC-2026-012). + overriddenByRegistry []string } func (e *Engine) normalizeRequest(request api.CheckRequest) (api.CheckRequest, registryFacts) { normalized := request facts := registryFacts{} + overridden := []string{} + if normalized.Tenant != "" { if normalized.Subject.Tenant == "" { normalized.Subject.Tenant = normalized.Tenant @@ -220,21 +229,24 @@ func (e *Engine) normalizeRequest(request api.CheckRequest) (api.CheckRequest, r if subject, ok := e.store.Subject(request.Subject.ID); ok { facts.subjectFound = true facts.subject = subject - normalized.Subject = enrichSubjectRef(request.Subject, subject) + normalized.Subject = enrichSubjectRef(request.Subject, subject, &overridden) } if resource, ok := e.store.Resource(request.Resource.System, request.Resource.ID); ok { facts.resourceFound = true facts.resource = resource - normalized.Resource = enrichResourceRef(request.Resource, resource) + normalized.Resource = enrichResourceRef(request.Resource, resource, &overridden) } if normalized.CaringContext != nil { descriptor := *normalized.CaringContext facts.descriptor = &descriptor + facts.overriddenByRegistry = overridden return normalized, facts } + facts.overriddenByRegistry = overridden + if descriptor, relationshipID := e.matchCaringDescriptor(normalized, facts); descriptor != nil { facts.descriptor = descriptor facts.matchedRelationship = relationshipID @@ -273,40 +285,67 @@ func (e *Engine) matchCaringDescriptor(request api.CheckRequest, facts registryF return nil, "" } -func enrichSubjectRef(ref api.SubjectRef, subject api.Subject) api.SubjectRef { +// enrichSubjectRef overlays registry facts onto the subject the caller sent. +// +// Registry facts WIN. A value in the registry is an authority statement; the +// same key on the request is the caller's proposal about itself. Letting the +// proposal survive made a registry ceiling advisory — see FLEX-DEC-2026-012, +// where a caller supplying resource.attributes.allowed_subjects authorized a +// subject the registry did not know. +// +// Keys the registry does not define still pass through from the request. That +// residual is stated in docs/request-enrichment.md rather than fixed here: a +// policy reading a key its manifest omits is consuming caller input, and the +// structural fix is separating the two namespaces (FLEX-WP-0025). +func enrichSubjectRef(ref api.SubjectRef, subject api.Subject, overridden *[]string) api.SubjectRef { out := ref + // subject.Type is deliberately NOT overwritten, unlike every other registry + // fact here. The registry's Type is CARING vocabulary (Human, Agent, + // Automation, Service); the request's is the protected system's actor + // vocabulary (service, adm, agt, atm). They are two different fields sharing + // a name, and substituting one for the other is translation rather than + // identity — the error GH-DEC-2026-008 ruled against. Making the registry win + // here denied every secrets-engine allow, because "Service" is not "service". + // + // The residual is that a caller can still assert its own subject.type and + // packages branch on it. That is not fixable by overwriting from a different + // vocabulary; it needs the two fields separated. FLEX-WP-0025. if out.Type == "" { out.Type = subject.Type } - if out.Tenant == "" { + if subject.Tenant != "" { out.Tenant = subject.Tenant } out.Attributes = copyMap(out.Attributes) - addAttribute(out.Attributes, "display_name", subject.DisplayName) - addAttribute(out.Attributes, "organization_relation", subject.OrganizationRelation) - addAttribute(out.Attributes, "roles", subject.Roles) - addAttribute(out.Attributes, "groups", subject.Groups) - addAttributes(out.Attributes, subject.Claims) - addAttributes(out.Attributes, subject.Metadata) + setAuthoritative(out.Attributes, "display_name", subject.DisplayName, "subject", overridden) + setAuthoritative(out.Attributes, "organization_relation", subject.OrganizationRelation, "subject", overridden) + setAuthoritative(out.Attributes, "roles", subject.Roles, "subject", overridden) + setAuthoritative(out.Attributes, "groups", subject.Groups, "subject", overridden) + setAuthoritatives(out.Attributes, subject.Claims, "subject", overridden) + setAuthoritatives(out.Attributes, subject.Metadata, "subject", overridden) return out } -func enrichResourceRef(ref api.ResourceRef, resource api.Resource) api.ResourceRef { +// enrichResourceRef overlays registry facts onto the resource the caller sent. +// Registry facts win, for the reason given on enrichSubjectRef: ops-warden's +// max_ttl_hours, allowed_subjects, and allowed_principals are ceilings and +// allowlists, and a ceiling a caller can raise is not a ceiling. +func enrichResourceRef(ref api.ResourceRef, resource api.Resource, overridden *[]string) api.ResourceRef { out := ref - if out.Type == "" { + if resource.Type != "" { out.Type = resource.Type } out.Attributes = copyMap(out.Attributes) - addAttribute(out.Attributes, "path", resource.Path) - addAttribute(out.Attributes, "parent", resource.Parent) - addAttribute(out.Attributes, "labels", resource.Labels) - addAttribute(out.Attributes, "trust_zone", resource.TrustZone) - addAttribute(out.Attributes, "owner", resource.Owner) - addAttributes(out.Attributes, resource.Attributes) + setAuthoritative(out.Attributes, "path", resource.Path, "resource", overridden) + setAuthoritative(out.Attributes, "parent", resource.Parent, "resource", overridden) + setAuthoritative(out.Attributes, "labels", resource.Labels, "resource", overridden) + setAuthoritative(out.Attributes, "trust_zone", resource.TrustZone, "resource", overridden) + setAuthoritative(out.Attributes, "owner", resource.Owner, "resource", overridden) + setAuthoritatives(out.Attributes, resource.Attributes, "resource", overridden) return out } -func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpectation, facts registryFacts) api.DecisionEnvelope { +func (e *Engine) envelope(request, submitted api.CheckRequest, expectation api.DecisionExpectation, facts registryFacts) api.DecisionEnvelope { envelope := api.DecisionEnvelope{ RequestID: request.ID, Effect: expectation.Effect, @@ -315,7 +354,7 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe MatchedRule: expectation.Reason, Resource: request.Resource, Subject: request.Subject, - Binding: api.NewDecisionBinding(request), + Binding: api.NewDecisionBindingFor(request, submitted), Obligations: expectation.Obligations, Diagnostics: map[string]any{ "action": request.Action, @@ -324,6 +363,7 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe "registry_subject": facts.subjectFound, "registry_resource": facts.resourceFound, "matched_relationship": facts.matchedRelationship, + "registry_overrode": facts.overriddenByRegistry, }, Provenance: api.DecisionProvenance{ Evaluator: "flex-auth/local", @@ -407,6 +447,43 @@ func copyMap(in map[string]any) map[string]any { return out } +// setAuthoritative writes a registry fact over whatever the request carried for +// that key, and records the key when a caller value was displaced so the +// override is visible in diagnostics rather than silent. +func setAuthoritative(target map[string]any, key string, value any, scope string, overridden *[]string) { + if isEmptyAttribute(value) { + return + } + if existing, exists := target[key]; exists && overridden != nil && !equalAttribute(existing, value) { + *overridden = append(*overridden, scope+"."+key) + } + target[key] = value +} + +func setAuthoritatives(target map[string]any, attrs map[string]any, scope string, overridden *[]string) { + for _, key := range sortedKeys(attrs) { + setAuthoritative(target, key, attrs[key], scope, overridden) + } +} + +func equalAttribute(a, b any) bool { + left, errLeft := json.Marshal(a) + right, errRight := json.Marshal(b) + if errLeft != nil || errRight != nil { + return false + } + return bytes.Equal(left, right) +} + +func sortedKeys(attrs map[string]any) []string { + keys := make([]string, 0, len(attrs)) + for key := range attrs { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + func addAttributes(target map[string]any, attrs map[string]any) { for key, value := range attrs { addAttribute(target, key, value) diff --git a/internal/decision/enrichment_precedence_test.go b/internal/decision/enrichment_precedence_test.go new file mode 100644 index 0000000..3110206 --- /dev/null +++ b/internal/decision/enrichment_precedence_test.go @@ -0,0 +1,198 @@ +package decision_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/netkingdom/flex-auth/pkg/api" +) + +// A registry fact is an authority statement; the same key on the request is the +// caller's proposal about itself. Enrichment once let the proposal survive, which +// made every registry ceiling and allowlist advisory — a caller supplying +// resource.attributes.max_ttl_hours raised its own ceiling, and one supplying +// allowed_subjects authorized a subject the registry did not know +// (FLEX-DEC-2026-012). +// +// These tests assert the registry value reaches policy on a request that +// contradicts it. They are written against attribute keys real packages branch +// on, because a test over an unused key would pass while the escalation stayed +// open. +func TestRegistryFactsOverrideCallerSuppliedAttributes(t *testing.T) { + store := newTestStore(t) + if err := store.ImportResourceManifest(api.ResourceManifest{ + ID: "ceilings", + System: "test-system", + Resources: []api.Resource{{ + ID: "resource:ceiling", + Type: "document", + Attributes: map[string]any{"max_ttl_hours": 8, "allowed_subjects": []any{"user:alice"}}, + }}, + }); err != nil { + t.Fatalf("ImportResourceManifest: %v", err) + } + engine := newTestEngineWithStore(t, store) + + decision, err := engine.Check(context.Background(), api.CheckRequest{ + Subject: api.SubjectRef{ID: "user:alice"}, + Action: "read", + Resource: api.ResourceRef{ + ID: "resource:ceiling", + System: "test-system", + Attributes: map[string]any{ + "max_ttl_hours": 99, + "allowed_subjects": []any{"user:mallory"}, + }, + }, + }) + if err != nil { + t.Fatalf("Check: %v", err) + } + + attributes := decision.Binding.Resource.Attributes + if got := attributes["max_ttl_hours"]; !equalJSON(t, got, 8) { + t.Fatalf("max_ttl_hours = %v; want the registry's 8, not the caller's 99", got) + } + if got := attributes["allowed_subjects"]; !equalJSON(t, got, []any{"user:alice"}) { + t.Fatalf("allowed_subjects = %v; caller displaced the registry allowlist", got) + } + + // The override must be visible. A registry that silently discards a + // contradicting claim hides that a caller asserted authority it did not have, + // which is the half worth alerting on even though the decision is now correct. + overrode, _ := decision.Diagnostics["registry_overrode"].([]string) + if !containsAll(overrode, "resource.max_ttl_hours", "resource.allowed_subjects") { + t.Fatalf("registry_overrode = %v; want both displaced keys named", overrode) + } +} + +// Tenant is a registry fact and the registry wins. subject.type deliberately does +// NOT, and the asymmetry is the finding rather than an oversight: the registry's +// Type is CARING vocabulary (Human, Agent, Automation, Service) while the +// request's is the protected system's actor vocabulary (service, adm, agt, atm). +// Two fields sharing a name. Overwriting substituted one vocabulary for the other +// and denied every secrets-engine allow, because "Service" is not "service". +// +// This test pins both halves so the residual cannot be closed by accident, and so +// a later reader sees the exclusion is reasoned. FLEX-WP-0025 separates them. +func TestRegistryTenantWinsWhileSubjectTypeVocabularyIsPreserved(t *testing.T) { + store := newTestStore(t) + if err := store.ImportSubjectManifest(api.SubjectManifest{ + ID: "principals", + Tenants: []api.Tenant{{ID: "tenant:real"}}, + Subjects: []api.Subject{{ + ID: "subject:claimant", + Type: "human", + Tenant: "tenant:real", + }}, + }); err != nil { + t.Fatalf("ImportSubjectManifest: %v", err) + } + engine := newTestEngineWithStore(t, store) + + decision, err := engine.Check(context.Background(), api.CheckRequest{ + Tenant: "tenant:claimed", + Subject: api.SubjectRef{ID: "subject:claimant", Type: "service", Tenant: "tenant:claimed"}, + Action: "read", + Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"}, + }) + if err != nil { + t.Fatalf("Check: %v", err) + } + if got := decision.Binding.Subject.Type; got != "service" { + t.Fatalf("subject.type = %q; the caller's actor vocabulary must survive", got) + } + if got := decision.Binding.Subject.Tenant; got != "tenant:real" { + t.Fatalf("subject.tenant = %q; want the registry's tenant:real", got) + } +} + +// An unregistered subject or resource has no facts to overlay, so what the caller +// sent stands. That is the stated residual rather than an oversight: a policy +// reading a key its manifest omits is consuming caller input, and no override +// should be reported for a key nobody contradicted. +func TestUnregisteredResourceKeepsCallerAttributesAndReportsNoOverride(t *testing.T) { + engine := newTestEngine(t) + + decision, err := engine.Check(context.Background(), api.CheckRequest{ + Subject: api.SubjectRef{ID: "user:alice"}, + Action: "read", + Resource: api.ResourceRef{ + ID: "document:not-in-registry", + System: "markitect-tool", + Attributes: map[string]any{"stage": "prod"}, + }, + }) + if err != nil { + t.Fatalf("Check: %v", err) + } + if got := decision.Binding.Resource.Attributes["stage"]; got != "prod" { + t.Fatalf("stage = %v; caller attributes on an unregistered resource must survive", got) + } + if overrode, _ := decision.Diagnostics["registry_overrode"].([]string); len(overrode) != 0 { + t.Fatalf("registry_overrode = %v; nothing was contradicted", overrode) + } +} + +func equalJSON(t *testing.T, got, want any) bool { + t.Helper() + left, err := json.Marshal(got) + if err != nil { + return false + } + right, err := json.Marshal(want) + if err != nil { + return false + } + return bytes.Equal(left, right) +} + +func containsAll(values []string, wanted ...string) bool { + for _, want := range wanted { + found := false + for _, value := range values { + if value == want { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// binding.request_digest is computed over the ENRICHED request, so a consumer +// recomputing it over what it sent gets a different value on every request whose +// subject or resource the registry knows. It was published as the §6.4.2 replay +// test for consumers, which it cannot be. submitted_request_digest is that test: +// RequestDigest over the request exactly as received (FLEX-DEC-2026-012). +func TestSubmittedRequestDigestIsComputableByTheConsumer(t *testing.T) { + engine := newTestEngine(t) + + request := api.CheckRequest{ + Tenant: "tenant:platform", + Subject: api.SubjectRef{ID: "user:alice"}, + Action: "read", + Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"}, + } + + decision, err := engine.Check(context.Background(), request) + if err != nil { + t.Fatalf("Check: %v", err) + } + + // The consumer holds only the request it sent. This is the whole property. + if got, want := decision.Binding.SubmittedRequestDigest, api.RequestDigest(request); got != want { + t.Fatalf("submitted_request_digest = %q; consumer computes %q", got, want) + } + + // And it must genuinely differ from the enriched digest here, or the test + // would pass on a request the registry never touched and prove nothing. + if decision.Binding.SubmittedRequestDigest == decision.Binding.RequestDigest { + t.Fatal("digests agree on an enriched request; the distinction is not being exercised") + } +} diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index e2f01d8..e3a0848 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -276,6 +276,25 @@ type DecisionBinding struct { // identity and must not be used as one: two requests differing only in // which approval was presented share it, and the decision does not. ApprovalBindingDigest string `json:"approval_binding_digest,omitempty" yaml:"approval_binding_digest,omitempty"` + + // SubmittedRequestDigest is RequestDigest over the request exactly as the + // consumer sent it, before the evaluator overlays registry facts onto the + // subject and resource. + // + // It exists because RequestDigest is not consumer-computable and was + // published as though it were. The evaluator hashes the ENRICHED request — + // subject.tenant, subject.attributes, and resource attributes the registry + // contributed — and the registry is flex-auth's, so a consumer recomputing + // the digest over what it sent gets a different value on every real allow. + // secrets-engine found this on its first live request (FLEX-DEC-2026-012). + // + // This is the digest a PEP compares for §6.4 obligation 2's replay test. + // 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 + // SubmittedRequestDigest together with that digest identifies the evaluated + // request completely. + SubmittedRequestDigest string `json:"submitted_request_digest,omitempty" yaml:"submitted_request_digest,omitempty"` } // ApprovalContextKey is the context key carrying an approval-engine @@ -296,6 +315,10 @@ type requestDigestMaterial struct { // NewDecisionBinding returns a stable structured binding for the exact request // an evaluator consumed. +// +// Prefer NewDecisionBindingFor, which also records the digest of the request as +// submitted. This form leaves SubmittedRequestDigest empty, which reads as "the +// evaluator did not record one" rather than "the two are equal". func NewDecisionBinding(request CheckRequest) *DecisionBinding { contextCopy := make(map[string]any, len(request.Context)) for key, value := range request.Context { @@ -315,6 +338,19 @@ func NewDecisionBinding(request CheckRequest) *DecisionBinding { return binding } +// NewDecisionBindingFor records the binding of the evaluated request together +// with the digest of the request as submitted. +// +// The two arguments are the same request before and after the evaluator +// overlaid registry facts. Where a request named nothing the registry knows they +// are equal, and the field is emitted anyway: a consumer that only sees it on +// enriched decisions would build a check that passes by absence. +func NewDecisionBindingFor(evaluated, submitted CheckRequest) *DecisionBinding { + binding := NewDecisionBinding(evaluated) + binding.SubmittedRequestDigest = RequestDigest(submitted) + return binding +} + // ApprovalBindingDigest is RequestDigest over the same material with the // approval claim removed from context. // diff --git a/schemas/decision_envelope.schema.json b/schemas/decision_envelope.schema.json index d53cdc3..f3dbc0a 100644 --- a/schemas/decision_envelope.schema.json +++ b/schemas/decision_envelope.schema.json @@ -123,12 +123,18 @@ }, "request_digest": { "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "Digest over the EVALUATED request, after the evaluator overlays registry facts onto subject and resource. It pins what the decision was a function of and makes the decision replayable by flex-auth for audit. It is NOT consumer-computable and must not be used as the consumer's replay test - use submitted_request_digest. With provenance.registry_snapshot_digest it identifies the evaluated request completely." }, "approval_binding_digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Present only when the request carried an approval claim on context.approval. The request digest computed with that claim removed from context, so it is stable across attaching the claim and can therefore be named by an approval's pdp_digest recorded at issue time (GH-DEC-2026-008, FLEX-DEC-2026-007). Compare claim.binding.pdp_digest to THIS value, never to request_digest: a claim can never equal the digest of the request that carries it. Not a replay identity -- two requests differing only in which approval was presented share this value and their decisions do not. request_digest remains the replay identity." + }, + "submitted_request_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "RequestDigest over the request exactly as the consumer sent it, before the evaluator overlays registry facts. This is the digest a PEP compares for security-layer-model 6.4 obligation 2; request_digest is over the enriched request and is not consumer-computable. See docs/request-enrichment.md (FLEX-DEC-2026-012)." } } }, diff --git a/workplans/FLEX-WP-0025-fact-versus-assertion.md b/workplans/FLEX-WP-0025-fact-versus-assertion.md new file mode 100644 index 0000000..1584b25 --- /dev/null +++ b/workplans/FLEX-WP-0025-fact-versus-assertion.md @@ -0,0 +1,122 @@ +--- +id: FLEX-WP-0025 +type: workplan +title: "A policy cannot tell a registry fact from a caller assertion" +domain: infotech +repo: flex-auth +status: ready +owner: claude +topic_slug: netkingdom +planning_priority: P1 +planning_order: 250 +depends_on_workplans: + - FLEX-WP-0021 +related_workplans: + - FLEX-WP-0022 + - FLEX-WP-0024 +created: "2026-09-07" +updated: "2026-09-07" +--- + +# FLEX-WP-0025 — A policy cannot tell a registry fact from a caller assertion + +Opened by `FLEX-DEC-2026-012`. That decision closed three verified privilege +escalations by making registry facts win over caller-supplied attributes. It did +not close the shape that produced them. + +`input.resource.attributes.max_ttl_hours` reaches a Rego rule as one value in one +map. **Nothing in that input says whether the registry asserted it or the caller +did.** Precedence decides which value survives; it does not let a policy author +require that a ceiling came from the registry at all. + +## The two residuals precedence did not close + +**1. A key the manifest omits is caller input.** Registry facts win only where +the registry has a value. A package branching on an attribute its manifest does +not declare is reading the caller, and a manifest that stops declaring a key +silently hands that key back. Registering the resource is not sufficient — the +specific key must be present. + +**2. `subject.type` is two fields sharing a name.** The registry's `type` is +CARING vocabulary (`Human`, `Agent`, `Automation`, `Service`); the request's is +the protected system's actor vocabulary (`service`, `adm`, `agt`, `atm`). +Policies compare against the latter. Making the registry win here denied every +`secrets-engine` allow, because `"Service"` is not `"service"`, so the exception +stands — and a caller can still assert its own `subject.type` while packages +branch on it. + +Note how the second surfaced: the registry's `type` had been dead data since the +field existed, because the caller's value always won. **A defect is invisible +while the value it produces is never read.** + +## 1. Decide the shape + +```task +id: FLEX-WP-0025-T01 +status: todo +priority: high +``` + +Owner: `flex-auth`. + +The obvious move is separate namespaces — `input.resource.registry.*` for facts +and `input.resource.asserted.*` for caller input, with `attributes` retained as +the merged view. That breaks no package on day one and lets a rule that enforces +a ceiling opt into the fact side. + +Weigh against it: a third view invites a package to read the merged one by +habit, which is the status quo with extra steps. A stricter alternative is to +stop merging at all and require packages to name a side, accepting a breaking +change across six published packages. + +Do not decide this from the shape alone. Read what the six packages actually +branch on first, and write down which of their reads are ceilings or allowlists +(must be facts), which are genuinely caller-proposed (`ttl_hours`, `purpose`), +and which are ambiguous. The ambiguous set is the real design input. + +Gate: the chosen shape is written down with the vocabulary collision addressed, +before any package changes. + +## 2. Audit every package for ceilings read from undeclared keys + +```task +id: FLEX-WP-0025-T02 +status: todo +priority: high +``` + +Owner: `flex-auth`. + +Mechanical and worth doing before T01 lands, because it is the live residual. +For each published package, list every `input.*.attributes.` read, and check +the key is declared in the manifest for every resource that package can be asked +about. + +Any ceiling or allowlist read from a key the manifest does not declare is a live +escalation of the `FLEX-DEC-2026-012` shape. Fix by declaring the key, not by +changing the rule. + +Gate: a table of package × key × declared-in-manifest, with no ceiling or +allowlist unbacked. Add it to `docs/request-enrichment.md`. + +## 3. Make the review obligation enforceable rather than written + +```task +id: FLEX-WP-0025-T03 +status: wait +priority: medium +``` + +Owner: `flex-auth`. + +`docs/request-enrichment.md` now says a policy enforcing a ceiling MUST read a +key its manifest declares. A review obligation nothing checks is the shape +`FLEX-DEC-2026-008` was about. + +Extend `flex-auth validate` to report attribute keys a package reads that its +fixtures' registry never supplies. It cannot prove intent — it does not know +which reads are ceilings — but it can surface every read that resolves only from +caller input, which is the set a reviewer must look at. + +Gate: the check runs in `validate` and flags a package whose ceiling key is +undeclared, demonstrated against a deliberately broken fixture package.