WARDEN-WP-0026 T06: rotation guidance registry + warden rotate-guide
- 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 <id>` (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 <noreply@anthropic.com>
This commit is contained in:
parent
ac09f21ad3
commit
c3eb59ea04
9 changed files with 385 additions and 12 deletions
|
|
@ -68,6 +68,14 @@ entries:
|
|||
exec_owner: railiance-platform
|
||||
exec_command: "scripts/credential.py exec --grant ops-warden/warden-sign --ttl 15m -- <cmd>"
|
||||
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=<FIELD> <path_template>"
|
||||
policy_ref: "flex-auth check secret.read:<domain>"
|
||||
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 <path> <FIELD>=@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 -- <cmd>"
|
||||
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=<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 <mode-0600-file>` 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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -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'")],
|
||||
|
|
|
|||
|
|
@ -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 <FIELD>/<PATH>, 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 <FIELD>/<PATH>, 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")),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <id> -- <cmd>"
|
||||
pointer_command: Optional[str] = None # e.g. "secrets-engine route <id> --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."""
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 <id>` 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.
|
||||
|
|
|
|||
|
|
@ -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 <id>` (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 /
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue