diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index ce09349..5f77f89 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -76,6 +76,22 @@ python3 tools/security-bootstrap-console/security_bootstrap_console.py \ Open `http://127.0.0.1:8765`. +The web UI is structured as: + +1. **Roles & Responsibilities** - global bootstrap roles with designated + operator emails. +2. **Subsystems & Scope** - installation and initial access for LLDAP, + privacyIDEA, KeyCape, the custodian age envelope, and Railiance OpenBao. +3. **Integration & Tests** - OIDC and OpenBao preflight checks, with every + operator command shown as a copyable console block. +4. **Artefacts & Locations** - final non-secret overview of established + artefacts and where to find their custody references. + +Role, subsystem, integration, and artefact records use the same fields: +`name`, `description`, `subsystem`, `responsibility`, `location`, and `state`. +States are `nil`, `set`, `err`, and `ok`. Role chips expose the designated +email as hover text. + The UI is a guide and approval surface, not the identity provider. Current lightweight-mode credential placement is: diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index eea2fee..e11ec9f 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -537,6 +537,12 @@ def merged_approval_metadata( "custodian_age_private_key_reference", "setup_operator", "notification_contact", + "role_setup_operator_email", + "role_platform_custodian_email", + "role_identity_admin_email", + "role_openbao_operator_email", + "role_recovery_custodian_email", + "role_future_quorum_email", "mfa_class", "mfa_enrollment_source", "mfa_enrollment_reference", @@ -750,6 +756,12 @@ def metadata_template() -> dict[str, Any]: "identity_group_confirmed": False, "setup_operator": "tegwick", "notification_contact": "bernd.worsch@gmail.com", + "role_setup_operator_email": "bernd.worsch@gmail.com", + "role_platform_custodian_email": "bernd.worsch@gmail.com", + "role_identity_admin_email": "bernd.worsch@gmail.com", + "role_openbao_operator_email": "bernd.worsch@gmail.com", + "role_recovery_custodian_email": "bernd.worsch@gmail.com", + "role_future_quorum_email": "", "storage_classes": ["password-safe", "offline-packet"], "password_safe_confirmed": False, "mfa_class": "totp", @@ -812,6 +824,383 @@ def gate_payload(gate: Gate) -> dict[str, str]: } +def role_email(data: dict[str, Any], role_key: str) -> str: + fallback = str(data.get("notification_contact") or "").strip() + return str(data.get(role_key) or fallback).strip() + + +def role_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + rows = [ + ( + "setup-operator", + "Runs bootstrap commands and records non-secret evidence.", + "NetKingdom bootstrap", + "role_setup_operator_email", + ), + ( + "platform-root-custodian", + "Holds the dedicated platform-root credential and approves custody gates.", + "NetKingdom identity", + "role_platform_custodian_email", + ), + ( + "identity-admin", + "Administers LLDAP, privacyIDEA, and login repair during bootstrap.", + "Identity stack", + "role_identity_admin_email", + ), + ( + "openbao-ceremony-operator", + "Runs the attended OpenBao ceremony without copying secret output into the control surface.", + "Railiance OpenBao", + "role_openbao_operator_email", + ), + ( + "recovery-custodian", + "Can recover the platform-root credential and encrypted bootstrap bundle outside this UI.", + "Custody packet", + "role_recovery_custodian_email", + ), + ( + "future-quorum-custodian", + "Reserved for later two-of-three custody migration.", + "Custody strategy", + "role_future_quorum_email", + ), + ] + payloads: list[dict[str, str]] = [] + for name, description, subsystem, key in rows: + email = role_email(data, key) + payloads.append( + { + "name": name, + "description": description, + "subsystem": subsystem, + "responsibility": name, + "email": email, + "location": "Role assignment in local bootstrap metadata." if email else "Not assigned yet.", + "state": "set" if email else "nil", + } + ) + return payloads + + +def state_value(ok: bool, set_value: bool = False, err: bool = False) -> str: + if err: + return "err" + if ok: + return "ok" + if set_value: + return "set" + return "nil" + + +def subsystem_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + public_key = extract_age_public_key(data.get("custodian_age_public_key")) + state = bootstrap_secret_state() + return [ + { + "name": "age recipient", + "description": "Public age recipient used to encrypt bootstrap bundles.", + "subsystem": "custodian age envelope", + "responsibility": "recovery-custodian", + "email": role_email(data, "role_recovery_custodian_email"), + "location": age_public_key_fingerprint(public_key) or "No public recipient registered.", + "state": state_value( + bool(public_key) and yes(data, "custodian_age_public_key_confirmed"), + bool(public_key), + ), + }, + { + "name": "platform-root user", + "description": "Dedicated LLDAP account used as the current king credential.", + "subsystem": "LLDAP", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": str(data.get("identity_account_reference") or "Not recorded."), + "state": state_value(identity_account_ready(data), bool(data.get("identity_account_reference"))), + }, + { + "name": "platform-root MFA token", + "description": "Second factor enrolled with the authority that verifies login.", + "subsystem": "privacyIDEA", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": str(data.get("mfa_enrollment_reference") or "Not recorded."), + "state": state_value(second_factor_ready(data), yes(data, "mfa_enrolled_confirmed")), + }, + { + "name": "bootstrap OIDC client", + "description": "KeyCape login path used to verify platform-root can authenticate with MFA.", + "subsystem": "KeyCape", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": KEYCAPE_ISSUER, + "state": state_value(identity_login_ready(data), bool(data.get("identity_account_reference"))), + }, + { + "name": "openbao-0", + "description": "Railiance OpenBao pod, services, PVCs, and sealed pre-init state.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": "namespace=openbao, pod=openbao-0", + "state": state_value(yes(data, "openbao_preflight_passed"), yes(data, "custody_mode_approved")), + }, + { + "name": "encrypted bootstrap bundle", + "description": "Encrypted bootstrap secret bundle; plaintext directory must be absent.", + "subsystem": "sso-mfa/bootstrap", + "responsibility": "recovery-custodian", + "email": role_email(data, "role_recovery_custodian_email"), + "location": str(state["encrypted_bundle_path"]), + "state": state_value( + bool(state["encrypted_bundle_exists"]) and not bool(state["plaintext_secrets_present"]), + bool(state["encrypted_bundle_exists"]), + bool(state["plaintext_secrets_present"]), + ), + }, + ] + + +def integration_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + return [ + { + "name": "LLDAP admin group assignment", + "description": "platform-root is assigned to the current NetKingdom admin group.", + "subsystem": "LLDAP", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": str(data.get("identity_group_reference") or "Not recorded."), + "state": state_value(yes(data, "identity_group_confirmed"), bool(data.get("identity_group_reference"))), + }, + { + "name": "privacyIDEA MFA verification", + "description": "The same platform-root account has an enrolled second factor.", + "subsystem": "privacyIDEA", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": str(data.get("mfa_enrollment_reference") or "Not recorded."), + "state": state_value(second_factor_ready(data), yes(data, "mfa_enrolled_confirmed")), + }, + { + "name": "KeyCape OIDC login", + "description": "platform-root completed the OIDC login check through KeyCape.", + "subsystem": "KeyCape", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": "local bootstrap callback", + "state": state_value(yes(data, "oidc_login_verified")), + }, + { + "name": "OpenBao preflight", + "description": "Railiance status and verify targets passed in the approved pre-init state.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": "../railiance-platform", + "state": state_value(yes(data, "openbao_preflight_passed"), yes(data, "custody_mode_approved")), + }, + { + "name": "OpenBao init/unseal ceremony", + "description": "Attended ceremony creates unseal shares and initial root token outside this UI.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": "operator shell with custody packet present", + "state": state_value(yes(data, "openbao_initialized"), yes(data, "openbao_preflight_passed")), + }, + ] + + +def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + public_key = extract_age_public_key(data.get("custodian_age_public_key")) + state = bootstrap_secret_state() + root_disposition = str(data.get("root_token_disposition") or "") + return [ + { + "name": "platform-root", + "description": "Dedicated LLDAP user for the king credential.", + "subsystem": "LLDAP", + "responsibility": "platform-root-custodian", + "email": role_email(data, "role_platform_custodian_email"), + "location": str(data.get("identity_account_reference") or "Not recorded."), + "state": state_value(identity_account_ready(data), bool(data.get("identity_account_reference"))), + }, + { + "name": "net-kingdom-admins", + "description": "Current lightweight admin group for the platform-root identity.", + "subsystem": "LLDAP", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": str(data.get("identity_group_reference") or "Not recorded."), + "state": state_value(yes(data, "identity_group_confirmed"), bool(data.get("identity_group_reference"))), + }, + { + "name": "platform-root password entry", + "description": "Password-safe entry for the dedicated identity password. Value is never stored here.", + "subsystem": "password safe", + "responsibility": "platform-root-custodian", + "email": role_email(data, "role_platform_custodian_email"), + "location": "operator password safe / offline packet", + "state": state_value(yes(data, "password_safe_confirmed")), + }, + { + "name": "TOTP token", + "description": "privacyIDEA token for the platform-root login path.", + "subsystem": "privacyIDEA", + "responsibility": "platform-root-custodian", + "email": role_email(data, "role_platform_custodian_email"), + "location": str(data.get("mfa_enrollment_reference") or "Not recorded."), + "state": state_value(second_factor_ready(data), yes(data, "mfa_enrolled_confirmed")), + }, + { + "name": "age recipient", + "description": "Public recipient used for encrypted bootstrap bundles.", + "subsystem": "custodian age envelope", + "responsibility": "recovery-custodian", + "email": role_email(data, "role_recovery_custodian_email"), + "location": age_public_key_fingerprint(public_key) or "Not recorded.", + "state": state_value(bool(public_key) and yes(data, "custodian_age_public_key_confirmed"), bool(public_key)), + }, + { + "name": "age private key reference", + "description": "Non-secret pointer to the private age key location.", + "subsystem": "custodian age envelope", + "responsibility": "recovery-custodian", + "email": role_email(data, "role_recovery_custodian_email"), + "location": str(data.get("custodian_age_private_key_reference") or "Not recorded."), + "state": state_value(yes(data, "custodian_age_private_key_confirmed"), bool(data.get("custodian_age_private_key_reference"))), + }, + { + "name": "secrets.enc", + "description": "Encrypted bootstrap bundle.", + "subsystem": "sso-mfa/bootstrap", + "responsibility": "recovery-custodian", + "email": role_email(data, "role_recovery_custodian_email"), + "location": str(state["encrypted_bundle_path"]), + "state": state_value(bool(state["encrypted_bundle_exists"]), False, bool(state["plaintext_secrets_present"])), + }, + { + "name": "custody strategy", + "description": "Selected OpenBao ceremony control model.", + "subsystem": "custody model", + "responsibility": "platform-root-custodian", + "email": role_email(data, "role_platform_custodian_email"), + "location": str(data.get("custody_mode") or "Not selected."), + "state": state_value(yes(data, "custody_mode_approved"), data.get("custody_mode") in VALID_CUSTODY_MODES), + }, + { + "name": "recovery material", + "description": "Recovery references for identity, MFA, age key, and encrypted bootstrap bundle.", + "subsystem": "custody packet", + "responsibility": "recovery-custodian", + "email": role_email(data, "role_recovery_custodian_email"), + "location": "offline packet / password safe references", + "state": state_value(yes(data, "recovery_confirmed")), + }, + { + "name": "OpenBao custody packet", + "description": "Ceremony envelope with share assignment slots and root-token disposition plan.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": "offline ceremony packet", + "state": state_value(yes(data, "custody_packet_prepared")), + }, + { + "name": "unseal shares", + "description": "Real OpenBao shares produced by init. They must be routed directly to approved custody locations.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": "created during attended init; not stored here", + "state": state_value(yes(data, "openbao_initialized"), yes(data, "openbao_preflight_passed")), + }, + { + "name": "initial root token", + "description": "OpenBao bootstrap token produced by init. Use only for first configuration and disposition.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": "created during attended init; never pasted here", + "state": state_value(root_disposition in {"revoked", "offline-sealed"}, yes(data, "openbao_initialized")), + }, + ] + + +def command_payloads() -> list[dict[str, str]]: + return [ + { + "name": "OpenBao status", + "description": "Show pod, service, PVC, and seal/init status.", + "command": "make -C ../railiance-platform openbao-status", + }, + { + "name": "OpenBao preflight", + "description": "Run safe status and verification checks. Does not initialize OpenBao.", + "command": ( + "python3 tools/security-bootstrap-console/security_bootstrap_console.py " + "openbao-preflight --railiance-path ../railiance-platform --run" + ), + }, + { + "name": "OpenBao init ceremony", + "description": "Creates real unseal shares and the initial root token. Run once, attended.", + "command": "kubectl exec -n openbao openbao-0 -- bao operator init -key-shares=3 -key-threshold=2", + }, + { + "name": "OpenBao unseal prompt", + "description": "Enter unseal shares by prompt. Do not place shares on the command line.", + "command": "kubectl exec -n openbao openbao-0 -- bao operator unseal", + }, + { + "name": "OpenBao initial configuration", + "description": "Apply first audit, auth, mount, and policy configuration after unseal.", + "command": "make -C ../railiance-platform openbao-configure-initial", + }, + { + "name": "OpenBao post-unseal verification", + "description": "Verify filesystem and post-unseal readiness before live secrets move in.", + "command": "make -C ../railiance-platform openbao-verify-post-unseal", + }, + ] + + +def section_gate_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + role_rows = role_payloads(data) + role_ok = all(row["state"] != "nil" for row in role_rows[:5]) + subsystem_rows = subsystem_payloads(data) + integration_rows = integration_payloads(data) + artifact_rows = artifact_payloads(data) + return [ + { + "key": "roles", + "name": "Roles & Responsibilities", + "status": "ok" if role_ok else "set", + "reason": "All active bootstrap roles have a designated email." if role_ok else "Assign an email to each active bootstrap role.", + }, + { + "key": "subsystems", + "name": "Subsystems & Scope", + "status": "ok" if all(row["state"] in {"ok", "set"} for row in subsystem_rows) else "err", + "reason": "Subsystems have install/access evidence." if all(row["state"] != "nil" for row in subsystem_rows) else "Complete subsystem setup fields and confirmations.", + }, + { + "key": "integrations", + "name": "Integration & Tests", + "status": "ok" if all(row["state"] == "ok" for row in integration_rows[:4]) else "set", + "reason": "Identity and OpenBao preflight checks are done." if all(row["state"] == "ok" for row in integration_rows[:4]) else "Run or confirm the remaining integration checks.", + }, + { + "key": "artifacts", + "name": "Artefacts & Locations", + "status": "ok" if all(row["state"] != "nil" for row in artifact_rows[:10]) else "set", + "reason": "Core custody artefacts have locations or confirmations." if all(row["state"] != "nil" for row in artifact_rows[:10]) else "Record missing artefact locations and confirmations.", + }, + ] + + def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: merged = metadata_template() merged.update(data) @@ -827,6 +1216,12 @@ def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: "gates": [gate_payload(gate) for gate in gates], "key_custody_gates": [gate_payload(gate) for gate in key_custody_validation(merged)], "kit_gates": [gate_payload(gate) for gate in kit_validation(merged)], + "section_gates": section_gate_payloads(merged), + "roles": role_payloads(merged), + "subsystems": subsystem_payloads(merged), + "integrations": integration_payloads(merged), + "artifacts": artifact_payloads(merged), + "commands": command_payloads(), "bootstrap_secret_state": bootstrap_secret_state(), "metadata": metadata_view, "approval_phrase": APPROVAL_PHRASE, @@ -1222,6 +1617,115 @@ def ui_html() -> str: cursor: wait; opacity: 0.65; } + summary { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + cursor: pointer; + list-style: none; + } + summary::-webkit-details-marker { display: none; } + .summary-title { + font-size: 18px; + line-height: 1.2; + font-weight: 650; + } + .section-gate { + border: 1px solid var(--soft-line); + border-radius: 4px; + background: #ffffff; + padding: 10px 12px; + margin: 14px 0; + color: var(--muted); + font-size: 13px; + line-height: 1.35; + } + .record-list, .command-list { + display: grid; + gap: 8px; + margin: 14px 0; + } + .record-row { + display: grid; + grid-template-columns: 62px minmax(170px, 1.2fr) minmax(120px, 0.7fr) minmax(150px, 0.8fr) minmax(150px, 1fr); + gap: 10px; + align-items: start; + border: 1px solid var(--soft-line); + border-radius: 4px; + background: #ffffff; + padding: 10px; + } + .record-name { + font-weight: 650; + line-height: 1.2; + } + .record-description, .record-meta { + color: var(--muted); + font-size: 13px; + line-height: 1.35; + margin-top: 3px; + overflow-wrap: anywhere; + } + .state { + display: inline-flex; + justify-content: center; + min-width: 42px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px 8px; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 12px; + line-height: 1.2; + text-transform: uppercase; + background: var(--warn); + } + .state.ok { background: var(--ok); } + .state.set { background: var(--human); } + .state.nil { background: #ffffff; } + .state.err { background: var(--bad); } + .role-chip { + display: inline-flex; + max-width: 100%; + border: 1px solid var(--soft-line); + border-radius: 999px; + padding: 3px 8px; + background: #ffffff; + color: var(--ink); + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 12px; + overflow-wrap: anywhere; + } + .command-row { + border: 1px solid var(--soft-line); + border-radius: 4px; + background: #ffffff; + padding: 10px; + } + .command-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; + } + .command-code { + display: block; + width: 100%; + margin-top: 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: #181818; + color: #ffffff; + padding: 10px 12px; + overflow-x: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .copy-button { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; + } .gates { border: 1px solid var(--soft-line); border-radius: 4px; @@ -1285,6 +1789,7 @@ def ui_html() -> str: header { padding: 18px; } main { padding: 16px; } .topline, .layout, .grid { grid-template-columns: 1fr; } + .record-row { grid-template-columns: 1fr; } .metric { border-right: 0; border-bottom: 1px solid var(--soft-line); @@ -1316,108 +1821,100 @@ def ui_html() -> str: