From c3eb59ea045be19a21b9033ab043deeaac3e1c6e Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 16 Jul 2026 14:40:30 +0200 Subject: [PATCH] WARDEN-WP-0026 T06: rotation guidance registry + warden rotate-guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routing model: RotationGuide (method rotate|re-establish, steps, owner, automatable), RouteEntry.rotation + has_rotation + vends_secret. - catalog parser: validate rotation block; secret-material screen gains a prose-safe mode (high-entropy detector only) so authored steps aren't tripped by substrings like "s."/"exists.". - CLI: `warden rotate-guide ` (human + --json); route show --json now carries has_rotation + rotation. - scorecard: catalog_rotation_coverage — every active secret-vending lane must carry a rotation block (SSH/login/pointer lanes exempt). Promotion checklist criterion 9. - data: rotation blocks for all 7 active vending lanes + the draft railiance-backup lane (re-establish: age keypair regen + re-encrypt). - fix pre-existing collision: bare `npm` keyword on forgejo-admin -> forgejo-npm so "npm token" routes to the generic lane (restores test_access expectations). - tests: rotation parse/coverage/prose-screen/CLI in tests/test_routing.py; scorecard count 6 -> 7. Co-Authored-By: Claude Opus 4.8 --- registry/routing/catalog.yaml | 77 ++++++++++++++++- src/warden/cli.py | 72 ++++++++++++++++ src/warden/routing/catalog.py | 72 ++++++++++++++-- src/warden/routing/models.py | 37 +++++++++ src/warden/scorecard.py | 38 +++++++++ tests/test_routing.py | 83 +++++++++++++++++++ tests/test_scorecard.py | 3 +- wiki/playbooks/catalog-lane-promotion.md | 1 + ...N-WP-0026-credential-disclosure-hygiene.md | 14 +++- 9 files changed, 385 insertions(+), 12 deletions(-) diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index ca863b9..d6a9520 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -68,6 +68,14 @@ entries: exec_owner: railiance-platform exec_command: "scripts/credential.py exec --grant ops-warden/warden-sign --ttl 15m -- " pointer_command: "make credential-exec-ops-warden-smoke" + rotation: + method: rotate + owner: railiance-platform + automatable: true + steps: + - "This lane vends a short-lived (15m) child VAULT_TOKEN — routine renewal is just re-running `credential.py exec`; the token auto-expires, nothing to revoke." + - "To rotate the underlying grant/issuer: railiance-platform edits `credential-grants/catalog.yaml` for `ops-warden/warden-sign` (scope/policy) and re-mints the issuer token behind OPENBAO_TOKEN_FILE." + - "Verify capabilities-safe: `make credential-exec-ops-warden-smoke` and a `bao token capabilities` check on ssh/sign/{adm,agt,atm}-role (never read a value)." - id: openbao-api-key title: API key, DB credential, or dynamic lease @@ -87,6 +95,15 @@ entries: fetch_command: "bao kv get -field= " policy_ref: "flex-auth check secret.read:" exec_capable: true + rotation: + method: rotate + owner: railiance-platform + automatable: false + steps: + - "Generic template lane — rotate per the concrete workload's own catalog entry when one exists." + - "Provider re-mint (or OpenBao dynamic-secret rotation): mint a fresh value at the source, then `bao kv put =@file` (value from a mode-0600 file, never on argv)." + - "For dynamic-lease secrets, revoke the old lease (`bao lease revoke`) instead of a KV put." + - "Verify capabilities-safe (`bao token capabilities` on the data path); notify consumers to re-fetch." - id: whynot-design-npm-publish title: whynot-design npm publish token (@whynot/design → coulomb Gitea registry) @@ -116,6 +133,14 @@ entries: exec_owner: secrets-engine exec_command: "secrets-engine exec --catalog whynot-design-npm-publish -- " pointer_command: "secrets-engine route whynot-design-npm-publish --json" + rotation: + method: rotate + owner: railiance-platform + automatable: false + steps: + - "In the coulomb Gitea/Forgejo registry, revoke the current @whynot/design publish token and generate a new one (scope: package read/write) for the whynot-design publish identity." + - "Write it back: `bao kv put platform/workloads/coulomb/whynot-design/npm-publish NPM_AUTH_TOKEN=@file` (value from a mode-0600 file)." + - "Verify capabilities-safe, then confirm publish works via `secrets-engine exec --catalog whynot-design-npm-publish -- npm whoami` (value used, not printed)." - id: flex-auth-policy-check title: Authorization decision — may this actor perform this action @@ -213,6 +238,15 @@ entries: policy_ref: "flex-auth check secret.read:issue-core" exec_capable: true lane: secret + rotation: + method: rotate + owner: railiance-platform + automatable: false + steps: + - "Mint a new issue-core ingestion API key at the issue-core admin surface; keep the old one until consumers cut over." + - "`bao kv put platform/workloads/issue-core/issue-core/issue-core-runtime ISSUE_CORE_API_KEY=@file` (value from a mode-0600 file)." + - "ESO re-syncs ExternalSecret issue-core/issue-core-runtime; roll consumers, then revoke the old key at the source." + - "Verify capabilities-safe on the data path (`bao token capabilities`); never read the value to confirm." - id: reuse-surface-hub-write-token title: reuse-surface federation hub write bearer token @@ -236,6 +270,15 @@ entries: exec_capable: true resolvable: true lane: secret + rotation: + method: rotate + owner: railiance-platform + automatable: false + steps: + - "Generate a new reuse-surface federation hub write bearer token at reuse.coulomb.social; if the Forgejo webhook secret rotates too, regenerate REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET alongside it." + - "`bao kv put platform/workloads/reuse/reuse-surface/runtime-secrets REUSE_SURFACE_TOKEN=@file` (and the webhook field if changed)." + - "ESO re-syncs reuse/reuse-surface-runtime → reuse-surface-env on Railiance01; roll the consumer; update the Forgejo webhook config if the secret changed; revoke the old token." + - "Verify capabilities-safe on the data path (`bao token capabilities`)." - id: openrouter-llm-connect title: OpenRouter API key for llm-connect in activity-core @@ -260,6 +303,15 @@ entries: policy_ref: "flex-auth check secret.read:llm-connect" exec_capable: true lane: secret + rotation: + method: rotate + owner: railiance-platform + automatable: false + steps: + - "Create a new key in the OpenRouter dashboard for the llm-connect identity; keep the old key active until rollout completes." + - "`bao kv put platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets OPENROUTER_API_KEY=@file` (value from a mode-0600 file)." + - "ESO re-syncs activity-core/llm-connect-provider-secrets; roll out llm-connect on the new value; then delete the old OpenRouter key." + - "Verify capabilities-safe on the data path (`bao token capabilities`)." - id: railiance-backup-offsite-lane title: Railiance offsite backup Nextcloud WebDAV credentials @@ -278,10 +330,24 @@ entries: fetch_command: "bao kv get -field= platform/workloads/railiance/backup/offsite-lane" exec_capable: true lane: secret + # Mixed lane: NC_WEBDAV_TOKEN rotates (provider re-mint); AGE_PRIVATE_KEY + # re-establishes (new keypair + re-encrypt existing artifacts). Method reflects + # the more involved re-establish path. Marked exposed 2026-07-16 (see + # history/2026-07-16-credential-disclosure-lessons.md); rotation is the operator's + # optional call (buildup), not a blocker for promotion. + rotation: + method: re-establish + owner: railiance-platform + automatable: false + steps: + - "Rotate NC_WEBDAV_TOKEN: regenerate the Nextcloud WebDAV app password/token; `bao kv put platform/workloads/railiance/backup/offsite-lane NC_WEBDAV_TOKEN=@file`. NC_WEBDAV_URL changes only if the host/share moves." + - "Re-establish AGE_PRIVATE_KEY: generate a new keypair (`age-keygen`), decrypt existing offsite artifacts with the old key and re-encrypt to the new recipient, then `bao kv put ... AGE_PRIVATE_KEY=@file` (mode-0600 file, shred after)." + - "Because AGE_PRIVATE_KEY is recovery escrow, retain the old key offline until re-encryption of all retained backups is confirmed." + - "Verify capabilities-safe on the data path (`bao token capabilities`); run a restore drill against a re-encrypted artifact." - id: forgejo-admin-api-token title: Forgejo operator/admin API token (PAT) - need_keywords: [forgejo, admin, pat, package, prune, FORGEJO_ADMIN_TOKEN, forgejo-package-prune, forgejo-tegwick, webhook, npm] + need_keywords: [forgejo, admin, pat, package, prune, FORGEJO_ADMIN_TOKEN, forgejo-package-prune, forgejo-tegwick, webhook, forgejo-npm] owner_repo: railiance-platform subsystem: OpenBao + Forgejo warden_executes: false @@ -303,6 +369,15 @@ entries: fetch_command: "bao kv get -field=API_TOKEN platform/workloads/forgejo/forgejo-admin" exec_capable: true lane: secret + rotation: + method: rotate + owner: railiance-platform + automatable: false + steps: + - "As Forgejo user tegwick (site admin): Settings → Applications → generate a new token with the current scopes (read/write:package, read/write:repository, plus admin scopes for operator-bootstrap); keep the old token until cutover." + - "Store via the provisioning helper: `~/railiance-platform/scripts/forgejo-admin-pat-provision.sh ` writes field API_TOKEN to platform/workloads/forgejo/forgejo-admin; shred the input file." + - "Confirm the new PAT works (fetch --field API_TOKEN into env, call /api/v1/user — never print it), then delete the old token in Forgejo." + - "Verify capabilities-safe on the data path (`bao token capabilities`); notify consumers (package-prune, operator-bootstrap, npm-smoke, reuse-webhook) to re-fetch." # --- draft: owner path not yet shipped; hidden from default lookup --- diff --git a/src/warden/cli.py b/src/warden/cli.py index de52646..da75b53 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -630,6 +630,21 @@ def _entry_summary(entry) -> dict: "canon_ref": entry.canon_ref, "reviewed": entry.reviewed, "status": entry.status, + # Renewal guidance (WP-0026 T06) — advisory, no secret values. `has_rotation` + # lets a caller gate before asking for the full block via `warden rotate-guide`. + "has_rotation": entry.has_rotation, + **( + { + "rotation": { + "method": entry.rotation.method, + "owner": entry.rotation.owner, + "automatable": entry.rotation.automatable, + "steps": entry.rotation.steps, + } + } + if entry.has_rotation + else {} + ), } @@ -788,6 +803,63 @@ def route_show( ) +@app.command("rotate-guide") +def rotate_guide( + entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")], + output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, +) -> None: + """Show how to rotate or re-establish a lane's credential (WP-0026 T06). + + Advisory renewal guidance held in the ops-warden registry — never a secret + value, and ops-warden does not execute it (that is Strand B, WARDEN-WP-0027). + """ + catalog = _load_catalog() + entry = catalog.get(entry_id) + if entry is None: + err.print( + f"[red]Unknown routing id {entry_id!r}.[/red] Try: warden route find {entry_id!r}" + ) + raise typer.Exit(1) + + if not entry.has_rotation: + if output_json: + print(json.dumps({"id": entry.id, "has_rotation": False}, indent=2)) + else: + err.print( + f"[yellow]No rotation guidance for {entry.id!r}.[/yellow] " + + ( + "This is the SSH lane — renewal is re-issuance (`warden sign`)." + if entry.warden_executes + else "Add a `rotation:` block to the catalog entry (WP-0026 T06)." + ) + ) + raise typer.Exit(0 if entry.warden_executes else 1) + + rot = entry.rotation + if output_json: + print(json.dumps( + { + "id": entry.id, + "method": rot.method, + "owner": rot.owner, + "automatable": rot.automatable, + "steps": rot.steps, + }, + indent=2, + )) + return + + console.print(f"[bold]Rotation guidance — {entry.title}[/bold] ([cyan]{entry.id}[/cyan])") + console.print(f" method : {rot.method} owner: {rot.owner} automatable: {rot.automatable}") + console.print(" steps:") + for i, step in enumerate(rot.steps, 1): + console.print(f" {i}. {step}") + console.print( + "\n[dim]Advisory only — ops-warden holds no value and does not execute this " + "(one-command rotation is Strand B, WARDEN-WP-0027).[/dim]" + ) + + @route_app.command("find") def route_find( query: Annotated[str, typer.Argument(help="Free-text need, e.g. 'issue core api key'")], diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index d44f16d..fde5313 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -22,7 +22,7 @@ from typing import List, Optional import yaml -from warden.routing.models import RouteEntry +from warden.routing.models import RotationGuide, RouteEntry # Structured handoff string fields (WP-0014) — templates and pointers only. # Every one is scanned for accidental secret material; see _assert_no_secret_material. @@ -60,6 +60,7 @@ _REQUIRED_FIELDS = ( ) _VALID_STATUS = ("active", "draft") _VALID_LANES = ("secret", "login") +_VALID_ROTATION_METHODS = ("rotate", "re-establish") # Default review cadence — see wiki/AccessRouting.md#drift-review-cadence DEFAULT_STALE_DAYS = 90 @@ -161,7 +162,9 @@ class Catalog: ] -def _assert_no_secret_material(entry_id: str, field_name: str, value: str) -> None: +def _assert_no_secret_material( + entry_id: str, field_name: str, value: str, *, prose: bool = False +) -> None: """Reject a handoff field that appears to embed a literal secret value. The structured handoff fields are command/path *templates*: concrete values @@ -169,15 +172,22 @@ def _assert_no_secret_material(entry_id: str, field_name: str, value: str) -> No catalog is git-tracked and agent-visible, so a leaked value here is the exact custody failure WP-0014 forbids. We screen for known token prefixes and for a long high-entropy run that is not a placeholder. + + ``prose=True`` (rotation guidance steps, WP-0026 T06) skips the *substring* + prefix screen — short prefixes like ``s.`` or ``eyJ`` collide with ordinary + English ("exists.", "artifacts.") — and relies on the high-entropy-run detector, + which catches an actually-pasted token (a real ``hvs.``/``ghp_``/``sk-`` value + carries a long high-entropy tail) while allowing plain sentences. """ lowered = value.lower() - for prefix in _SECRET_PREFIXES: - if prefix.lower() in lowered: - raise CatalogError( - f"entry {entry_id!r} field {field_name!r} appears to contain a literal " - f"secret (matched {prefix!r}). Handoff fields are templates — use " - "placeholders like /, never a real value." - ) + if not prose: + for prefix in _SECRET_PREFIXES: + if prefix.lower() in lowered: + raise CatalogError( + f"entry {entry_id!r} field {field_name!r} appears to contain a literal " + f"secret (matched {prefix!r}). Handoff fields are templates — use " + "placeholders like /, never a real value." + ) for run in _HIGH_ENTROPY_RUN.findall(value): # Allow long placeholder/path/identifier tokens; flag anything else. if "<" in run or ">" in run: @@ -190,6 +200,49 @@ def _assert_no_secret_material(entry_id: str, field_name: str, value: str) -> No ) +def _parse_rotation(entry_id: str, raw: Optional[dict]) -> Optional[RotationGuide]: + """Parse and validate an optional ``rotation:`` block (WP-0026 T06). + + Advisory renewal guidance only — screened for secret material like every other + catalog string. ``method`` must be rotate | re-establish; ``steps`` a non-empty + list; ``owner`` required. + """ + if raw is None: + return None + if not isinstance(raw, dict): + raise CatalogError(f"entry {entry_id!r} `rotation` must be a mapping") + + method = str(raw.get("method", "")).strip() + if method not in _VALID_ROTATION_METHODS: + raise CatalogError( + f"entry {entry_id!r} rotation.method {method!r} invalid " + f"(expected one of {_VALID_ROTATION_METHODS})" + ) + + steps_raw = raw.get("steps") + if not isinstance(steps_raw, list) or not steps_raw: + raise CatalogError( + f"entry {entry_id!r} rotation.steps must be a non-empty list of steps" + ) + steps = [str(s) for s in steps_raw] + + owner = str(raw.get("owner", "")).strip() + if not owner: + raise CatalogError(f"entry {entry_id!r} rotation.owner is required") + + # Screen advisory prose for accidental secret material (git-tracked, agent-visible). + for i, step in enumerate(steps): + _assert_no_secret_material(entry_id, f"rotation.steps[{i}]", step, prose=True) + _assert_no_secret_material(entry_id, "rotation.owner", owner, prose=True) + + return RotationGuide( + method=method, + steps=steps, + owner=owner, + automatable=bool(raw.get("automatable", False)), + ) + + def _parse_entry(raw: dict, index: int) -> RouteEntry: if not isinstance(raw, dict): raise CatalogError(f"entry #{index} is not a mapping") @@ -272,6 +325,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: exec_owner=str(raw["exec_owner"]) if raw.get("exec_owner") else None, exec_command=handoff["exec_command"], pointer_command=handoff["pointer_command"], + rotation=_parse_rotation(entry_id, raw.get("rotation")), ) diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index 54f216f..f87a8d6 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -11,6 +11,23 @@ from dataclasses import dataclass, field from typing import List, Optional +@dataclass +class RotationGuide: + """Structured-but-advisory renewal guidance for a lane (WARDEN-WP-0026 T06). + + Held in the ops-warden registry, never in OpenBao. ``steps`` are authored + advisory prose (screened for secret material like every catalog string) — they + tell an operator *how* to renew, they are not executed here. ``method`` is + ``rotate`` (provider re-mints the same kind of credential) or ``re-establish`` + (regenerate from source, e.g. a new age keypair + re-encrypt). ``automatable`` + is a hint for a future Strand-B executable driver (WARDEN-WP-0027). + """ + method: str # "rotate" | "re-establish" + steps: List[str] + owner: str + automatable: bool = False + + @dataclass class RouteEntry: id: str @@ -50,11 +67,31 @@ class RouteEntry: exec_owner: Optional[str] = None # subsystem owning the native exec (e.g. secrets-engine) exec_command: Optional[str] = None # e.g. "secrets-engine exec --catalog -- " pointer_command: Optional[str] = None # e.g. "secrets-engine route --json" + # Rotation / re-establishment guidance (WP-0026 T06) — advisory, no secret values. + rotation: Optional[RotationGuide] = None @property def is_active(self) -> bool: return self.status == "active" + @property + def has_rotation(self) -> bool: + """True when this lane carries renewal guidance (WP-0026 T06).""" + return self.rotation is not None + + @property + def vends_secret(self) -> bool: + """True when this lane hands back a rotatable static secret value. + + Rotation guidance (WP-0026 T06) applies to these. It excludes the SSH lane + (short-lived certs — renewal is re-issuance), ``login`` lanes (re-auth, no + stored value), and pure routing pointers with no secret path (tunnel, + principals, emission sinks, policy checks). + """ + if self.warden_executes or self.lane != "secret": + return False + return bool(self.path_template or self.fetch_command or self.exec_owner) + @property def has_native_exec(self) -> bool: """True when an owner-native exec front door is the primary path for this lane.""" diff --git a/src/warden/scorecard.py b/src/warden/scorecard.py index e479580..f5a47ee 100644 --- a/src/warden/scorecard.py +++ b/src/warden/scorecard.py @@ -152,6 +152,43 @@ def check_file_permissions(state_dir: Path) -> CheckResult: ) +def check_catalog_rotation_coverage() -> CheckResult: + """Every active secret-vending catalog lane must carry rotation guidance (T06). + + A lane an operator can obtain a *secret value* through must also tell them how + to renew or re-establish it. Scoped to ``vends_secret`` lanes: this exempts the + SSH lane (short-lived certs — renewal is re-issuance), ``login`` lanes (re-auth, + no stored value), and pure routing pointers (tunnel, principals, emission + sinks, policy checks) with no secret to rotate. Draft lanes are exempt until + promoted. + """ + try: + from warden.routing import load_catalog + catalog = load_catalog() + except Exception as e: # noqa: BLE001 — catalog missing/invalid is its own signal + return CheckResult( + name="catalog_rotation_coverage", + passed=False, + detail=f"could not load routing catalog: {e}", + ) + + missing = [ + e.id + for e in catalog.entries + if e.is_active and e.vends_secret and not e.has_rotation + ] + return CheckResult( + name="catalog_rotation_coverage", + passed=len(missing) == 0, + detail=( + f"active lanes lacking rotation guidance: {missing} — add a `rotation:` " + "block (see WARDEN-WP-0026 T06)" + if missing + else "all active lanes carry rotation guidance" + ), + ) + + def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[CheckResult]: """Run all cert-side scorecard checks. Returns list of CheckResult.""" return [ @@ -161,4 +198,5 @@ def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[Check check_no_stale_certs(state_dir), check_ttl_policy(state_dir, inventory), check_file_permissions(state_dir), + check_catalog_rotation_coverage(), ] diff --git a/tests/test_routing.py b/tests/test_routing.py index 9b96520..76fcc10 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -448,3 +448,86 @@ def test_every_entry_has_reviewed_date(): assert re.match(r"^\d{4}-\d{2}-\d{2}$", entry.reviewed), ( f"{entry.id}: reviewed must be YYYY-MM-DD, got {entry.reviewed!r}" ) + + +# --------------------------------------------------------------------------- +# Rotation / re-establishment guidance registry (WARDEN-WP-0026 T06) +# --------------------------------------------------------------------------- + +from warden.scorecard import check_catalog_rotation_coverage + + +def test_every_active_vending_lane_has_rotation_guidance(): + """Coverage gate: an active lane that vends a secret must say how to renew it.""" + catalog = load_catalog(_repo_catalog()) + missing = [e.id for e in catalog.entries if e.is_active and e.vends_secret and not e.has_rotation] + assert not missing, f"active vending lanes lacking rotation guidance: {missing}" + + +def test_scorecard_rotation_coverage_check_passes_on_repo_catalog(): + result = check_catalog_rotation_coverage() + assert result.passed, result.detail + + +def test_non_vending_lanes_are_exempt_from_rotation(): + """SSH (issue), login, and pointer-only lanes carry no rotation block.""" + catalog = load_catalog(_repo_catalog()) + assert catalog.get("ssh-cert-host-access").vends_secret is False # issue lane + assert catalog.get("key-cape-oidc-login").vends_secret is False # login lane + assert catalog.get("ops-bridge-tunnel").vends_secret is False # pointer only + + +def test_rotation_block_parses_fields(): + catalog = load_catalog(_repo_catalog()) + rot = catalog.get("forgejo-admin-api-token").rotation + assert rot is not None + assert rot.method in ("rotate", "re-establish") + assert rot.owner == "railiance-platform" + assert rot.steps and all(isinstance(s, str) for s in rot.steps) + + +def test_re_establish_method_on_backup_lane(): + catalog = load_catalog(_repo_catalog()) + rot = catalog.get("railiance-backup-offsite-lane").rotation + assert rot is not None and rot.method == "re-establish" + + +def test_invalid_rotation_method_rejected(tmp_path): + entry = dict(ROUTED_ENTRY, rotation={"method": "renew", "owner": "x", "steps": ["a"]}) + with pytest.raises(CatalogError, match="rotation.method"): + load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, entry])) + + +def test_rotation_steps_screened_for_pasted_token(tmp_path): + """A high-entropy pasted token in prose is rejected; ordinary prose is allowed.""" + leak = dict(ROUTED_ENTRY, rotation={ + "method": "rotate", "owner": "x", + "steps": ["set the value to ghp_" + "aB3dE5" * 6], # mixed alnum → high-entropy run + }) + with pytest.raises(CatalogError, match="high-entropy|secret"): + load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, leak])) + + +def test_rotation_prose_allows_ordinary_sentences(tmp_path): + """Words like 'exists.' must not trip the terse 's.' prefix screen.""" + ok = dict(ROUTED_ENTRY, rotation={ + "method": "rotate", "owner": "railiance-platform", + "steps": ["Rotate per the concrete workload's entry when one exists."], + }) + catalog = load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, ok])) + assert catalog.get("openbao-api-key").rotation.steps + + +def test_rotate_guide_cli_json(): + result = runner.invoke(app, ["rotate-guide", "forgejo-admin-api-token", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["method"] == "rotate" + assert payload["owner"] == "railiance-platform" + assert payload["steps"] + + +def test_rotate_guide_cli_ssh_lane_is_graceful(): + # SSH renewal is re-issuance, not a static rotation — exit 0, not an error. + result = runner.invoke(app, ["rotate-guide", "ssh-cert-host-access"]) + assert result.exit_code == 0 diff --git a/tests/test_scorecard.py b/tests/test_scorecard.py index 1b0fb06..9fcfe19 100644 --- a/tests/test_scorecard.py +++ b/tests/test_scorecard.py @@ -101,7 +101,8 @@ def test_run_scorecard_clean(tmp_path): ) results = run_scorecard(tmp_path, inv) assert all(r.passed for r in results) - assert len(results) == 6 + # cert-side checks + catalog_rotation_coverage (WP-0026 T06) + assert len(results) == 7 # --------------------------------------------------------------------------- diff --git a/wiki/playbooks/catalog-lane-promotion.md b/wiki/playbooks/catalog-lane-promotion.md index 36ebcad..4e71d3f 100644 --- a/wiki/playbooks/catalog-lane-promotion.md +++ b/wiki/playbooks/catalog-lane-promotion.md @@ -23,6 +23,7 @@ Before changing `status: draft` → `status: active`: | 6 | **Tests** | Routing test or smoke proving lookup + handoff shape (no secret values in fixtures) | | 7 | **Review date** | Update `reviewed:` in catalog entry | | 8 | **Verification** | Positive + negative proof via **`bao token capabilities`** — never `bao kv get` (see below) | +| 9 | **Rotation guidance** | Secret-vending lanes carry a `rotation:` block; `warden rotate-guide ` returns steps. Enforced by the `catalog_rotation_coverage` scorecard check (WP-0026 T06) | Promotion PR touches: `registry/routing/catalog.yaml`, playbook, optional `tests/test_routing.py`, and a one-line note in `wiki/CredentialRouting.md` draft table. diff --git a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md index a3aba41..cbe74de 100644 --- a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md +++ b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md @@ -158,11 +158,23 @@ a lane (advisory; no auto-rotation here). ```task id: WARDEN-WP-0026-T06 -status: todo +status: done priority: high state_hub_task_id: "b8a43aee-18c7-46cd-9cfe-1f73234746d3" ``` +Done 2026-07-16: `rotation:` block (method rotate|re-establish, ordered steps, +owner, automatable) added to the routing model/parser (`RotationGuide`, +`RouteEntry.rotation`, `vends_secret`), screened for secret material in a prose-safe +mode. `warden rotate-guide ` (human + `--json`) surfaces the guidance; `warden +route show --json` carries `has_rotation` + `rotation`. Coverage enforced by the new +`catalog_rotation_coverage` scorecard check (every active secret-vending lane must +have a block) and promotion checklist criterion 9. Rotation blocks authored for all +7 active vending lanes + the draft railiance-backup lane (re-establish example: age +keypair regen + re-encrypt). Tests in `tests/test_routing.py`. Also fixed a +pre-existing keyword collision (bare `npm` on the forgejo-admin lane → `forgejo-npm`) +so "npm token" routes to the generic lane again. + Give every catalog lane **structured-but-advisory** renewal guidance, held in the ops-warden registry (not in OpenBao). Add a `rotation:` block per catalog entry capturing: `method` (rotate | re-establish), ordered `steps` (provider re-mint /