flex-auth/examples/secrets-engine/replay
tegwick d98323b2bb
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 41s
fix(secrets-engine): v2 adds the tenant rule v1 never had
secrets-engine.catalog-lane.lifecycle v1 contained no reference to
input.tenant — not in well_formed, not in the denial ladder, not in a
test. A rotate on lane:glas-primary under tenant:coulomb returned allow
against the deployed package (decision:066e629bbf0c0924).

Found while answering glas-harness's tenant-alignment request, which had
asked for wrong-tenant denial evidence. There was none to return.

Three covers failed the same way: every one of the 29 fixtures carried
tenant:platform, so the suite could not report on the field; T02's own
gate named "wrong-tenant deny" and was recorded done unmet; and the
engine hashes tenant into request_digest but never compares it. Four
other published packages carry the branch — this one was the outlier.

v2 adds wrong_tenant above wrong_system, three Rego tests and three
fixtures (28/28, 32/32). The absent-tenant test caught a second defect
in the first draft: a bare input.tenant != comparison is undefined on a
missing key, so the branch dropped and the ladder reported the wrong
rung. request_tenant := object.get(input, "tenant", "") fixes it.

v2 supersedes rather than amends v1 because the defect failed open: a
consumer pinned to _VERSION=v1 would keep receiving allows with no
signal the rule beneath the version string had changed. The earlier
dual-control correction stayed at v1 because it denied everything.

Replay envelopes regenerated at v2; both request_digest values are
byte-identical, so secrets-engine's digest join needs no re-pinning.

The sweep this prompted found tenant-engine unscoped on tenant as well —
deployed, and verified allowing tenant:coulomb. Not the same fix: its
request tenant names the target rather than the caller, so a constant
would break it. Recorded and carried by FLEX-WP-0022 rather than patched
unilaterally.

FLEX-WP-0021 closes at T05; the pin still serves v1 until a redeploy.

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-06 20:38:45 +02:00
..
decision_destroy_dual_control.json fix(secrets-engine): v2 adds the tenant rule v1 never had 2026-09-06 20:38:45 +02:00
decision_rotate.json fix(secrets-engine): v2 adds the tenant rule v1 never had 2026-09-06 20:38:45 +02:00
decision_wrong_tenant_deny.json fix(secrets-engine): v2 adds the tenant rule v1 never had 2026-09-06 20:38:45 +02:00
README.md fix(secrets-engine): v2 adds the tenant rule v1 never had 2026-09-06 20:38:45 +02:00

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 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.