Refine bootstrap responsibilities and command states

This commit is contained in:
2026-05-25 13:13:47 +02:00
parent 4982c92fb1
commit 976f399342
3 changed files with 218 additions and 42 deletions

View File

@@ -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 States are `nil`, `set`, `err`, and `ok`. Role chips expose the designated
email as hover text. 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 The UI is a guide and approval surface, not the identity provider. Current
lightweight-mode credential placement is: lightweight-mode credential placement is:

View File

@@ -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 [ return [
{ {
"name": "OpenBao status", "name": "OpenBao status",
"description": "Show pod, service, PVC, and seal/init status.", "description": "Show pod, service, PVC, and seal/init status.",
"status": status_state,
"status_reason": status_reason,
"command": "make -C ../railiance-platform openbao-status", "command": "make -C ../railiance-platform openbao-status",
}, },
{ {
"name": "OpenBao preflight", "name": "OpenBao preflight",
"description": "Run safe status and verification checks. Does not initialize OpenBao.", "description": "Run safe status and verification checks. Does not initialize OpenBao.",
"status": preflight_state,
"status_reason": preflight_reason,
"command": ( "command": (
"python3 tools/security-bootstrap-console/security_bootstrap_console.py " "python3 tools/security-bootstrap-console/security_bootstrap_console.py "
"openbao-preflight --railiance-path ../railiance-platform --run" "openbao-preflight --railiance-path ../railiance-platform --run"
@@ -1147,21 +1198,29 @@ def command_payloads() -> list[dict[str, str]]:
{ {
"name": "OpenBao init ceremony", "name": "OpenBao init ceremony",
"description": "Creates real unseal shares and the initial root token. Run once, attended.", "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", "command": "kubectl exec -n openbao openbao-0 -- bao operator init -key-shares=3 -key-threshold=2",
}, },
{ {
"name": "OpenBao unseal prompt", "name": "OpenBao unseal prompt",
"description": "Enter unseal shares by prompt. Do not place shares on the command line.", "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", "command": "kubectl exec -n openbao openbao-0 -- bao operator unseal",
}, },
{ {
"name": "OpenBao initial configuration", "name": "OpenBao initial configuration",
"description": "Apply first audit, auth, mount, and policy configuration after unseal.", "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", "command": "make -C ../railiance-platform openbao-configure-initial",
}, },
{ {
"name": "OpenBao post-unseal verification", "name": "OpenBao post-unseal verification",
"description": "Verify filesystem and post-unseal readiness before live secrets move in.", "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", "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")) 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"] = public_key
metadata_view["custodian_age_public_key_fingerprint"] = age_public_key_fingerprint(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 { return {
"metadata_path": str(metadata_path), "metadata_path": str(metadata_path),
"stage": derive_stage(merged), "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), "subsystems": subsystem_payloads(merged),
"integrations": integration_payloads(merged), "integrations": integration_payloads(merged),
"artifacts": artifact_payloads(merged), "artifacts": artifact_payloads(merged),
"commands": command_payloads(), "commands": command_payloads(merged),
"bootstrap_secret_state": bootstrap_secret_state(), "bootstrap_secret_state": bootstrap_secret_state(),
"metadata": metadata_view, "metadata": metadata_view,
"approval_phrase": APPROVAL_PHRASE, "approval_phrase": APPROVAL_PHRASE,
@@ -1648,7 +1717,7 @@ def ui_html() -> str:
} }
.record-row { .record-row {
display: grid; 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; gap: 10px;
align-items: start; align-items: start;
border: 1px solid var(--soft-line); border: 1px solid var(--soft-line);
@@ -1667,6 +1736,11 @@ def ui_html() -> str:
margin-top: 3px; margin-top: 3px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.record-context {
display: grid;
gap: 6px;
min-width: 0;
}
.state { .state {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
@@ -1681,9 +1755,13 @@ def ui_html() -> str:
background: var(--warn); background: var(--warn);
} }
.state.ok { background: var(--ok); } .state.ok { background: var(--ok); }
.state.done { background: var(--ok); }
.state.set { background: var(--human); } .state.set { background: var(--human); }
.state.redo { background: var(--human); }
.state.nil { background: #ffffff; } .state.nil { background: #ffffff; }
.state.todo { background: var(--warn); }
.state.err { background: var(--bad); } .state.err { background: var(--bad); }
.state.blocked { background: var(--bad); }
.role-chip { .role-chip {
display: inline-flex; display: inline-flex;
max-width: 100%; max-width: 100%;
@@ -1826,40 +1904,47 @@ def ui_html() -> str:
<div class="section-gate" data-section-gate="roles">Loading role gate.</div> <div class="section-gate" data-section-gate="roles">Loading role gate.</div>
<p class="notice">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.</p> <p class="notice">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.</p>
<div id="roles-records" class="record-list"></div> <div id="roles-records" class="record-list"></div>
<div class="grid"> <details id="responsibility-editor" class="system-note">
<label class="field"> <summary><span class="record-name">Change responsibilities</span><span class="state set" id="responsibility-edit-state">set</span></summary>
<span class="label">Setup operator</span> <div class="grid" style="margin-top: 14px;">
<input id="setup_operator" type="text" autocomplete="off" title="Local operator handle used for non-secret progress records."> <label class="field">
</label> <span class="label">Setup operator</span>
<label class="field"> <input id="setup_operator" type="text" autocomplete="off" title="Local operator handle used for non-secret progress records.">
<span class="label">Notification contact</span> </label>
<input id="notification_contact" type="text" autocomplete="off" title="Email for bootstrap notifications and lockout handling."> <label class="field">
</label> <span class="label">Notification contact</span>
<label class="field"> <input id="notification_contact" type="text" autocomplete="off" title="Email for bootstrap notifications and lockout handling.">
<span class="label">Setup operator email</span> </label>
<input id="role_setup_operator_email" type="text" autocomplete="off" title="Designated user for the setup-operator role."> <label class="field">
</label> <span class="label">Setup operator email</span>
<label class="field"> <input id="role_setup_operator_email" type="text" autocomplete="off" title="Designated user for the setup-operator role.">
<span class="label">Platform-root custodian email</span> </label>
<input id="role_platform_custodian_email" type="text" autocomplete="off" title="Designated user for the platform-root-custodian role."> <label class="field">
</label> <span class="label">Platform-root custodian email</span>
<label class="field"> <input id="role_platform_custodian_email" type="text" autocomplete="off" title="Designated user for the platform-root-custodian role.">
<span class="label">Identity admin email</span> </label>
<input id="role_identity_admin_email" type="text" autocomplete="off" title="Designated user for identity-admin during bootstrap."> <label class="field">
</label> <span class="label">Identity admin email</span>
<label class="field"> <input id="role_identity_admin_email" type="text" autocomplete="off" title="Designated user for identity-admin during bootstrap.">
<span class="label">OpenBao operator email</span> </label>
<input id="role_openbao_operator_email" type="text" autocomplete="off" title="Designated user for the attended OpenBao ceremony."> <label class="field">
</label> <span class="label">OpenBao operator email</span>
<label class="field"> <input id="role_openbao_operator_email" type="text" autocomplete="off" title="Designated user for the attended OpenBao ceremony.">
<span class="label">Recovery custodian email</span> </label>
<input id="role_recovery_custodian_email" type="text" autocomplete="off" title="Designated user for recovery material and bootstrap bundle custody."> <label class="field">
</label> <span class="label">Recovery custodian email</span>
<label class="field"> <input id="role_recovery_custodian_email" type="text" autocomplete="off" title="Designated user for recovery material and bootstrap bundle custody.">
<span class="label">Future quorum email</span> </label>
<input id="role_future_quorum_email" type="text" autocomplete="off" title="Optional placeholder for later two-of-three custody migration."> <label class="field">
</label> <span class="label">Future quorum email</span>
</div> <input id="role_future_quorum_email" type="text" autocomplete="off" title="Optional placeholder for later two-of-three custody migration.">
</label>
</div>
<div class="actions">
<button class="secondary" id="responsibility-cancel-button" type="button" disabled title="Discard responsibility edits and close this foldout.">Cancel</button>
<button id="responsibility-save-button" type="button" disabled title="Save responsibility edits as non-secret local metadata and close this foldout.">Save</button>
</div>
</details>
</details> </details>
<details class="panel workflow-section" data-section="subsystems" open> <details class="panel workflow-section" data-section="subsystems" open>
@@ -2054,6 +2139,17 @@ def ui_html() -> str:
"root_token_disposition", "root_token_disposition",
"restore_drill_passed" "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) { function setMessage(text, kind) {
const element = document.getElementById("message"); const element = document.getElementById("message");
@@ -2116,18 +2212,20 @@ def ui_html() -> str:
description.textContent = record.description; description.textContent = record.description;
identity.append(name, description); identity.append(name, description);
const context = document.createElement("div");
context.className = "record-context";
const subsystem = document.createElement("div"); const subsystem = document.createElement("div");
subsystem.className = "record-meta"; subsystem.className = "record-meta";
subsystem.textContent = record.subsystem; subsystem.textContent = record.subsystem;
const responsibility = document.createElement("div"); const responsibility = document.createElement("div");
responsibility.append(makeRoleChip(record.responsibility, record.email)); responsibility.append(makeRoleChip(record.responsibility, record.email));
context.append(subsystem, responsibility);
const location = document.createElement("div"); const location = document.createElement("div");
location.className = "record-meta"; location.className = "record-meta";
location.textContent = record.location; location.textContent = record.location;
row.append(identity, subsystem, responsibility, location); row.append(identity, context, location);
root.append(row); root.append(row);
} }
} }
@@ -2162,7 +2260,10 @@ def ui_html() -> str:
const description = document.createElement("div"); const description = document.createElement("div");
description.className = "record-description"; description.className = "record-description";
description.textContent = item.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"); const button = document.createElement("button");
button.className = "copy-button secondary"; button.className = "copy-button secondary";
@@ -2170,7 +2271,10 @@ def ui_html() -> str:
button.textContent = "Copy"; button.textContent = "Copy";
button.title = "Copy this console command to the clipboard."; button.title = "Copy this console command to the clipboard.";
button.dataset.command = item.command; 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"); const code = document.createElement("code");
code.className = "command-code"; code.className = "command-code";
@@ -2203,6 +2307,29 @@ def ui_html() -> str:
syncConditionalHardware(); 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() { function syncConditionalHardware() {
const source = document.getElementById("mfa_class"); const source = document.getElementById("mfa_class");
const row = document.getElementById("hardware-storage-choice"); const row = document.getElementById("hardware-storage-choice");
@@ -2227,7 +2354,9 @@ def ui_html() -> str:
renderRecords("integrations-records", data.integrations); renderRecords("integrations-records", data.integrations);
renderRecords("artifacts-records", data.artifacts); renderRecords("artifacts-records", data.artifacts);
renderCommands(data.commands); renderCommands(data.commands);
fillForm(data.metadata || {}); currentMetadata = data.metadata || {};
fillForm(currentMetadata);
setResponsibilityDirty(false);
} }
function approvalPayload() { function approvalPayload() {
@@ -2275,6 +2404,40 @@ def ui_html() -> str:
document.getElementById("mfa_class").addEventListener("change", syncConditionalHardware); 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) => { document.getElementById("command-list").addEventListener("click", async (event) => {
const button = event.target.closest(".copy-button"); const button = event.target.closest(".copy-button");
if (!button) return; if (!button) return;

View File

@@ -236,6 +236,12 @@ Artefacts & Locations. Role, subsystem, integration, and artefact rows now use
the same `name`, `description`, `subsystem`, `responsibility`, `location`, and the same `name`, `description`, `subsystem`, `responsibility`, `location`, and
`state` fields, and console commands are shown as copyable command blocks. `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 **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 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 the custodian public age recipient, a derived fingerprint, and a non-secret