diff --git a/Makefile b/Makefile index fdef922..69bfacf 100644 --- a/Makefile +++ b/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 \ diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f9dd1b4..17f382e 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/adr/ADR-0009-expanded-mode-keycloak-federation-topology.md b/docs/adr/ADR-0009-expanded-mode-keycloak-federation-topology.md new file mode 100644 index 0000000..0389eb7 --- /dev/null +++ b/docs/adr/ADR-0009-expanded-mode-keycloak-federation-topology.md @@ -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? diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md new file mode 100644 index 0000000..5360f9b --- /dev/null +++ b/docs/tutorials/README.md @@ -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 by ` 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`. diff --git a/docs/tutorials/TEMPLATE.md b/docs/tutorials/TEMPLATE.md new file mode 100644 index 0000000..30a862f --- /dev/null +++ b/docs/tutorials/TEMPLATE.md @@ -0,0 +1,41 @@ +# + +Exercise status: unexercised +Workplan task: NK-WP-NNNN-TNN +Pattern(s): + +## Outcome + + + +## Prerequisites + +- + +## Architecture context + + + +## Steps + +1. **[owner: ]** + +## Verification + +Done when: + +- + +## Rollback + +- + +## Threat checks + +- + +## Ownership notes + +| Concern | Owner | +| --- | --- | +| | | diff --git a/docs/tutorials/openbao-operating-path.md b/docs/tutorials/openbao-operating-path.md new file mode 100644 index 0000000..4eab152 --- /dev/null +++ b/docs/tutorials/openbao-operating-path.md @@ -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 | diff --git a/docs/tutorials/ssh-certificates-and-tunnels.md b/docs/tutorials/ssh-certificates-and-tunnels.md new file mode 100644 index 0000000..12f8f75 --- /dev/null +++ b/docs/tutorials/ssh-certificates-and-tunnels.md @@ -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 --pubkey ~/.ssh/_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 ` 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/-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 `; `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 | diff --git a/tools/tutorial-verify/tests/test_tutorial_verify.py b/tools/tutorial-verify/tests/test_tutorial_verify.py new file mode 100644 index 0000000..b942d11 --- /dev/null +++ b/tools/tutorial-verify/tests/test_tutorial_verify.py @@ -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: ]** ", "")), 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 diff --git a/tools/tutorial-verify/tutorial_verify.py b/tools/tutorial-verify/tutorial_verify.py new file mode 100644 index 0000000..f58846a --- /dev/null +++ b/tools/tutorial-verify/tutorial_verify.py @@ -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)) diff --git a/workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md b/workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md index 12ac96f..8a05cc2 100644 --- a/workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md +++ b/workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md @@ -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" ``` diff --git a/workplans/NK-WP-0011-enterprise-federation-saml.md b/workplans/NK-WP-0011-enterprise-federation-saml.md index 67a52d1..a65267d 100644 --- a/workplans/NK-WP-0011-enterprise-federation-saml.md +++ b/workplans/NK-WP-0011-enterprise-federation-saml.md @@ -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 ```