diff --git a/Makefile b/Makefile index b84efcc..05b31b9 100644 --- a/Makefile +++ b/Makefile @@ -203,6 +203,12 @@ service-identity-test: ## Run service-identity schema/validator tests (NK-WP-005 service-identity-check: ## Evaluate the checked-in service identity declarations (read-only, offline) python3 tools/service-identity/validate.py tools/service-identity/declarations/*.yaml +rotation-lanes-test: ## Run rotation-lane register tests (NK-WP-0055-T15) + python3 -m pytest tools/rotation-lanes/tests + +rotation-lanes-check: ## Check every known credential has a rotation lane (read-only, offline) + python3 tools/rotation-lanes/check.py + security-tools: ## List the security tools, what each answers, and whether an agent may run it python3 tools/security_tools.py @@ -368,7 +374,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a --host "$(SECURITY_BOOTSTRAP_HOST)" \ --port "$(SECURITY_BOOTSTRAP_PORT)" -.PHONY: runbooks-validate help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \ +.PHONY: runbooks-validate rotation-lanes-test rotation-lanes-check help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \ check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \ creds-status creds-rotate \ creds-agent-init creds-agent-status creds-emergency-reprint \ diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 887c85a..2e58e7c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -332,7 +332,7 @@ | task | NK-WP-0055-T12 | todo | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T13 | done | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T14 | todo | — | workplans/NK-WP-0055-security-platform-production-readiness.md | -| task | NK-WP-0055-T15 | todo | — | workplans/NK-WP-0055-security-platform-production-readiness.md | +| task | NK-WP-0055-T15 | done | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T16 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NK-IN-0002 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | diff --git a/tools/rotation-lanes/README.md b/tools/rotation-lanes/README.md new file mode 100644 index 0000000..a552bcb --- /dev/null +++ b/tools/rotation-lanes/README.md @@ -0,0 +1,26 @@ +# Credential Rotation Lanes + +NK-WP-0055-T15. `lanes.yaml` lists every credential lane: how the credential is replaced, its +blast radius, the step that proves the rotation worked, and the owner lane (workplan task) that +holds it. It is value-free: names, paths and procedures only. + +```bash +make rotation-lanes-check # every known credential has a complete lane (offline, read-only) +make rotation-lanes-test # synthetic tests +``` + +The check derives "every known credential" from three repository sources and fails on a gap: + +| Source | What must have a lane | +| --- | --- | +| `tools/service-identity/declarations/*.yaml` | Each identity whose custody is not `none` (federated logins hold nothing to rotate; they may still be named) | +| `canon/standards/credential-management_v0.2.md` section 6 | Each backticked secret name in its tables | +| `docs/crisis-custody-list.md` | Each numbered item that is not struck through as retired | + +It also refuses a lane without blast radius, verification or owner lane, an unknown NetKingdom +task reference, and a credential assigned to two lanes. A new declaration, secret or crisis item +therefore needs a lane in the same change. + +`state` says how far each lane is: `admitted` (owner lane accepted and runnable), `designed` +(procedure written, not admitted), `deferred` (held by a founder decision, today the rotation +deferral `1591daa1` behind NK-WP-0055-T06) or `none` (no rotation, reason given). diff --git a/tools/rotation-lanes/check.py b/tools/rotation-lanes/check.py new file mode 100644 index 0000000..e82d003 --- /dev/null +++ b/tools/rotation-lanes/check.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["PyYAML>=6,<7"] +# /// +"""Check that every known credential has a rotation lane (NK-WP-0055-T15). + +Sources of "known credentials", all in this repository: +- service-identity declarations whose custody is not `none` (federated logins hold nothing); +- the backticked secret names in Credential Management v0.2 section 6; +- the numbered rows of the crisis custody list that are not struck through (retired). + +Each lane must name its blast radius, verification step and owner lane. NetKingdom owner-lane +references must exist in `workplans/`; other repositories' references are checked for shape only. +Offline and read-only; the register holds names and procedures, never values. +""" + +from __future__ import annotations + +import argparse +import glob +import re +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +CLASSES = {"service", "database", "client-secret", "openbao-issued", "break-glass"} +STATES = {"admitted", "designed", "deferred", "none"} +LEVELS = {"minimal", "low", "medium", "high"} +TASK_REF = re.compile(r"^[A-Z][A-Z-]*-WP-\d{4}(-T\d{2})?$") + + +def declared_identities(root): + """(identities that hold a credential, every declared identity).""" + found, every = set(), set() + for path in sorted( + glob.glob(str(root / "tools/service-identity/declarations/*.yaml")) + ): + doc = yaml.safe_load(Path(path).read_text()) + for ident in doc.get("identities", []): + name = f"{doc['service']}/{ident['name']}" + every.add(name) + if set(ident.get("custody") or []) - {"none"}: + found.add(name) + return found, every + + +def standard_secrets(root): + text = (root / "canon/standards/credential-management_v0.2.md").read_text() + section = text.split("## 6. Secret Rotation", 1)[1].split("\n## ", 1)[0] + return set(re.findall(r"^\| `([A-Z][A-Z0-9_]+)` \|", section, flags=re.MULTILINE)) + + +def crisis_items(root): + text = (root / "docs/crisis-custody-list.md").read_text() + return { + m.group(1) + for m in re.finditer(r"^\| (\d+[a-z]?) \| (?!~~)", text, flags=re.MULTILINE) + } + + +def nk_tasks(root): + ids = set() + for path in glob.glob(str(root / "workplans/**/*.md"), recursive=True): + ids.update( + re.findall( + r"^id: (NK-WP-\d{4}(?:-T\d{2})?)$", + Path(path).read_text(), + flags=re.MULTILINE, + ) + ) + return ids + + +def check(register, root): + errors = [] + lanes = register.get("lanes", []) + seen = {"identities": {}, "credentials": {}, "crisis_items": {}} + known_nk = nk_tasks(root) + for lane in lanes: + lid = lane.get("id", "?") + if lane.get("class") not in CLASSES: + errors.append(f"{lid}: class must be one of {sorted(CLASSES)}") + if lane.get("state") not in STATES: + errors.append(f"{lid}: state must be one of {sorted(STATES)}") + radius = lane.get("blast_radius") or {} + if ( + radius.get("level") not in LEVELS + or not radius.get("affects") + or not radius.get("effect") + ): + errors.append(f"{lid}: blast_radius needs level, affects and effect") + for field in ("path", "verification"): + if not str(lane.get(field) or "").strip(): + errors.append(f"{lid}: {field} is empty") + refs = lane.get("owner_lane") or [] + if not refs: + errors.append(f"{lid}: owner_lane is empty") + for ref in refs: + if not TASK_REF.match(ref): + errors.append(f"{lid}: owner_lane {ref!r} is not a workplan or task id") + elif ref.startswith("NK-WP-") and ref not in known_nk: + errors.append(f"{lid}: owner_lane {ref} not found in workplans/") + for key, owners in seen.items(): + for item in lane.get(key) or []: + item = str(item) + if item in owners: + errors.append(f"{lid}: {key} {item} already in lane {owners[item]}") + owners[item] = lid + holding, declared = declared_identities(root) + secrets, items = standard_secrets(root), crisis_items(root) + for kind, required, allowed in ( + ("identities", holding, declared), + ("credentials", secrets, secrets), + ("crisis_items", items, items), + ): + for missing in sorted(required - set(seen[kind])): + errors.append( + f"no lane for {kind[:-1] if kind != 'identities' else 'identity'} {missing}" + ) + for extra in sorted(set(seen[kind]) - allowed): + errors.append( + f"lane {seen[kind][extra]} names unknown {kind} entry {extra}" + ) + return lanes, errors + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "register", nargs="?", default=str(ROOT / "tools/rotation-lanes/lanes.yaml") + ) + parser.add_argument("--root", default=str(ROOT)) + args = parser.parse_args(argv) + lanes, errors = check( + yaml.safe_load(Path(args.register).read_text()), Path(args.root) + ) + by_state = {} + for lane in lanes: + by_state[lane.get("state")] = by_state.get(lane.get("state"), 0) + 1 + for error in errors: + print(f"ERROR: {error}") + print( + f"rotation lanes: {len(lanes)} ({', '.join(f'{n} {s}' for s, n in sorted(by_state.items()))}); " + f"{len(errors)} error(s)" + ) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/rotation-lanes/lanes.yaml b/tools/rotation-lanes/lanes.yaml new file mode 100644 index 0000000..c3b6be0 --- /dev/null +++ b/tools/rotation-lanes/lanes.yaml @@ -0,0 +1,241 @@ +# Credential rotation lanes (NK-WP-0055-T15). Value-free: names, paths and procedures only. +# +# One lane is one way a credential (or a family of credentials rotated the same way) is +# replaced. `check.py` fails if a credential named in the service-identity declarations, +# Credential Management v0.2 section 6 or the crisis custody list has no lane here. +# +# state: admitted (owner lane accepted and runnable) | designed (procedure written, not admitted) +# | deferred (a founder decision holds it) | none (no rotation; reason given) +# blast_radius.level: minimal | low | medium | high +schema: netkingdom.rotation-lanes.v0.1 +reviewed: "2026-10-28" +lanes: + # --- service credentials --------------------------------------------------------------- + - id: lldap-directory-bind + class: service + credentials: [LLDAP_LDAP_USER_PASS] + identities: [lldap/admin] + path: "Three-way coordinated change: LLDAP admin password, Authelia and KeyCape bind config, then restart both (Credential Management section 6)" + blast_radius: {level: high, affects: [lldap, authelia, keycape], effect: "SSO and token issuance fail until both consumers carry the new password"} + verification: "Authelia login and a KeyCape authorization-code flow succeed; an LDAP bind with the old password is refused" + owner_lane: [NK-WP-0054-T05, NK-WP-0055-T06] + state: deferred + note: "Bind split (svc-* accounts) replaces this three-way lane; rotation deferral 1591daa1 holds it" + - id: lldap-service-binds + class: service + credentials: [] + identities: [lldap/svc-authelia, lldap/svc-keycape, lldap/svc-privacyidea, lldap/svc-provisioner] + path: "Per consumer: new bind password in LLDAP, delivered Secret updated, that consumer alone restarted" + blast_radius: {level: low, affects: [one consumer], effect: "Only the rotated consumer loses directory access"} + verification: "The consumer's own login or resolver check passes; the old password's bind is refused" + owner_lane: [NK-WP-0054-T05] + state: designed + note: "Accounts are planned; the lane becomes real with the bind split" + - id: lldap-super-admin + class: service + credentials: [] + identities: [lldap/ld-admin] + path: "To be decided with the account (NK-WP-0054-T06)" + blast_radius: {level: high, affects: [lldap], effect: "Directory administration"} + verification: "Founder login to the LLDAP UI with the new password; old password refused" + owner_lane: [NK-WP-0054-T06] + state: designed + - id: identity-provisioner-token + class: service + credentials: [] + identities: [identity-provisioner/identity-provisioner-token] + path: "Owner rotation of Kubernetes Secret identity-provisioner-token, user-engine and provisioner restarted together" + blast_radius: {level: low, affects: [user-engine, identity-provisioner], effect: "User onboarding pauses during the swap"} + verification: "A synthetic onboarding call from user-engine is accepted; a call with the old token is refused" + owner_lane: [NK-WP-0055-T06] + state: deferred + - id: session-and-signing-secrets + class: service + credentials: [PI_SECRET_KEY, LLDAP_JWT_SECRET, AUTHELIA_SESSION_SECRET] + identities: [] + path: "Replace the value in its Secret and restart the one service (Credential Management section 6)" + blast_radius: {level: low, affects: [privacyidea, lldap, authelia], effect: "Existing sessions of that service end; users sign in again"} + verification: "Fresh login to the service succeeds after restart" + owner_lane: [NK-WP-0055-T06] + state: deferred + - id: keycape-signing-key + class: service + credentials: [KEYCAPE_RSA_KEY] + identities: [] + path: "New signing key published in JWKS, then switched; KeyCape has a single key kid=key-1 and no rotation state today" + blast_radius: {level: high, affects: [keycape, every relying party], effect: "All issued tokens become invalid; brief authentication outage"} + verification: "JWKS serves the new kid; a fresh token validates at OpenBao and access-engine; a token signed by the old key is refused" + owner_lane: [NK-WP-0055-T06] + state: deferred + note: "Overlap rotation (two keys in JWKS) is not implemented; until it is, rotation is an outage window" + - id: privacyidea-pepper + class: service + credentials: [PI_PEPPER] + identities: [] + path: "No rotation: re-hashing every privacyIDEA user password would be required" + blast_radius: {level: high, affects: [privacyidea], effect: "All privacyIDEA-local passwords"} + verification: "Not applicable; custody is proven by the restore drill decrypting token material" + owner_lane: [NK-WP-0053-T02] + state: none + - id: privacyidea-resolver-bind + class: service + credentials: [] + identities: [privacyidea/keycape-factor-reader] + path: "ESO renews the reader token under policy keycape-factor-renewer" + blast_radius: {level: low, affects: [keycape], effect: "Factor checks fail closed if renewal stops"} + verification: "KeyCape MFA step passes after renewal; ESO status Ready with a fresh refresh time" + owner_lane: [RPF-WP-0035] + state: admitted + - id: privacyidea-legacy-trigger-admin + class: service + credentials: [] + identities: [privacyidea/trigger-admin] + path: "Revoke rather than rotate; revocation needs its own approval" + blast_radius: {level: medium, affects: [privacyidea], effect: "Whatever still triggers challenges with it"} + verification: "Token listed as revoked in privacyIDEA; no consumer reports failures" + owner_lane: [NK-WP-0054-T06] + state: none + # --- database credentials -------------------------------------------------------------- + - id: privacyidea-database + class: database + credentials: [PI_DB_PASSWORD] + identities: [postgresql/privacyidea] + path: "Atomic update of the PostgreSQL role password and the privacyIDEA pod Secret (Credential Management section 6)" + blast_radius: {level: medium, affects: [privacyidea, postgresql], effect: "MFA unavailable between the two changes"} + verification: "privacyIDEA health and one OTP validation succeed; a connection with the old password is refused" + owner_lane: [NK-WP-0055-T06] + state: deferred + # --- client secrets -------------------------------------------------------------------- + - id: authelia-keycape-client + class: client-secret + credentials: [AUTHELIA_KEYCAPE_CLIENT_SECRET] + identities: [authelia/keycape] + path: "Two-way atomic: Authelia verifier and KeyCape upstream secret together (Credential Management section 6)" + blast_radius: {level: medium, affects: [authelia, keycape], effect: "KeyCape cannot complete upstream login until both carry the new secret"} + verification: "A KeyCape login through Authelia succeeds; the old secret is refused at Authelia's token endpoint" + owner_lane: [NK-WP-0055-T06] + state: deferred + - id: keycape-registered-clients + class: client-secret + credentials: [] + identities: [keycape/secrets-engine-openbao, keycape/secrets-engine-approval, keycape/secrets-engine-requester, + keycape/approval-engine-operator, keycape/informed-decision-sitting-requester, keycape/rapp-qonto] + path: "Mint in custody, then deliver through the credential broker; KeyCape and the consumer read it at start" + blast_radius: {level: low, affects: [keycape, the one consuming service], effect: "Only that client's token requests fail during the swap"} + verification: "client_credentials token issued for the consumer with its registered scopes; old secret refused" + owner_lane: [RPF-WP-0035, RPF-WP-0055-T01] + state: designed + note: "First-generation custody admitted in RPF-WP-0035; coordinated rotation is RPF-WP-0055-T01 (backlog)" + # --- OpenBao-issued credentials -------------------------------------------------------- + - id: openbao-federated-roles + class: openbao-issued + credentials: [] + identities: [openbao/netkingdom-platform-admin, openbao/credential-broker-keycape-client-secret-generate, + openbao/fluid-telegram-operators, openbao/secrets-engine-openbao] + path: "Nothing to rotate: tokens are short-lived from OIDC/JWT login; role and policy change by reviewed change" + blast_radius: {level: low, affects: [openbao], effect: "A role change affects only new logins"} + verification: "Role readback matches the committed role file (service-identity metadata exporter, RPF-WP-0067)" + owner_lane: [RPF-WP-0035] + state: admitted + - id: openbao-approle-secret-ids + class: openbao-issued + credentials: [] + identities: [openbao/coding-agent] + path: "AppRole secret-id reissued by railiance-platform; JWT role is federated" + blast_radius: {level: low, affects: [coding agent], effect: "Agent loses OpenBao access until the new secret-id is delivered"} + verification: "Login with the new secret-id succeeds; the old secret-id accessor is destroyed" + owner_lane: [RPF-WP-0035] + state: designed + - id: openbao-workload-secrets + class: openbao-issued + credentials: [] + identities: [] + path: "KV values replaced through the platform operator-write CCR lane; ESO delivers; prefer leases where the engine supports them" + blast_radius: {level: low, affects: [the one workload], effect: "That workload until ESO refreshes"} + verification: "ExternalSecret Ready with a fresh sync; workload health passes" + owner_lane: [RPF-WP-0035] + state: admitted + - id: audit-core-sender-bearers + class: openbao-issued + credentials: [] + identities: [] + path: "Per-sender bearer in platform/workloads/audit-core/senders, delivered by ESO as a mounted file" + blast_radius: {level: low, affects: [one audit sender], effect: "That sender's events queue until the new bearer is delivered"} + verification: "Sender's next POST /v1/events is accepted; audit-core refuses the old bearer" + owner_lane: [RPF-WP-0035] + state: admitted + # --- break-glass material -------------------------------------------------------------- + - id: backup-age-key + class: break-glass + credentials: [] + crisis_items: ["1"] + path: "New age key, re-encrypt the backup stream and the break-glass copy, prove a restore with the new key before destroying the old one" + blast_radius: {level: high, affects: [identity backup, break-glass copy], effect: "Every past backup stays readable only with the old key"} + verification: "Isolated restore drill (NK-WP-0053) decrypts a generation made with the new key" + owner_lane: [NK-WP-0053-T02] + state: designed + - id: custodian-age-key + class: break-glass + credentials: [] + crisis_items: ["1b"] + path: "Credential Management section 6 'Age key rotation': new key, make sops-rotate, commit, deliver a new bundle before revoking the old key" + blast_radius: {level: high, affects: [SOPS custody files, bootstrap bundles], effect: "Repository custody files unreadable until re-encrypted"} + verification: "make sops-custody-check passes with the new key; make check-secrets passes" + owner_lane: [NK-WP-0055-T06] + state: deferred + - id: storage-share-backup-account + class: break-glass + credentials: [] + crisis_items: ["2"] + path: "Provider app-password reissue by the founder; old app password revoked at the provider" + blast_radius: {level: low, affects: [offsite retrieval], effect: "Retrieval of the backup ciphertext"} + verification: "Independent retrieval (retrieve-identity.py) downloads the newest generation with the new password" + owner_lane: [NK-WP-0055-T06] + state: deferred + - id: openbao-unseal-and-root + class: break-glass + credentials: [] + crisis_items: ["3"] + path: "Rekey of unseal shares by ceremony; the initial root token is revoked at production entry and later roots come from generate-root" + blast_radius: {level: high, affects: [openbao], effect: "OpenBao cannot be unsealed if the new shares are lost"} + verification: "Restore drill unseals with the new shares; root revoke receipt shows revoke-self 204 then lookup-self 403" + owner_lane: [NK-WP-0055-T16, NK-WP-0055-T06] + state: designed + - id: king-credential + class: break-glass + credentials: [BREAKGLASS_PASSWORD] + identities: [platform/platform-root] + crisis_items: ["4"] + path: "Explicit maintenance window; new password and factors into the independent password safe (Credential Management section 6)" + blast_radius: {level: minimal, affects: [break-glass login], effect: "None while unused"} + verification: "King-path drill passes with the new material" + owner_lane: [NK-WP-0055-T06] + state: deferred + - id: founder-admission-signing-key + class: break-glass + credentials: [] + crisis_items: ["5"] + path: "New ed25519 key, allowed_signers updated in the restore admission config, old key removed" + blast_radius: {level: medium, affects: [restore and drill admissions], effect: "No new restore attempt can be admitted until the new key is trusted"} + verification: "A drill admission signed with the new key is accepted; one signed with the old key is refused" + owner_lane: [NK-WP-0055-T06] + state: designed + - id: personal-admin-credentials + class: break-glass + credentials: [] + identities: [privacyidea/pi-admin, platform/tegwick] + crisis_items: ["6", "7"] + path: "Owner-managed personal credentials: password change in the service, OTP re-enrolment, password safe updated" + blast_radius: {level: low, affects: [the one person's access], effect: "Only that login"} + verification: "Login with the new credential succeeds; the crisis list's last-proven date is updated" + owner_lane: [NK-WP-0054-T07] + state: admitted + - id: operator-cluster-access + class: break-glass + credentials: [] + crisis_items: ["9"] + path: "Replace the static SSH key with a warden certificate lane and reissue the admin kubeconfig client certificate" + blast_radius: {level: medium, affects: [production host and cluster access], effect: "Founder loses direct production access if both lapse"} + verification: "bridge check k3s-api-railiance01 and a read-only kubectl get nodes succeed with the new material" + owner_lane: [NK-WP-0055-T06] + state: deferred diff --git a/tools/rotation-lanes/tests/test_check.py b/tools/rotation-lanes/tests/test_check.py new file mode 100644 index 0000000..000f014 --- /dev/null +++ b/tools/rotation-lanes/tests/test_check.py @@ -0,0 +1,118 @@ +"""Synthetic tests for the rotation-lane coverage check (NK-WP-0055-T15).""" + +import importlib.util +from pathlib import Path + +import yaml + +SOURCE = Path(__file__).parents[1] / "check.py" +spec = importlib.util.spec_from_file_location("rotation_lanes_check", SOURCE) +rl = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rl) + +STANDARD = "# CM\n\n## 6. Secret Rotation\n\n| Secret | Blast |\n|---|---|\n| `APP_SECRET` | Low |\n\n## 7. Next\n| `NOT_IN_SIX` | x |\n" +CRISIS = "| # | Item |\n|---|---|\n| 1 | **key** | a |\n| 2 | ~~retired~~ | b |\n" + + +def make_root(tmp_path): + (tmp_path / "tools/service-identity/declarations").mkdir(parents=True) + (tmp_path / "canon/standards").mkdir(parents=True) + (tmp_path / "docs").mkdir() + (tmp_path / "workplans").mkdir() + decl = { + "service": "svc", + "identities": [ + {"name": "bind", "custody": ["delivered-secret"]}, + {"name": "sso", "custody": ["none"]}, + ], + } + (tmp_path / "tools/service-identity/declarations/svc.yaml").write_text( + yaml.safe_dump(decl) + ) + (tmp_path / "canon/standards/credential-management_v0.2.md").write_text(STANDARD) + (tmp_path / "docs/crisis-custody-list.md").write_text(CRISIS) + (tmp_path / "workplans/NK-WP-0001-x.md").write_text("id: NK-WP-0001-T01\n") + return tmp_path + + +def lane(**over): + base = { + "id": "l1", + "class": "service", + "state": "designed", + "path": "swap it", + "verification": "old refused", + "owner_lane": ["NK-WP-0001-T01"], + "blast_radius": {"level": "low", "affects": ["svc"], "effect": "sessions end"}, + "identities": ["svc/bind"], + "credentials": ["APP_SECRET"], + "crisis_items": ["1"], + } + base.update(over) + return base + + +def test_complete_register_passes(tmp_path): + _, errors = rl.check({"lanes": [lane()]}, make_root(tmp_path)) + assert errors == [] + + +def test_missing_coverage_is_reported(tmp_path): + _, errors = rl.check( + {"lanes": [lane(identities=[], credentials=[], crisis_items=[])]}, + make_root(tmp_path), + ) + assert "no lane for identity svc/bind" in errors + assert "no lane for credential APP_SECRET" in errors + assert "no lane for crisis_item 1" in errors + + +def test_federated_identity_may_be_named_but_is_not_required(tmp_path): + _, errors = rl.check( + {"lanes": [lane(identities=["svc/bind", "svc/sso"])]}, make_root(tmp_path) + ) + assert errors == [] + + +def test_retired_crisis_row_and_other_sections_are_ignored(tmp_path): + _, errors = rl.check( + { + "lanes": [ + lane(crisis_items=["1", "2"], credentials=["APP_SECRET", "NOT_IN_SIX"]) + ] + }, + make_root(tmp_path), + ) + assert "lane l1 names unknown crisis_items entry 2" in errors + assert "lane l1 names unknown credentials entry NOT_IN_SIX" in errors + + +def test_lane_fields_and_owner_refs_are_enforced(tmp_path): + bad = lane( + state="maybe", + verification="", + owner_lane=["NK-WP-0099-T01", "bogus"], + blast_radius={"level": "huge"}, + ) + _, errors = rl.check({"lanes": [bad]}, make_root(tmp_path)) + assert any("state must be" in e for e in errors) + assert "l1: verification is empty" in errors + assert any("blast_radius needs" in e for e in errors) + assert "l1: owner_lane NK-WP-0099-T01 not found in workplans/" in errors + assert any("'bogus' is not a workplan" in e for e in errors) + + +def test_duplicate_assignment_is_reported(tmp_path): + _, errors = rl.check( + {"lanes": [lane(), lane(id="l2", credentials=[], crisis_items=[])]}, + make_root(tmp_path), + ) + assert "l2: identities svc/bind already in lane l1" in errors + + +def test_checked_in_register_is_complete(): + root = Path(__file__).parents[3] + _, errors = rl.check( + yaml.safe_load((root / "tools/rotation-lanes/lanes.yaml").read_text()), root + ) + assert errors == [] diff --git a/tools/security-tools.json b/tools/security-tools.json index 096aded..8e4b710 100644 --- a/tools/security-tools.json +++ b/tools/security-tools.json @@ -20,6 +20,9 @@ {"id": "service-identity", "path": "tools/service-identity", "answers": "Do the declared service accounts conform to the identity and privilege standard?", "run": "make service-identity-check", "test": "make service-identity-test", "agent_may_run": "yes", "standard": "canon/standards/service-identity-privilege_v0.1.md"}, + {"id": "rotation-lanes", "path": "tools/rotation-lanes", "answers": "Does every known credential have a rotation lane with blast radius, verification step and owner?", + "run": "make rotation-lanes-check", "test": "make rotation-lanes-test", "agent_may_run": "yes", + "standard": "canon/standards/credential-management_v0.2.md"}, {"id": "custody-record", "path": "tools/custody-record", "answers": "Is a platform-root custody record valid, and may a root grant be observed from it?", "run": "uv run tools/custody-record/validate.py ", "test": "make custody-record-test", "agent_may_run": "yes", "standard": "docs/adr/ADR-0017-root-grant-custody-and-account-authority.md"}, diff --git a/workplans/NK-WP-0055-security-platform-production-readiness.md b/workplans/NK-WP-0055-security-platform-production-readiness.md index b09fc2d..6efd544 100644 --- a/workplans/NK-WP-0055-security-platform-production-readiness.md +++ b/workplans/NK-WP-0055-security-platform-production-readiness.md @@ -355,7 +355,7 @@ envelope) are delivered or accepted as exceptions. ```task id: NK-WP-0055-T15 -status: todo +status: done priority: medium assignee: claude-code state_hub_task_id: "d9c0584e-10a1-5edf-a1f9-cec5738f94a8" @@ -364,6 +364,16 @@ state_hub_task_id: "d9c0584e-10a1-5edf-a1f9-cec5738f94a8" **Done when:** every service, database, OpenBao-issued, client-secret and break-glass credential lane is listed with its blast radius and verification step, linked to its owner lane (RPF-WP-0035, RPF-WP-0055-T01, T06). +**Done 2026-10-28.** `tools/rotation-lanes/lanes.yaml` lists 24 lanes (service, database, +client-secret, OpenBao-issued, break-glass), each with path, blast radius, verification step +and owner lane. `make rotation-lanes-check` derives every known credential from the +service-identity declarations, Credential Management v0.2 section 6 and the crisis custody list, +and fails on a credential without a complete lane; 7 synthetic tests plus a test on the +checked-in register. State today: 5 admitted, 7 designed, 10 deferred (rotation deferral +`1591daa1`, T06), 2 none (`PI_PEPPER`, the legacy `trigger-admin` to be revoked). Readiness +notes for T09: `KEYCAPE_RSA_KEY` has no overlap rotation (single kid, rotation is an outage +window); `LLDAP_LDAP_USER_PASS` stays three-way coupled until the NK-WP-0054-T05 bind split. + ## Revoke the held initial OpenBao root token at production entry ```task