Activate NK-WP-0009/0011; add tutorials slice and proposed ADR-0009
- docs/tutorials: template, OpenBao and SSH tutorials (unexercised) - tools/tutorial-verify + make tutorials-verify (NK-WP-0009-T06) - ADR-0009 proposed: expanded-mode Keycloak trigger and topology Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
parent
5c4bc16706
commit
0d460e3c02
11 changed files with 469 additions and 8 deletions
8
Makefile
8
Makefile
|
|
@ -186,6 +186,12 @@ openbao-init-unseal: ## SOPS-held OpenBao init/unseal (NET-WP-0020 T2; requires
|
|||
openbao-init-unseal-dry-run: ## Dry-run the SOPS-held OpenBao init/unseal path
|
||||
@bash sso-mfa/bootstrap/openbao-init-unseal.sh --dry-run
|
||||
|
||||
tutorials-verify: ## Verify docs/tutorials structure, ownership tags, and references
|
||||
python3 tools/tutorial-verify/tutorial_verify.py
|
||||
|
||||
tutorials-verify-test: ## Run tutorial verifier tests
|
||||
python3 -m pytest tools/tutorial-verify/tests
|
||||
|
||||
iam-profile-conformance-test: ## Run current IAM Profile conformance fixture tests
|
||||
python3 -m pytest tools/iam-profile-conformance/tests
|
||||
|
||||
|
|
@ -373,7 +379,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a
|
|||
--host "$(SECURITY_BOOTSTRAP_HOST)" \
|
||||
--port "$(SECURITY_BOOTSTRAP_PORT)"
|
||||
|
||||
.PHONY: help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \
|
||||
.PHONY: tutorials-verify tutorials-verify-test 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 \
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
| workplan | NK-WP-ADHOC-2026-08-14 | finished | — | workplans/ADHOC-2026-08-14.md |
|
||||
| workplan | NK-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md |
|
||||
| workplan | NET-WP-0020 | finished | — | workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md |
|
||||
| workplan | NK-WP-0009 | backlog | — | workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md |
|
||||
| workplan | NK-WP-0011 | backlog | — | workplans/NK-WP-0011-enterprise-federation-saml.md |
|
||||
| workplan | NK-WP-0009 | active | — | workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md |
|
||||
| workplan | NK-WP-0011 | active | — | workplans/NK-WP-0011-enterprise-federation-saml.md |
|
||||
| workplan | NK-WP-0021 | finished | — | workplans/NK-WP-0021-activity-core-ops-sso-operators.md |
|
||||
| workplan | NK-WP-0022 | blocked | — | workplans/NK-WP-0022-railiance01-identity-cutover-and-coulombcore-retirement.md |
|
||||
| workplan | NK-WP-0023 | finished | — | workplans/NK-WP-0023-user-engine-portal-platform-integration.md |
|
||||
|
|
|
|||
111
docs/adr/ADR-0009-expanded-mode-keycloak-federation-topology.md
Normal file
111
docs/adr/ADR-0009-expanded-mode-keycloak-federation-topology.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
id: NK-ADR-0009
|
||||
type: architecture-decision-record
|
||||
title: "Expanded-Mode Keycloak: Adoption Trigger and Federation Topology"
|
||||
status: proposed
|
||||
owner: net-kingdom
|
||||
revision: "1"
|
||||
proposed: "2026-09-28"
|
||||
last_reviewed: "2026-09-28"
|
||||
review_interval: 12m
|
||||
---
|
||||
|
||||
# ADR-0009 - Expanded-Mode Keycloak: Adoption Trigger and Federation Topology
|
||||
|
||||
**Status:** Proposed (awaiting Bernd's decision; NK-WP-0011-T01)
|
||||
**Date:** 2026-09-28
|
||||
**Deciders:** Bernd Worsch, Claude
|
||||
|
||||
## Context
|
||||
|
||||
The live IAM Profile issuer is KeyCape (Authelia + LLDAP + privacyIDEA) at
|
||||
`https://kc.coulomb.social`. No Keycloak Deployment exists. The architecture
|
||||
doc says expanded mode is capability-driven, chiefly inbound enterprise
|
||||
federation (Entra ID, AD, SAML), and leaves the per-tenant trigger and
|
||||
dual-issuer rule open. ADR-0014 defines the `IAM` capability role as a tenant
|
||||
operating its own dedicated IAM instance. ADR-0006 keeps flex-auth as the PDP.
|
||||
ADR-0016 makes MFA a user preference with workload opt-in step-up. The user-engine
|
||||
extension-point doc requires immutable provider subjects, tenant-scoped group
|
||||
mappings, and no privilege from unmapped upstream claims. The estate is one
|
||||
node with single-instance databases, so no option here buys HA.
|
||||
|
||||
NK-WP-0001 decision D2 (Keycloak as primary internal user store) predates all
|
||||
of this.
|
||||
|
||||
## Decision (proposed)
|
||||
|
||||
1. **Trigger.** A tenant moves to expanded mode only when all three hold: a
|
||||
named tenant, a named owner of the upstream IdP, and a concrete federation
|
||||
requirement the lightweight stack cannot meet. User count, or the wish to
|
||||
have Keycloak, is not a trigger. Until then NK-WP-0011 implementation tasks
|
||||
stay unstarted.
|
||||
2. **Role: OIDC identity broker only.** Keycloak brokers upstream Entra ID
|
||||
(OIDC), AD (LDAP federation) and generic SAML 2.0 IdPs (SAML as an
|
||||
upstream, via Keycloak's identity-provider brokering). Downstream it issues
|
||||
only IAM Profile OIDC/PKCE tokens. Emitting SAML assertions to downstream
|
||||
applications is out of scope. Keycloak is not the primary user store
|
||||
(**supersedes D2**), not the PDP, and not a secret store.
|
||||
3. **Isolation.** Realms are the tenant boundary. The `tenant:platform` realm
|
||||
is separate and reserved. A tenant without the `IAM` role gets a realm in
|
||||
the shared broker; a tenant with the `IAM` role gets a dedicated broker
|
||||
instance (ADR-0014). A shared broker gives realm-level, not process-level,
|
||||
isolation, so it is acceptable only for tenants whose federation trust and
|
||||
data classification allow that. Realm admins may not alter IAM Profile
|
||||
semantics, the platform realm, federation trust settings, or audit
|
||||
retention. tenant-engine, not Keycloak, is the source of truth for tenant
|
||||
existence and capability roles.
|
||||
4. **Coexistence.** Each tenant has exactly one active issuer at a time,
|
||||
recorded by tenant-engine and published as issuer configuration. KeyCape
|
||||
stays unchanged and keeps serving lightweight tenants. Applications target
|
||||
the IAM Profile and discover the issuer; they do not embed provider
|
||||
choice. Account bindings remain keyed on issuer plus immutable subject, so
|
||||
moving a tenant between issuers is a planned migration with an explicit
|
||||
subject-mapping step, never a silent dual login.
|
||||
5. **Assurance.** Federated login is AAL1 by default. Upstream MFA (for example
|
||||
Entra Conditional Access) counts as higher assurance only through a
|
||||
reviewed, per-IdP trust mapping written down before use. Privileged step-up
|
||||
continues to use privacyIDEA under ADR-0016 and the proposed IAM v0.4
|
||||
step-up. The privacyIDEA provider JAR is added only if a tenant needs
|
||||
Keycloak-side MFA.
|
||||
6. **Provisioning and mapping.** Follow the user-engine extension-point rules:
|
||||
JIT creates a pending projection; group mappings are tenant-scoped,
|
||||
versioned and deny ambiguous envelopes; no platform role from raw upstream
|
||||
group names.
|
||||
7. **Hostname and issuer.** Not chosen here. It is assigned by the package
|
||||
owner under ADR-0015 with the first tenant, as a distinct host from
|
||||
`kc.coulomb.social`; the issuer URL is then treated as immutable.
|
||||
8. **Ownership.** net-kingdom owns this contract and conformance checks.
|
||||
Packaging and runtime belong to the ADR-0015 owner; the database consumer to
|
||||
railiance-platform; OpenBao roles to railiance-platform.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keycloak replaces KeyCape.** Rejected: unnecessary migration risk for
|
||||
working tenants; contradicts capability-driven adoption.
|
||||
- **Dedicated instance for every federated tenant.** Strongest isolation, but on
|
||||
one node the cost is multiplied database, memory and patching for tenants
|
||||
that do not need it. Kept for `IAM`-role tenants.
|
||||
- **Realm-per-tenant only, including `IAM` tenants.** Contradicts ADR-0014's
|
||||
meaning of `IAM`.
|
||||
- **Keycloak as SAML IdP downstream too.** No consumer requires it; widens the
|
||||
contract surface. Revisit on a named need.
|
||||
- **Trust upstream MFA claims by default.** Rejected: assurance would depend on
|
||||
each customer's tenant configuration.
|
||||
- **Build ahead of demand.** Rejected: adds an issuer, a database and a
|
||||
break-glass path with no owner or user.
|
||||
|
||||
## Consequences
|
||||
|
||||
- NK-WP-0011 T02 onward stay gated on the trigger in decision 1.
|
||||
- T06 conformance uses `tools/iam-profile-conformance/` against IAM v0.3.
|
||||
- tenant-engine needs an issuer-per-tenant field; this is a dependency to
|
||||
raise with its owner, not something built here.
|
||||
- Shared-broker realms cannot be described as strongly isolated tenants.
|
||||
- No HA is implied; T02 and T08 must name backup ownership, off-host custody
|
||||
and an isolated restore proof.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Which named tenant and IdP owner will trigger the first deployment?
|
||||
- Does key-cape require changes for issuer-per-tenant discovery (EP-NK-001)?
|
||||
- Which claim carries the federated-assurance mapping so flex-auth can consume it?
|
||||
36
docs/tutorials/README.md
Normal file
36
docs/tutorials/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# NetKingdom Security Pattern Tutorials
|
||||
|
||||
Hands-on paths for operating the canonical NetKingdom security patterns
|
||||
(NK-WP-0009). Each tutorial is a file in this directory, written from
|
||||
[`TEMPLATE.md`](TEMPLATE.md) and checked by `make tutorials-verify`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Exercise status is mandatory.** Per
|
||||
[`docs/attended-procedure-standard.md`](../attended-procedure-standard.md), a
|
||||
tutorial header says `exercised <date> by <operator>` or `unexercised`.
|
||||
Nothing is labelled exercised until someone has run it.
|
||||
2. **Every concrete step names its owning repo.** This repo owns canon and
|
||||
reference tooling only (see `SCOPE.md`); deployment belongs to owners.
|
||||
3. **Verification and rollback are required**, not optional happy-path extras.
|
||||
4. **No secrets, ever.** Tutorials show paths and commands, never values.
|
||||
5. **Consume, don't copy.** Link owner runbooks; do not paste runtime
|
||||
manifests. Use the named `openbao-ui-railiance01` tunnel, never a public
|
||||
Bao URL (`bao.coulomb.social` is retired).
|
||||
|
||||
## Index
|
||||
|
||||
| Tutorial | Workplan task | Owners | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| [OpenBao: consume, attend, recover](openbao-operating-path.md) | T03 | railiance-platform, net-kingdom | unexercised |
|
||||
| [Short-lived SSH credentials](ssh-certificates-and-tunnels.md) | T04 | ops-warden, ops-bridge | unexercised |
|
||||
|
||||
Deferred (see NK-WP-0009): T02 object-storage STS (needs an owner-backed
|
||||
issuer and refusal/lease proof — ADR-0008 is architecture, not evidence) and
|
||||
T05 flex-auth protected consumer.
|
||||
|
||||
## Pattern mapping
|
||||
|
||||
NK-WP-0008 (the pattern library) has no file in this repo, so tutorials map to
|
||||
the canonical documents directly: `docs/platform-identity-security-architecture.md`,
|
||||
`docs/responsibility-map.md`, `docs/platform-root-custody.md`.
|
||||
41
docs/tutorials/TEMPLATE.md
Normal file
41
docs/tutorials/TEMPLATE.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# <Tutorial title>
|
||||
|
||||
Exercise status: unexercised
|
||||
Workplan task: NK-WP-NNNN-TNN
|
||||
Pattern(s): <canonical doc and section this teaches>
|
||||
|
||||
## Outcome
|
||||
|
||||
<One sentence: what is true when you are done.>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- <Access, tools, and state required. Name the owner of each.>
|
||||
|
||||
## Architecture context
|
||||
|
||||
<Who owns what. Which boundaries this crosses.>
|
||||
|
||||
## Steps
|
||||
|
||||
1. **[owner: <repo>]** <step and command>
|
||||
|
||||
## Verification
|
||||
|
||||
Done when:
|
||||
|
||||
- <Observable, repeatable check. Command plus expected result.>
|
||||
|
||||
## Rollback
|
||||
|
||||
- <How to undo each step, or "not reversible" and why.>
|
||||
|
||||
## Threat checks
|
||||
|
||||
- <What could go wrong; what must never be logged, committed, or pasted.>
|
||||
|
||||
## Ownership notes
|
||||
|
||||
| Concern | Owner |
|
||||
| --- | --- |
|
||||
| <concern> | <repo> |
|
||||
76
docs/tutorials/openbao-operating-path.md
Normal file
76
docs/tutorials/openbao-operating-path.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# OpenBao: consume, attend, recover
|
||||
|
||||
Exercise status: unexercised
|
||||
Workplan task: NK-WP-0009-T03
|
||||
Pattern(s): platform-root custody (`docs/platform-root-custody.md`); credential routing
|
||||
|
||||
## Outcome
|
||||
|
||||
You can reach the already-deployed private OpenBao, read a secret you are
|
||||
entitled to, know which unseal custody model governs it, and follow the
|
||||
attended recovery path without exposing shares or tokens.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **[owner: ops-bridge]** `bridge` CLI and the named `openbao-ui-railiance01`
|
||||
tunnel; an SSH certificate (see the SSH tutorial).
|
||||
- **[owner: ops-warden]** `warden` CLI for credential routing.
|
||||
- **[owner: railiance-platform]** OpenBao is already deployed and private.
|
||||
Greenfield deployment is a lab exercise only, never against the live estate.
|
||||
|
||||
## Architecture context
|
||||
|
||||
OpenBao is the runtime secret authority. railiance-platform deploys and
|
||||
operates it; net-kingdom owns the custody canon and the guarded bootstrap
|
||||
console, which refuses live `bao operator init`. Three unseal custody models
|
||||
exist (`docs/openbao-unseal-custody-models.md`); production blocks the
|
||||
`sops-held-automation` lab model.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **[owner: ops-warden]** Find the owner of your need:
|
||||
`warden route find "read a database password" --json`.
|
||||
2. **[owner: ops-bridge]** Check and, if needed, restore the tunnel:
|
||||
`bridge status`, then `bridge up openbao-ui-railiance01`.
|
||||
3. **[owner: railiance-platform]** Authenticate with your own identity and read
|
||||
only the path the routing result names. Use the owner's
|
||||
`railiance-platform/docs/openbao.md` for exact commands.
|
||||
4. **[owner: net-kingdom]** Know your custody model:
|
||||
`python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-unseal-custody-models`.
|
||||
5. **[owner: net-kingdom + railiance-platform]** Recovery after a seal event
|
||||
follows `docs/openbao-attended-ceremony-runbook.md`: operator and witness
|
||||
present, shares escrowed out of band, root token revoked after handoff.
|
||||
Record the ceremony in a non-secret record and run
|
||||
`make security-bootstrap-validate-openbao-ceremony-record`.
|
||||
|
||||
## Verification
|
||||
|
||||
Done when:
|
||||
|
||||
- `bridge status` shows `openbao-ui-railiance01` up.
|
||||
- `make security-bootstrap-console` reports no unmet custody gate for the
|
||||
selected model.
|
||||
- `make security-bootstrap-validate-openbao-ceremony-record` passes on a
|
||||
ceremony record, and fails on one containing a token-shaped marker.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Close the tunnel: `bridge down openbao-ui-railiance01`.
|
||||
- Read-only steps need no rollback. A ceremony cannot be undone; a mistaken
|
||||
share transcription is corrected by re-escrow per the custody roster.
|
||||
|
||||
## Threat checks
|
||||
|
||||
- Init output, shares and tokens go to the operator's screen only: never to
|
||||
chat, State Hub, logs, or a Git checkout.
|
||||
- Never use a public Bao URL; `bao.coulomb.social` is retired.
|
||||
- Never place root token and unseal shares in one artifact outside lab.
|
||||
|
||||
## Ownership notes
|
||||
|
||||
| Concern | Owner |
|
||||
| --- | --- |
|
||||
| OpenBao deployment, config, unseal execution | railiance-platform |
|
||||
| Custody canon, ceremony record validator | net-kingdom |
|
||||
| Tunnel | ops-bridge |
|
||||
| Credential routing | ops-warden |
|
||||
65
docs/tutorials/ssh-certificates-and-tunnels.md
Normal file
65
docs/tutorials/ssh-certificates-and-tunnels.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Short-lived SSH credentials for admins, agents and automations
|
||||
|
||||
Exercise status: unexercised
|
||||
Workplan task: NK-WP-0009-T04
|
||||
Pattern(s): credential routing; ops-warden `AccessManagementDirective`
|
||||
|
||||
## Outcome
|
||||
|
||||
An actor obtains a short-lived CA-signed SSH certificate and uses it through
|
||||
an ops-bridge tunnel, with no static key doing the work.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **[owner: ops-warden]** `warden` CLI installed; actor registered in the
|
||||
principals inventory.
|
||||
- **[owner: ops-bridge]** `bridge` CLI and a tunnel definition.
|
||||
- **[owner: railiance-infra]** Target hosts trust the SSH CA and carry the
|
||||
actor's principal.
|
||||
|
||||
## Architecture context
|
||||
|
||||
ops-warden issues SSH certificates only (`warden sign`). ops-bridge runs the
|
||||
tunnel and calls the `cert_command` before each connect. Max TTLs: `adm` 48 h,
|
||||
`agt` 24 h, `atm` 8 h; the caller refreshes about 5 minutes before expiry.
|
||||
See `ops-warden/wiki/CertCommandInterface.md`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **[owner: ops-warden]** Sign a public key for the actor:
|
||||
`warden sign <actor> --pubkey ~/.ssh/<actor>_ed25519.pub`.
|
||||
2. **[owner: ops-bridge]** Set `cert_command` to that command in the tunnel
|
||||
definition. Leave static-key mode unused.
|
||||
3. **[owner: ops-bridge]** `bridge up <tunnel>` then `bridge status`.
|
||||
4. **[owner: ops-warden]** Inspect the cert: `warden status`; audit history via
|
||||
`warden log`.
|
||||
|
||||
## Verification
|
||||
|
||||
Done when:
|
||||
|
||||
- `ssh-keygen -L -f ~/.local/state/warden/<actor>-cert.pub` shows the expected
|
||||
principal and a `Valid before` within the actor-type TTL.
|
||||
- `warden status` exits 0 (it exits 1 if any cert is expired).
|
||||
- After expiry, the tunnel reconnects only after `cert_command` succeeds.
|
||||
|
||||
## Rollback
|
||||
|
||||
- `bridge down <tunnel>`; `warden cleanup` removes stale certificates.
|
||||
- Certificates expire on their own; there is no long-lived credential to
|
||||
revoke. Remove the actor from the inventory to stop future signing.
|
||||
|
||||
## Threat checks
|
||||
|
||||
- Cert files must be mode 600; never reuse a cert across reconnects.
|
||||
- A non-zero `cert_command` exit is a failure and must trigger backoff.
|
||||
- ops-warden never vends API keys or passwords; route them with
|
||||
`warden route find`.
|
||||
|
||||
## Ownership notes
|
||||
|
||||
| Concern | Owner |
|
||||
| --- | --- |
|
||||
| Certificate issuance and TTL policy | ops-warden |
|
||||
| Tunnel lifecycle and refresh | ops-bridge |
|
||||
| Host CA trust and principals | railiance-infra |
|
||||
48
tools/tutorial-verify/tests/test_tutorial_verify.py
Normal file
48
tools/tutorial-verify/tests/test_tutorial_verify.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import tutorial_verify as tv # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
GOOD = (ROOT / "docs/tutorials/TEMPLATE.md").read_text()
|
||||
|
||||
|
||||
def write(tmp_path, text):
|
||||
p = tmp_path / "t.md"
|
||||
p.write_text(text)
|
||||
return p
|
||||
|
||||
|
||||
def test_real_tutorials_pass():
|
||||
for p in (ROOT / "docs/tutorials").glob("*.md"):
|
||||
if p.name in ("README.md", "TEMPLATE.md"):
|
||||
continue
|
||||
assert tv.check(p, ROOT) == [], p.name
|
||||
|
||||
|
||||
def test_missing_status(tmp_path):
|
||||
errs = tv.check(write(tmp_path, GOOD.replace("Exercise status: unexercised\n", "")), ROOT)
|
||||
assert any("Exercise status" in e for e in errs)
|
||||
|
||||
|
||||
def test_missing_rollback(tmp_path):
|
||||
errs = tv.check(write(tmp_path, GOOD.replace("## Rollback", "## Other")), ROOT)
|
||||
assert "missing section: Rollback" in errs
|
||||
|
||||
|
||||
def test_step_without_owner(tmp_path):
|
||||
errs = tv.check(write(tmp_path, GOOD.replace("**[owner: <repo>]** ", "")), ROOT)
|
||||
assert any("owner tag" in e for e in errs)
|
||||
|
||||
|
||||
def test_secret_marker(tmp_path):
|
||||
errs = tv.check(write(tmp_path, GOOD + "\nhvs.ABCDEFGHIJKLMNOP\n"), ROOT)
|
||||
assert "contains secret-looking marker" in errs
|
||||
|
||||
|
||||
def test_retired_endpoint_and_missing_path(tmp_path):
|
||||
txt = GOOD + "\nOpen https://bao.coulomb.social now. See `docs/nope.md`.\n"
|
||||
errs = tv.check(write(tmp_path, txt), ROOT)
|
||||
assert any("bao.coulomb.social" in e for e in errs)
|
||||
assert "references missing path: docs/nope.md" in errs
|
||||
78
tools/tutorial-verify/tutorial_verify.py
Normal file
78
tools/tutorial-verify/tutorial_verify.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Structural verifier for docs/tutorials (NK-WP-0009-T06).
|
||||
|
||||
Fails a tutorial that is prose-only: missing required sections, missing or
|
||||
invalid exercise status, no per-step owner tags, retired endpoints, secret
|
||||
markers, or references to repo paths that do not exist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_SECTIONS = [
|
||||
"Outcome", "Prerequisites", "Architecture context", "Steps",
|
||||
"Verification", "Rollback", "Threat checks", "Ownership notes",
|
||||
]
|
||||
STATUS_RE = re.compile(
|
||||
r"^Exercise status: (unexercised|exercised \d{4}-\d{2}-\d{2} by \S+)\s*$", re.M)
|
||||
OWNER_TAG_RE = re.compile(r"\*\*\[owner: [^\]]+\]\*\*")
|
||||
SECRET_RE = re.compile(
|
||||
r"(hvs\.[A-Za-z0-9]{8,}|s\.[A-Za-z0-9]{24}|-----BEGIN [A-Z ]*PRIVATE KEY|otpauth://)")
|
||||
PATH_RE = re.compile(r"`((?:docs|tools|canon|workplans)/[\w./-]+)`")
|
||||
RETIRED_MENTION_OK = "retired"
|
||||
|
||||
|
||||
def check(path: Path, root: Path) -> list[str]:
|
||||
text = path.read_text()
|
||||
errs: list[str] = []
|
||||
if not STATUS_RE.search(text):
|
||||
errs.append("missing or invalid 'Exercise status:' header")
|
||||
headings = set(re.findall(r"^## (.+?)\s*$", text, re.M))
|
||||
for s in REQUIRED_SECTIONS:
|
||||
if s not in headings:
|
||||
errs.append(f"missing section: {s}")
|
||||
steps = re.search(r"^## Steps\s*$(.*?)(?=^## |\Z)", text, re.M | re.S)
|
||||
if steps:
|
||||
items = re.findall(r"^\d+\. .*$", steps.group(1), re.M)
|
||||
if not items:
|
||||
errs.append("Steps has no numbered steps")
|
||||
for i in items:
|
||||
if not OWNER_TAG_RE.search(i):
|
||||
errs.append(f"step lacks owner tag: {i[:50]}")
|
||||
ver = re.search(r"^## Verification\s*$(.*?)(?=^## |\Z)", text, re.M | re.S)
|
||||
if ver and "Done when" not in ver.group(1):
|
||||
errs.append("Verification lacks a 'Done when' outcome")
|
||||
for line in text.splitlines():
|
||||
if "bao.coulomb.social" in line and RETIRED_MENTION_OK not in line:
|
||||
errs.append("references bao.coulomb.social without marking it retired")
|
||||
if SECRET_RE.search(text):
|
||||
errs.append("contains secret-looking marker")
|
||||
for ref in PATH_RE.findall(text):
|
||||
if "<" in ref or "*" in ref:
|
||||
continue
|
||||
if not (root / ref).exists():
|
||||
errs.append(f"references missing path: {ref}")
|
||||
return errs
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
tdir = Path(argv[1]) if len(argv) > 1 else root / "docs" / "tutorials"
|
||||
files = sorted(p for p in tdir.glob("*.md") if p.name not in ("README.md", "TEMPLATE.md"))
|
||||
if not files:
|
||||
print("no tutorials found", file=sys.stderr)
|
||||
return 1
|
||||
failed = 0
|
||||
for f in files:
|
||||
errs = check(f, root)
|
||||
print(f"{'FAIL' if errs else 'ok '} {f.name}")
|
||||
for e in errs:
|
||||
print(f" - {e}")
|
||||
failed += bool(errs)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
|
|
@ -68,7 +68,7 @@ Out of scope:
|
|||
|
||||
```task
|
||||
id: NK-WP-0009-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3c6824a0-39e6-51f5-b46f-5861a9375439"
|
||||
```
|
||||
|
|
@ -95,7 +95,7 @@ configuration.
|
|||
|
||||
```task
|
||||
id: NK-WP-0009-T03
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "16cf51a4-0763-59e9-9b4c-d486f5bde908"
|
||||
```
|
||||
|
|
@ -109,7 +109,7 @@ integration, leases, unseal, backup, and break-glass.
|
|||
|
||||
```task
|
||||
id: NK-WP-0009-T04
|
||||
status: todo
|
||||
status: progress
|
||||
priority: medium
|
||||
state_hub_task_id: "1d28e3f1-fb39-5d0e-a7ee-ce2e9bc16ed5"
|
||||
```
|
||||
|
|
@ -135,7 +135,7 @@ decision envelopes, and delegated PDP options.
|
|||
|
||||
```task
|
||||
id: NK-WP-0009-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "5b6a1670-a283-56e9-892e-ef8b91194a7b"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ Out of scope:
|
|||
```task
|
||||
id: NK-WP-0011-T01
|
||||
state_hub_task_id: "a5807808-fb34-50de-8f67-9128011833d4"
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue