flex-auth/examples/secrets-engine/replay/README.md
tegwick 0bc624ba62
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
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
2026-09-07 13:43:33 +02:00

5.6 KiB

T03 replay fixtures

Real DecisionEnvelopes emitted from the published package, for secrets-engine to verify its digest join (627810b) unchanged. FLEX-WP-0021-T03.

File From
decision_rotate.json ../check_request_allow_rotate.json — plain allow, empty context
decision_destroy_dual_control.json ../check_request_allow_destroy_dual_control.json — dual control, valid approval-claim
decision_wrong_tenant_deny.json ../check_request_deny_wrong_tenant.json — foreign tenant, denied wrong_tenant

Regenerate either with:

go run ./cmd/flex-auth check \
  -policy   examples/secrets-engine/policy_package.md \
  -registry examples/secrets-engine/registry_snapshot.json \
  -request  examples/secrets-engine/check_request_allow_rotate.json

What is stable and what is not

Stable across runs — these are the fields to pin a contract test against:

Field rotate destroy
binding.request_digest sha256:de67324f…4345 sha256:c749ee2…091a
provenance.policy_package_digest sha256:bd11c5fe…c643 same
provenance.registry_snapshot_digest sha256:f5a309bc…40bb same
provenance.input_claim_digests.context absent (empty context) sha256:8b73d29…2800

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 sha256:bd11c5fe…c643. Both request_digest values are unchanged — the canonical digest covers tenant/subject/action/resource/context and not the package, so a consumer that pinned the digest join has nothing to re-pin. Only the two provenance fields moved, and they moved because the policy did.

decision_wrong_tenant_deny.json is the denial evidence for the tenant question: the same rotate request as decision_rotate.json with tenant: tenant:coulomb substituted. Its request_digest differs from the allow's, which is the replay identity working — the tenant is hashed material, so the two requests are not the same request. A deny carries no lifetime.

| binding.approval_binding_digest | absent (no claim) | sha256:fa07bec…cd56 |

That last row is the value an approval's pdp_digest must equal — never request_digest, which a claim can never match on the request that carries it. It equals the request_digest of the same request with context.approval removed, which is verifiable from this directory: strip the context from ../check_request_allow_destroy_dual_control.json, re-run check, and the claim-free request digest is the same value (it denies dual_control_required, which is the point).

This fixture verifies itself. The claim inside the request carries binding.pdp_digest equal to the envelope's approval_binding_digest, and binding.pdp_path: true. Compare the two values in the two files. Note that request_digest moved when the claim's contents changed while approval_binding_digest did not — that is the stability property, demonstrated in the artifact rather than asserted in prose.

Not stable: id, provenance.decision_time, and lifetime.not_before / lifetime.expires_at move with the clock. lifetime.ttl is 15m from the package's allow_ttl. Do not pin the record as a whole.

input_claim_digests.context appears only when the request carries a non-empty context — which is why both fixtures are here rather than just one. A consumer asserting the field is always present would pass on destroy and fail on rotate.

The destroy request's context.approval is a complete approval-claim, valid against approval-engine/schemas/approval_claim.schema.json including the now-required binding.pdp_digest. It was regenerated on 2026-09-06 when that claim was completed, so its digests differ from the first emission — a partial claim in a fixture is how a consumer learns the wrong shape.

Not a deployment

These come from flex-auth/local in standalone mode (provenance.evaluator / mode), not from a cluster pin. No flex-auth-secrets-engine pin exists yet (FLEX-WP-0021-T04), and the consumer policy pin stays unset until T05.