From 083bee733354f3cd8890a32b640582258872134a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 08:09:42 +0200 Subject: [PATCH] test: enforce that dry runs never reach the authorization gate flex-auth's published policy package encodes an explicit limit: apply and apply --dry-run are indistinguishable to a PDP, since both arrive as action `apply`. What separates them is a property of this repo - the PEP does not call the gate for a dry run - and flex-auth recorded that as a limit rather than implying a control they do not have, asking to be told if it stops holding. Verified it holds across all five dry-run handlers (apply, revoke, lifecycle suspend/deactivate/destroy) and locked it with a regression test that fails with the instruction to notify flex-auth. A paired test proves the hook under test is actually load-bearing, so the guard cannot pass vacuously. Also records the FLEX-WP-0021-T01/T02 and GH-DEC-2026-005 outcomes in SECRETS-WP-0007-T04, including the revisit trigger flex-auth flagged: with one calling identity the denial ladder has no action_not_granted branch, so a second identity or a per-lane/stage split is the trigger to add it. allow_ttl 15m needed no change - normalize_wrap_ttl already caps wrap at 15m. The policy pin stays unset; publishing is not deploying. 257 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4 --- tests/test_dry_run_never_gates.py | 67 +++++++++++++++++++ ...-WP-0007-production-lifecycle-hardening.md | 57 ++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/test_dry_run_never_gates.py diff --git a/tests/test_dry_run_never_gates.py b/tests/test_dry_run_never_gates.py new file mode 100644 index 0000000..aa7fe11 --- /dev/null +++ b/tests/test_dry_run_never_gates.py @@ -0,0 +1,67 @@ +"""Dry runs must never reach the authorization gate. + +flex-auth's `secrets-engine.catalog-lane.lifecycle` v1 encodes an explicit +limit: `apply` and `apply --dry-run` are indistinguishable to a PDP, because +both would arrive as action `apply`. What keeps them apart is a property of +THIS repo -- the PEP does not call the gate for a dry run -- and flex-auth asked +to be told if that assumption ever stops holding (FLEX-WP-0021-T02). + +This test is that notification. If a dry-run path ever starts calling +`_require_lane_approval`, it fails here rather than silently widening what the +published package is understood to cover. +""" +import pytest + +from secrets_engine import cli + + +DRY_RUN_COMMANDS = [ + ("apply", ["apply", "whynot-design-npm-publish", "--stage", "prod", "--dry-run"]), + ("revoke", ["revoke", "whynot-design-npm-publish", "--dry-run"]), + ("lifecycle suspend", ["lifecycle", "suspend", "whynot-design-npm-publish", "--dry-run"]), + ("lifecycle deactivate", ["lifecycle", "deactivate", "whynot-design-npm-publish", "--dry-run"]), + ( + "lifecycle destroy", + [ + "lifecycle", "destroy", "whynot-design-npm-publish", "--dry-run", + "--confirm-destroy", "whynot-design-npm-publish", + ], + ), +] + + +@pytest.mark.parametrize(("label", "argv"), DRY_RUN_COMMANDS, ids=[c[0] for c in DRY_RUN_COMMANDS]) +def test_dry_run_does_not_reach_the_authorization_gate(label, argv, monkeypatch, capsys): + def _forbidden(*_args, **_kwargs): + pytest.fail( + f"'{label} --dry-run' reached the authorization gate. flex-auth's " + "policy package assumes dry runs never do, because a PDP cannot " + "distinguish them from the live action. Tell flex-auth before " + "changing this." + ) + + monkeypatch.setattr(cli, "_require_lane_approval", _forbidden) + monkeypatch.setattr( + cli.OpenBaoClient, + "resolve", + lambda *_a, **_k: pytest.fail(f"'{label} --dry-run' opened a backend"), + ) + cli.main(argv) + # A dry run must still render something for the operator to inspect. + assert capsys.readouterr().out.strip() + + +def test_live_paths_do_still_gate(monkeypatch): + """Guard the guard: prove the hook under test is actually load-bearing.""" + calls = [] + monkeypatch.setattr( + cli, + "_require_lane_approval", + lambda *a, **k: calls.append(a[2] if len(a) > 2 else "?"), + ) + monkeypatch.setattr( + cli.OpenBaoClient, "resolve", lambda *_a, **_k: pytest.fail("backend opened") + ) + with pytest.raises(BaseException): + cli.main(["apply", "whynot-design-npm-publish", "--stage", "prod"]) + assert calls == ["apply"], "live apply must pass action 'apply' to the gate" diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index 4b00e4f..ea0e2ee 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -352,6 +352,63 @@ and the published package from `FLEX-WP-0021-T02`. The pin stays unset. `FLEX-WP-0021-T01`. Separately tracked: production requires a KeyCape RS256 JWT, not the static Bearer token this engine currently sends. +Closed out 2026-09-06. gate-house amended `GH-DEC-2026-005`; flex-auth published +the policy package (`FLEX-WP-0021-T01`/`T02` done, commit `f75db59`). + +- `secrets-engine.catalog-lane.lifecycle` v1 exists with both manifests, a + loadable registry snapshot, 26 fixtures and five standalone check requests; + `validate -kind policy` reports 22/22 Rego tests and 26/26 fixtures. Our + vocabulary is recorded in flex-auth `docs/secrets-engine-action-vocabulary.md`. + All four constraints from `docs/gated-actions.md` are encoded rather than + assumed: `test_revoke_is_not_an_action` asserts `revoke` denies + `unknown_action`, `destroy` is fixtured as the dual-control case before + `T04` opens the path, `compromise`/`reactivate` are annotated overlay-only, + and the ungated verbs deny `unknown_action`. flex-auth reports four of the + twelve would have been inferred wrongly, `revoke` most confidently. +- **A published limit now rests on a property of this repo.** `apply` and + `apply --dry-run` are indistinguishable to a PDP; both arrive as action + `apply`. What separates them is that our PEP does not call the gate for a dry + run. flex-auth recorded that as a limit rather than implying a control they do + not have, and asked to be told if the assumption stops holding. + `tests/test_dry_run_never_gates.py` now enforces it across all five dry-run + handlers, with a paired test proving the hook is load-bearing. Changing a + dry-run path to gate is now a test failure carrying the instruction to notify + flex-auth first. +- Dual control on `destroy` checks what the claim *says* (two distinct approver + subject ids). flex-auth deliberately does not re-derive temporal validity, + signature, or supersession — those are approval-engine's to assert and **ours + to verify against the live claim**. `validate_approval_claim` is that check. +- `allow_ttl` is 15m in the package, stated rather than left to an engine + default. Per `FLEX-DEC-2026-004` that is authority to *issue* the operation, + not to keep using what it produced; bounding a delivered secret's own lifetime + is ours. Already satisfied: `normalize_wrap_ttl` caps wrap at 15m and lane + AppRoles carry 5m/15m token TTLs with a single-use Secret ID. +- Structural note carried as a revisit trigger, not a defect: because we named + exactly one calling identity, the denial ladder has no `action_not_granted` + branch — with one subject holding all twelve actions it could never fire, and + a rule that cannot fail reads as per-action control that is not there. + Registering a second identity, or splitting the CLI identity by lane or stage, + is the trigger; adding the branch then is additive and needs no request-shape + change. +- gate-house struck the G3 revisit trigger for a composed authorization object, + verified against the DecisionEnvelope schema: `lifetime` is required whenever + effect is allow, so carrying its own end — the one structural thing the bundle + did that the split does not — is already answered. Three triggers remain: a + third or fourth PEP-shaped consumer, a single signed forwardable artifact, and + §17 request-claim assent. +- On the approver threshold we stopped checking, gate-house recorded both that + it is correct on layering **and** a genuine reduction in defence in depth, + noting a decision recording only the first would be self-serving. The + compensating property is reconstructability at the issuer under §9.6 — + detection, not prevention — not a second check here. + +**The pin stays unset.** Publishing is not deploying: there is still no +`flex-auth-secrets-engine` pin and therefore no address to call. `T04` stands it +up in `callerAuth.mode warn`; `T05` hands over the Service DNS and confirms the +package/version to configure. Next inbound is the `T03` replay fixture — a real +DecisionEnvelope from this package including `provenance.registry_snapshot_digest` +— to verify the digest join unchanged. + Define and enforce the decision contract needed by production commands. A resolved approval must bind at least: