diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index 5f77f89..4bd5153 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -92,6 +92,13 @@ Role, subsystem, integration, and artefact records use the same fields: States are `nil`, `set`, `err`, and `ok`. Role chips expose the designated email as hover text. +Responsibility assignments are edited through the **Change responsibilities** +foldout. Editing enables local Save/Cancel actions; Save writes only non-secret +role metadata and Cancel restores the last loaded values. Command cards use +`blocked`, `todo`, `redo`, and `done` to show whether an operator command is +available, needs to be run, should be repeated after a state change, or has +already succeeded. + 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 e11ec9f..e6655f1 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -1129,16 +1129,67 @@ def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: ] -def command_payloads() -> list[dict[str, str]]: +def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + preflight_done = yes(data, "openbao_preflight_passed") + custody_approved = custody_mode_approved(data) + initialized = yes(data, "openbao_initialized") + root_disposed = data.get("root_token_disposition") in {"revoked", "offline-sealed"} + restore_done = yes(data, "restore_drill_passed") + + status_state = "todo" + status_reason = "Run any time to inspect the current OpenBao deployment state." + if preflight_done: + status_state = "done" + status_reason = "Deployment and pre-init status were verified." + if initialized and not root_disposed: + status_state = "redo" + status_reason = "OpenBao changed during init/unseal; rerun status before root-token disposition." + + preflight_state = "done" if preflight_done else "todo" + preflight_reason = "Safe preflight passed." + if not preflight_done: + preflight_reason = "Run after custody approval and before init." + if not custody_approved: + preflight_state = "blocked" + preflight_reason = "Approve the selected custody strategy first." + + ceremony_state = "done" if initialized else "todo" + ceremony_reason = "Init/unseal ceremony has been recorded." + if not initialized: + ceremony_reason = "Run once, attended, after OpenBao preflight." + if not preflight_done: + ceremony_state = "blocked" + ceremony_reason = "OpenBao preflight must pass first." + + config_state = "done" if root_disposed else "todo" + config_reason = "Initial configuration and root-token disposition are recorded." + if not root_disposed: + config_reason = "Configure OpenBao, then revoke or offline-seal the root token." + if not initialized: + config_state = "blocked" + config_reason = "OpenBao must be initialized and unsealed first." + + verify_state = "done" if restore_done else "todo" + verify_reason = "Restore proof has been recorded." + if not restore_done: + verify_reason = "Verify post-unseal readiness, snapshot, and isolated restore." + if not initialized: + verify_state = "blocked" + verify_reason = "OpenBao must be initialized and unsealed first." + return [ { "name": "OpenBao status", "description": "Show pod, service, PVC, and seal/init status.", + "status": status_state, + "status_reason": status_reason, "command": "make -C ../railiance-platform openbao-status", }, { "name": "OpenBao preflight", "description": "Run safe status and verification checks. Does not initialize OpenBao.", + "status": preflight_state, + "status_reason": preflight_reason, "command": ( "python3 tools/security-bootstrap-console/security_bootstrap_console.py " "openbao-preflight --railiance-path ../railiance-platform --run" @@ -1147,21 +1198,29 @@ def command_payloads() -> list[dict[str, str]]: { "name": "OpenBao init ceremony", "description": "Creates real unseal shares and the initial root token. Run once, attended.", + "status": ceremony_state, + "status_reason": ceremony_reason, "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.", + "status": ceremony_state, + "status_reason": ceremony_reason, "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.", + "status": config_state, + "status_reason": config_reason, "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.", + "status": verify_state, + "status_reason": verify_reason, "command": "make -C ../railiance-platform openbao-verify-post-unseal", }, ] @@ -1209,6 +1268,16 @@ def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: public_key = extract_age_public_key(metadata_view.get("custodian_age_public_key")) metadata_view["custodian_age_public_key"] = public_key metadata_view["custodian_age_public_key_fingerprint"] = age_public_key_fingerprint(public_key) + for role_key in ( + "role_setup_operator_email", + "role_platform_custodian_email", + "role_identity_admin_email", + "role_openbao_operator_email", + "role_recovery_custodian_email", + "role_future_quorum_email", + ): + if not metadata_view.get(role_key): + metadata_view[role_key] = role_email(merged, role_key) return { "metadata_path": str(metadata_path), "stage": derive_stage(merged), @@ -1221,7 +1290,7 @@ def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: "subsystems": subsystem_payloads(merged), "integrations": integration_payloads(merged), "artifacts": artifact_payloads(merged), - "commands": command_payloads(), + "commands": command_payloads(merged), "bootstrap_secret_state": bootstrap_secret_state(), "metadata": metadata_view, "approval_phrase": APPROVAL_PHRASE, @@ -1648,7 +1717,7 @@ def ui_html() -> str: } .record-row { display: grid; - grid-template-columns: 62px minmax(170px, 1.2fr) minmax(120px, 0.7fr) minmax(150px, 0.8fr) minmax(150px, 1fr); + grid-template-columns: 58px minmax(170px, 1.25fr) minmax(130px, 0.72fr) minmax(150px, 1fr); gap: 10px; align-items: start; border: 1px solid var(--soft-line); @@ -1667,6 +1736,11 @@ def ui_html() -> str: margin-top: 3px; overflow-wrap: anywhere; } + .record-context { + display: grid; + gap: 6px; + min-width: 0; + } .state { display: inline-flex; justify-content: center; @@ -1681,9 +1755,13 @@ def ui_html() -> str: background: var(--warn); } .state.ok { background: var(--ok); } + .state.done { background: var(--ok); } .state.set { background: var(--human); } + .state.redo { background: var(--human); } .state.nil { background: #ffffff; } + .state.todo { background: var(--warn); } .state.err { background: var(--bad); } + .state.blocked { background: var(--bad); } .role-chip { display: inline-flex; max-width: 100%; @@ -1826,40 +1904,47 @@ def ui_html() -> str:
Loading role gate.

Define who is accountable for each bootstrap role before touching subsystem-specific controls. Role chips in every record show the role name; hover them to see the designated email.

-
- - - - - - - - -
+
+ Change responsibilitiesset +
+ + + + + + + + +
+
+ + +
+
@@ -2054,6 +2139,17 @@ def ui_html() -> str: "root_token_disposition", "restore_drill_passed" ]; + const responsibilityFields = [ + "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" + ]; + let currentMetadata = {}; function setMessage(text, kind) { const element = document.getElementById("message"); @@ -2116,18 +2212,20 @@ def ui_html() -> str: description.textContent = record.description; identity.append(name, description); + const context = document.createElement("div"); + context.className = "record-context"; const subsystem = document.createElement("div"); subsystem.className = "record-meta"; subsystem.textContent = record.subsystem; - const responsibility = document.createElement("div"); responsibility.append(makeRoleChip(record.responsibility, record.email)); + context.append(subsystem, responsibility); const location = document.createElement("div"); location.className = "record-meta"; location.textContent = record.location; - row.append(identity, subsystem, responsibility, location); + row.append(identity, context, location); root.append(row); } } @@ -2162,7 +2260,10 @@ def ui_html() -> str: const description = document.createElement("div"); description.className = "record-description"; description.textContent = item.description; - title.append(name, description); + const statusReason = document.createElement("div"); + statusReason.className = "record-description"; + statusReason.textContent = item.status_reason || ""; + title.append(name, description, statusReason); const button = document.createElement("button"); button.className = "copy-button secondary"; @@ -2170,7 +2271,10 @@ def ui_html() -> str: button.textContent = "Copy"; button.title = "Copy this console command to the clipboard."; button.dataset.command = item.command; - head.append(title, button); + const commandActions = document.createElement("div"); + commandActions.className = "inline-actions"; + commandActions.append(makeStateBadge(item.status), button); + head.append(title, commandActions); const code = document.createElement("code"); code.className = "command-code"; @@ -2203,6 +2307,29 @@ def ui_html() -> str: syncConditionalHardware(); } + function fillResponsibilityFields(metadata) { + for (const id of responsibilityFields) { + const element = document.getElementById(id); + if (element) element.value = metadata[id] || ""; + } + } + + function responsibilityPayload() { + const payload = {}; + for (const id of responsibilityFields) { + payload[id] = document.getElementById(id).value.trim(); + } + return payload; + } + + function setResponsibilityDirty(dirty) { + document.getElementById("responsibility-save-button").disabled = !dirty; + document.getElementById("responsibility-cancel-button").disabled = !dirty; + const badge = document.getElementById("responsibility-edit-state"); + badge.className = "state " + (dirty ? "redo" : "set"); + badge.textContent = dirty ? "redo" : "set"; + } + function syncConditionalHardware() { const source = document.getElementById("mfa_class"); const row = document.getElementById("hardware-storage-choice"); @@ -2227,7 +2354,9 @@ def ui_html() -> str: renderRecords("integrations-records", data.integrations); renderRecords("artifacts-records", data.artifacts); renderCommands(data.commands); - fillForm(data.metadata || {}); + currentMetadata = data.metadata || {}; + fillForm(currentMetadata); + setResponsibilityDirty(false); } function approvalPayload() { @@ -2275,6 +2404,40 @@ def ui_html() -> str: document.getElementById("mfa_class").addEventListener("change", syncConditionalHardware); + document.getElementById("responsibility-editor").addEventListener("input", () => { + setResponsibilityDirty(true); + }); + + document.getElementById("responsibility-cancel-button").addEventListener("click", () => { + fillResponsibilityFields(currentMetadata); + setResponsibilityDirty(false); + document.getElementById("responsibility-editor").open = false; + }); + + document.getElementById("responsibility-save-button").addEventListener("click", async () => { + const button = document.getElementById("responsibility-save-button"); + button.disabled = true; + try { + const response = await fetch("/api/save-progress", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(responsibilityPayload()) + }); + const result = await response.json(); + if (!response.ok) { + setMessage("Save failed:\\n" + (result.errors || []).map((item) => "- " + item).join("\\n"), "error"); + setResponsibilityDirty(true); + return; + } + await loadStatus(); + document.getElementById("responsibility-editor").open = false; + setMessage("Responsibilities saved. No secrets were recorded.", "ok"); + } catch (error) { + setMessage("Save failed: " + error.message, "error"); + setResponsibilityDirty(true); + } + }); + document.getElementById("command-list").addEventListener("click", async (event) => { const button = event.target.closest(".copy-button"); if (!button) return; diff --git a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md index 3786b6a..16eb611 100644 --- a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md +++ b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md @@ -236,6 +236,12 @@ Artefacts & Locations. Role, subsystem, integration, and artefact rows now use the same `name`, `description`, `subsystem`, `responsibility`, `location`, and `state` fields, and console commands are shown as copyable command blocks. +**2026-05-25:** Refined the new model after operator review: role chips now sit +under subsystem labels to keep artefact rows narrow, responsibility editing is +inside a dirty-state Save/Cancel foldout, future quorum contact uses the same +effective-value prefill as the role display, and command cards now derive +`blocked`, `todo`, `redo`, or `done` status from bootstrap metadata. + **2026-05-24:** Stepped back from ad hoc secret rollout and added the custodian age-key bootstrap model to the control surface. The UI now records the custodian public age recipient, a derived fingerprint, and a non-secret