Add credential CCR operator handoff
This commit is contained in:
parent
a27a114491
commit
248bc58b6a
4 changed files with 104 additions and 7 deletions
3
Makefile
3
Makefile
|
|
@ -213,6 +213,9 @@ credential-change-sync-decision: ## Sync resolved State Hub decision back into a
|
||||||
credential-change-apply-plan: ## Render approved-only operator apply plan
|
credential-change-apply-plan: ## Render approved-only operator apply plan
|
||||||
scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE)
|
scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE)
|
||||||
|
|
||||||
|
credential-change-operator-commands: ## Render approved-only non-secret OpenBao operator commands
|
||||||
|
scripts/credential-change.py operator-commands $(CREDENTIAL_CHANGE)
|
||||||
|
|
||||||
openbao-token-grants-dry-run: ## Dry-run OpenBao token roles and issuer policies for credential grants
|
openbao-token-grants-dry-run: ## Dry-run OpenBao token roles and issuer policies for credential grants
|
||||||
scripts/openbao-apply-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS)
|
scripts/openbao-apply-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,10 +155,14 @@ scripts/credential-change.py deny CCR-2026-0001 --reviewer <name> --comment "...
|
||||||
scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer <name> --comment "..."
|
scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer <name> --comment "..."
|
||||||
make credential-change-sync-decision CREDENTIAL_CHANGE=CCR-2026-0001
|
make credential-change-sync-decision CREDENTIAL_CHANGE=CCR-2026-0001
|
||||||
make credential-change-apply-plan CREDENTIAL_CHANGE=CCR-2026-0001
|
make credential-change-apply-plan CREDENTIAL_CHANGE=CCR-2026-0001
|
||||||
|
make credential-change-operator-commands CREDENTIAL_CHANGE=CCR-2026-0001
|
||||||
```
|
```
|
||||||
|
|
||||||
`apply-plan` is intentionally guarded: it refuses anything not approved and
|
`apply-plan` and `operator-commands` are intentionally guarded: they refuse
|
||||||
refuses unconfirmed auth bindings.
|
anything not approved and refuse unconfirmed auth bindings. `operator-commands`
|
||||||
|
renders the reviewed non-secret `bao policy write` and `bao write auth/.../role`
|
||||||
|
commands for a platform operator; the actual secret value is still provisioned
|
||||||
|
through approved OpenBao/operator custody only.
|
||||||
|
|
||||||
The same operations can be exposed through chat by having the agent create the
|
The same operations can be exposed through chat by having the agent create the
|
||||||
proposal, show the rendered summary, then call the CLI only after the human
|
proposal, show the rendered summary, then call the CLI only after the human
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
@ -374,6 +375,46 @@ def render_plan(ccr: dict[str, Any]) -> str:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def shell_kv_arg(key: str, value: Any) -> str:
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
encoded = "true" if value else "false"
|
||||||
|
elif value is None:
|
||||||
|
encoded = ""
|
||||||
|
else:
|
||||||
|
encoded = str(value)
|
||||||
|
return shlex.quote(f"{key}={encoded}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_operator_commands(ccr: dict[str, Any]) -> str:
|
||||||
|
openbao = ccr["openbao"]
|
||||||
|
auth = openbao["auth"]
|
||||||
|
auth_path = f"auth/{auth['mount']}/role/{auth['role']}"
|
||||||
|
payload = auth_payload(ccr)
|
||||||
|
role_args = " ".join(
|
||||||
|
shell_kv_arg(key, payload[key]) for key in sorted(payload)
|
||||||
|
)
|
||||||
|
secret_args = " ".join(
|
||||||
|
shlex.quote(f"{field}=<enter-through-approved-custody>")
|
||||||
|
for field in openbao["fields"]
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
f"# Operator handoff for {ccr['id']}: {ccr['title']}",
|
||||||
|
"# Run from the railiance-platform repo with an approved OpenBao operator token.",
|
||||||
|
"set -euo pipefail",
|
||||||
|
f"bao policy write {shlex.quote(openbao['policy_name'])} {shlex.quote(openbao['policy_file'])}",
|
||||||
|
f"bao write {shlex.quote(auth_path)} {role_args}",
|
||||||
|
"",
|
||||||
|
"# Secret provisioning remains under approved OpenBao/operator custody.",
|
||||||
|
"# Do not paste secret values into Git, State Hub, workplans, logs, or chat.",
|
||||||
|
f"# bao kv put {shlex.quote(openbao['kv_path'])} {secret_args}",
|
||||||
|
"",
|
||||||
|
"# After provisioning, run positive and negative verification without printing secret values.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def validate_or_exit(path: Path) -> tuple[dict[str, Any], list[str]]:
|
def validate_or_exit(path: Path) -> tuple[dict[str, Any], list[str]]:
|
||||||
ccr, errors, warnings = validate_ccr(path)
|
ccr, errors, warnings = validate_ccr(path)
|
||||||
for warning in warnings:
|
for warning in warnings:
|
||||||
|
|
@ -645,18 +686,30 @@ def command_status(args: argparse.Namespace) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def require_apply_ready(ccr: dict[str, Any], command_name: str) -> None:
|
||||||
|
if ccr.get("status") not in APPLY_ALLOWED_STATUSES:
|
||||||
|
fail(f"{command_name} requires status approved, got {ccr.get('status')}")
|
||||||
|
auth = ccr["openbao"]["auth"]
|
||||||
|
if auth.get("bound_claims_confirmed") is not True:
|
||||||
|
fail(f"{command_name} requires openbao.auth.bound_claims_confirmed=true")
|
||||||
|
|
||||||
|
|
||||||
def command_apply_plan(args: argparse.Namespace) -> int:
|
def command_apply_plan(args: argparse.Namespace) -> int:
|
||||||
path = resolve_ccr(args.ref)
|
path = resolve_ccr(args.ref)
|
||||||
ccr, _warnings = validate_or_exit(path)
|
ccr, _warnings = validate_or_exit(path)
|
||||||
if ccr.get("status") not in APPLY_ALLOWED_STATUSES:
|
require_apply_ready(ccr, "apply-plan")
|
||||||
fail(f"apply-plan requires status approved, got {ccr.get('status')}")
|
|
||||||
auth = ccr["openbao"]["auth"]
|
|
||||||
if auth.get("bound_claims_confirmed") is not True:
|
|
||||||
fail("apply-plan requires openbao.auth.bound_claims_confirmed=true")
|
|
||||||
print(render_plan(ccr))
|
print(render_plan(ccr))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_operator_commands(args: argparse.Namespace) -> int:
|
||||||
|
path = resolve_ccr(args.ref)
|
||||||
|
ccr, _warnings = validate_or_exit(path)
|
||||||
|
require_apply_ready(ccr, "operator-commands")
|
||||||
|
print(render_operator_commands(ccr))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def command_decision(args: argparse.Namespace, status: str) -> int:
|
def command_decision(args: argparse.Namespace, status: str) -> int:
|
||||||
path = resolve_ccr(args.ref)
|
path = resolve_ccr(args.ref)
|
||||||
append_decision(path, status, args.reviewer, args.comment)
|
append_decision(path, status, args.reviewer, args.comment)
|
||||||
|
|
@ -713,6 +766,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
apply_plan.add_argument("ref")
|
apply_plan.add_argument("ref")
|
||||||
apply_plan.set_defaults(func=command_apply_plan)
|
apply_plan.set_defaults(func=command_apply_plan)
|
||||||
|
|
||||||
|
operator_commands = sub.add_parser(
|
||||||
|
"operator-commands",
|
||||||
|
help="Render reviewed non-secret OpenBao commands for an approved CCR",
|
||||||
|
)
|
||||||
|
operator_commands.add_argument("ref")
|
||||||
|
operator_commands.set_defaults(func=command_operator_commands)
|
||||||
|
|
||||||
for name, status in (
|
for name, status in (
|
||||||
("approve", "approved"),
|
("approve", "approved"),
|
||||||
("deny", "denied"),
|
("deny", "denied"),
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,36 @@ class CredentialChangeTests(unittest.TestCase):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
credential_change.command_apply_plan(type("Args", (), {"ref": str(self.issue_core)})())
|
credential_change.command_apply_plan(type("Args", (), {"ref": str(self.issue_core)})())
|
||||||
|
|
||||||
|
def test_operator_commands_render_non_secret_policy_and_role_handoff(self) -> None:
|
||||||
|
ccr, errors, warnings = credential_change.validate_ccr(self.sample)
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
rendered = credential_change.render_operator_commands(ccr)
|
||||||
|
self.assertIn(
|
||||||
|
"bao policy write workload-kv-read-whynot-design-npm-publish",
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"bao write auth/netkingdom/role/whynot-design-workload-kv-read",
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'bound_claims={"groups":["whynot-design"]}',
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"# bao kv put platform/workloads/whynot-design/whynot-design/npm-publish",
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertIn("NPM_AUTH_TOKEN=<enter-through-approved-custody>", rendered)
|
||||||
|
self.assertNotIn("npm_", rendered)
|
||||||
|
|
||||||
|
def test_operator_commands_refuse_unapproved_ccr(self) -> None:
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
credential_change.command_operator_commands(
|
||||||
|
type("Args", (), {"ref": str(self.issue_core)})()
|
||||||
|
)
|
||||||
|
|
||||||
def test_approve_records_comment_but_unconfirmed_claim_still_blocks_apply(self) -> None:
|
def test_approve_records_comment_but_unconfirmed_claim_still_blocks_apply(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
tmp_path = Path(tmp)
|
tmp_path = Path(tmp)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue