NK-WP-0055-T15: credential rotation-lane register with coverage check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 308701@bnt-lap001 Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
This commit is contained in:
parent
5466c831cd
commit
666160a390
8 changed files with 559 additions and 3 deletions
26
tools/rotation-lanes/README.md
Normal file
26
tools/rotation-lanes/README.md
Normal file
|
|
@ -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).
|
||||
152
tools/rotation-lanes/check.py
Normal file
152
tools/rotation-lanes/check.py
Normal file
|
|
@ -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())
|
||||
241
tools/rotation-lanes/lanes.yaml
Normal file
241
tools/rotation-lanes/lanes.yaml
Normal file
|
|
@ -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
|
||||
118
tools/rotation-lanes/tests/test_check.py
Normal file
118
tools/rotation-lanes/tests/test_check.py
Normal file
|
|
@ -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 == []
|
||||
|
|
@ -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 <record.json>", "test": "make custody-record-test", "agent_may_run": "yes",
|
||||
"standard": "docs/adr/ADR-0017-root-grant-custody-and-account-authority.md"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue