From 85d454803579154d35b2612c98480fd1555f0358 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 2 Sep 2026 10:08:33 +0200 Subject: [PATCH] Revoke leases by operator-held lease id session revoke accepts exactly one of --accessor-file or --lease-id-file. Lease ids are read from a mode-0600 out-of-repo file and fingerprinted in evidence; the handle is never printed. Production remains fail-closed. Assistant: grok Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00 --- SCOPE.md | 9 ++-- docs/cli.md | 2 +- docs/hardening-backlog.md | 7 ++- src/secrets_engine/cli.py | 44 ++++++++++++---- src/secrets_engine/openbao.py | 6 +++ tests/test_session_revoke.py | 52 ++++++++++++++++++- ...-WP-0007-production-lifecycle-hardening.md | 4 +- 7 files changed, 100 insertions(+), 24 deletions(-) diff --git a/SCOPE.md b/SCOPE.md index c010b2a..49cd73a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -177,11 +177,10 @@ high-risk lanes. catalog-id confirmation; it will remain closed until the canonical exact-action approval contract in `SECRETS-WP-0007-T04` is enforced. -These operations do not manage external workload delivery. `session revoke ---accessor-file` revokes an already-issued token by a non-secret accessor the -operator already holds; evidence is fingerprint-only. There is currently no -rotation command, compromised state, or persistent/reversible lane state -machine. +These operations do not manage external workload delivery. `session revoke` +revokes an already-issued token accessor or lease id the operator already +holds; evidence is fingerprint-only. There is currently no rotation command, +compromised state, or persistent/reversible lane state machine. ## CLI Surface diff --git a/docs/cli.md b/docs/cli.md index ccf8ae0..b2d4d77 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -60,7 +60,7 @@ secrets-engine exec --catalog [--field NAME] [--mode auto|npm-confi secrets-engine policy publication secrets-engine route [--json] secrets-engine revoke [--dry-run] -secrets-engine session revoke --accessor-file F [--stage stage] +secrets-engine session revoke (--accessor-file F | --lease-id-file F) [--stage stage] secrets-engine lifecycle suspend [--dry-run] secrets-engine lifecycle deactivate [--dry-run] secrets-engine lifecycle destroy [--dry-run] [--confirm-destroy ] diff --git a/docs/hardening-backlog.md b/docs/hardening-backlog.md index efecda0..2d1a426 100644 --- a/docs/hardening-backlog.md +++ b/docs/hardening-backlog.md @@ -75,10 +75,9 @@ contents in this repo. ## H4a — Known-accessor operator command - Delivery sessions already self-revoke in `finally` (`SECRETS-WP-0007-T05`). -- Implemented: `secrets-engine session revoke --accessor-file F` reads a - mode-0600 out-of-repo accessor, calls `token revoke -accessor`, and records - only a fingerprint. Production remains fail-closed. Lease-id revoke is still - outstanding. +- Implemented: `secrets-engine session revoke --accessor-file F` or + `--lease-id-file F` reads a mode-0600 out-of-repo handle, revokes it, and + records only a fingerprint. Production remains fail-closed. ## H5 — Audit report command diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 10f7d91..df8a724 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -13,7 +13,7 @@ Command surface (FR7): exec --catalog [--field NAME] [--mode auto|npm-config|exec-env|exec-file] -- CMD... route [--json] revoke - session revoke --accessor-file F [--stage stage] + session revoke (--accessor-file F | --lease-id-file F) [--stage stage] lifecycle suspend|deactivate|destroy audit [--json] secret-use snapshot [--catalog-id ID] [--json] @@ -505,7 +505,16 @@ def cmd_exec(cfg: Config, args) -> int: def cmd_session_revoke(cfg: Config, args) -> int: - """Revoke an already-issued token by accessor. Never print the accessor.""" + """Revoke an already-issued token or lease. Never print the handle.""" + from secrets_engine.errors import ProvisioningError + + accessor_file = getattr(args, "accessor_file", None) or "" + lease_file = getattr(args, "lease_id_file", None) or "" + if bool(accessor_file) == bool(lease_file): + raise ProvisioningError( + "exactly one of --accessor-file or --lease-id-file is required" + ) + kind = "accessor" if accessor_file else "lease" stance_entry = SimpleNamespace(stage=args.stage, approval={"model": "bootstrap-only"}) evidence = PrivilegedActionEvidence( writer=_writer(cfg), @@ -513,20 +522,28 @@ def cmd_session_revoke(cfg: Config, args) -> int: catalog_id="", stage=args.stage, approval_required=False, + detail={"handle_kind": kind}, ) with evidence: stance = apply_unreachable_engine_stance(cfg, stance_entry, "session-revoke") evidence.mark_stance(stance) - accessor = read_strict_token_file( - Path(args.accessor_file), purpose="token accessor" + handle = read_strict_token_file( + Path(accessor_file or lease_file), + purpose="token accessor" if kind == "accessor" else "lease id", ) - fingerprint = accessor_fingerprint(accessor) + fingerprint = accessor_fingerprint(handle) evidence.detail["session_handle"] = fingerprint with _open_backend(cfg, args, evidence) as client: - client.revoke_accessor(accessor) - del accessor - print(f"revoked session handle {fingerprint}") - evidence.finish("revoked", detail={"session_handle": fingerprint}) + if kind == "accessor": + client.revoke_accessor(handle) + else: + client.revoke_lease(handle) + del handle + print(f"revoked {kind} handle {fingerprint}") + evidence.finish( + "revoked", + detail={"session_handle": fingerprint, "handle_kind": kind}, + ) return 0 @@ -870,13 +887,18 @@ def build_parser() -> argparse.ArgumentParser: sessub = sess.add_subparsers(dest="subcmd", required=True) srev = sessub.add_parser( "revoke", - help="revoke a token by a non-secret accessor the operator already holds", + help="revoke a token accessor or lease id the operator already holds", ) srev.add_argument( "--accessor-file", - required=True, + default=None, help="mode-0600 out-of-repo file containing the token accessor", ) + srev.add_argument( + "--lease-id-file", + default=None, + help="mode-0600 out-of-repo file containing the lease id", + ) srev.add_argument("--stage", default="prod", choices=("build", "test", "prod")) add_token_arg(srev) srev.set_defaults(func=cmd_session_revoke) diff --git a/src/secrets_engine/openbao.py b/src/secrets_engine/openbao.py index 1d9bf76..75e8555 100644 --- a/src/secrets_engine/openbao.py +++ b/src/secrets_engine/openbao.py @@ -353,6 +353,12 @@ class OpenBaoClient: raise BackendError("token accessor is missing or invalid") self._run_ok(["token", "revoke", "-accessor", accessor]) + def revoke_lease(self, lease_id: str) -> None: + """Revoke a lease the operator already holds, by lease id only.""" + if not lease_id or any(ch.isspace() for ch in lease_id): + raise BackendError("lease id is missing or invalid") + self._run_ok(["lease", "revoke", lease_id]) + def _parse_wrap_response(self, stdout: str, *, ttl: str, creation_path: str) -> WrappedResponse: try: payload = json.loads(stdout) diff --git a/tests/test_session_revoke.py b/tests/test_session_revoke.py index 3915424..6e61b7c 100644 --- a/tests/test_session_revoke.py +++ b/tests/test_session_revoke.py @@ -5,7 +5,7 @@ import pytest from secrets_engine import cli from secrets_engine.config import Config -from secrets_engine.errors import BackendError, DecisionError +from secrets_engine.errors import BackendError, DecisionError, ProvisioningError from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint @@ -42,6 +42,7 @@ def test_session_revoke_uses_fingerprint_only(tmp_path, monkeypatch): ) args = SimpleNamespace( accessor_file=str(_accessor_file(tmp_path)), + lease_id_file=None, stage="test", bootstrap_token_file=None, auth="auto", @@ -95,6 +96,53 @@ def test_session_revoke_production_fails_closed_before_backend(tmp_path, monkeyp cli.cmd_session_revoke(cfg, args) +def test_session_revoke_lease_uses_fingerprint_only(tmp_path, monkeypatch): + lease = "kv/data/test/team/thing/lease-id-secretish" + seen = {} + + class _Client: + def revoke_lease(self, lease_id): + seen["lease"] = lease_id + + def revoke_accessor(self, _accessor): + pytest.fail("must not revoke accessor") + + monkeypatch.setattr(cli, "_open_backend", lambda *_args, **_kwargs: _ctx(_Client())) + path = tmp_path / "lease.handle" + path.write_text(lease, encoding="utf-8") + path.chmod(0o600) + args = SimpleNamespace( + accessor_file=None, + lease_id_file=str(path), + stage="test", + bootstrap_token_file=None, + auth="auto", + ) + rc = cli.cmd_session_revoke(_config(tmp_path), args) + assert rc == 0 + assert seen["lease"] == lease + records = [ + json.loads(line) + for line in next((tmp_path / "evidence").glob("evidence-*.jsonl")).read_text().splitlines() + ] + dumped = json.dumps(records) + assert lease not in dumped + assert records[-1]["detail"]["handle_kind"] == "lease" + assert records[-1]["detail"]["session_handle"] == accessor_fingerprint(lease) + + +def test_session_revoke_requires_exactly_one_handle(tmp_path): + args = SimpleNamespace( + accessor_file=None, + lease_id_file=None, + stage="test", + bootstrap_token_file=None, + auth="auto", + ) + with pytest.raises(ProvisioningError, match="exactly one"): + cli.cmd_session_revoke(_config(tmp_path), args) + + def test_revoke_accessor_rejects_blank(monkeypatch): client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao") monkeypatch.setattr( @@ -102,3 +150,5 @@ def test_revoke_accessor_rejects_blank(monkeypatch): ) with pytest.raises(BackendError, match="missing or invalid"): client.revoke_accessor(" ") + with pytest.raises(BackendError, match="missing or invalid"): + client.revoke_lease("") diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index 3ead921..04aa952 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -168,8 +168,8 @@ destroy stays fail-closed until T04's exact-action contract; that is the designed containment, not unfinished T03 work. Issued delivery sessions self-revoke in T05. -Residual from this task, now implemented: `secrets-engine session revoke ---accessor-file` (hardening H4a). Lease-id revoke remains outstanding. +Residual from this task, now implemented: `secrets-engine session revoke` +`--accessor-file` or `--lease-id-file` (hardening H4a). Progress 2026-08-23. Added one lifecycle plan model shared by dry-run and live execution. Ordinary `revoke` safely aliases native deactivation; suspend and