From aee0dcefad7fbdc5fce76991a89db7083af79061 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 27 Jun 2026 23:30:29 +0200 Subject: [PATCH] Add credential lane readiness proposals --- Makefile | 6 + ...-2026-0001-whynot-design-npm-publish.yaml} | 11 +- ...026-0002-issue-core-ingestion-api-key.yaml | 80 ++++++++++ ...6-0003-llm-connect-openrouter-api-key.yaml | 78 ++++++++++ docs/credential-change-approval.md | 11 +- docs/workload-kv-access-lanes.md | 21 ++- .../workload-kv-read-issue-core-runtime.hcl | 7 + ...d-kv-read-llm-connect-provider-secrets.hcl | 7 + schemas/credential-change-request.schema.yaml | 16 ++ scripts/credential-change.py | 145 +++++++++++++++++- tests/test_credential_change.py | 31 +++- ...LIANCE-WP-0006-workload-kv-access-lanes.md | 17 +- ...007-credential-change-approval-workflow.md | 20 ++- 13 files changed, 425 insertions(+), 25 deletions(-) rename credential-change-requests/{CCR-2026-0001-whynot-design-npm-token.yaml => CCR-2026-0001-whynot-design-npm-publish.yaml} (86%) create mode 100644 credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml create mode 100644 credential-change-requests/CCR-2026-0003-llm-connect-openrouter-api-key.yaml create mode 100644 openbao/policies/workload-kv-read-issue-core-runtime.hcl create mode 100644 openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl diff --git a/Makefile b/Makefile index 209518c..13a35b3 100644 --- a/Makefile +++ b/Makefile @@ -200,6 +200,12 @@ credential-change-render: ## Render a credential change request review summary credential-change-plan: ## Render a credential change request apply plan for review scripts/credential-change.py plan $(CREDENTIAL_CHANGE) +credential-change-status: ## Render credential change request readiness status + scripts/credential-change.py status $(CREDENTIAL_CHANGE) + +credential-change-status-json: ## Render credential change request readiness status as JSON + scripts/credential-change.py status --json $(CREDENTIAL_CHANGE) + credential-change-apply-plan: ## Render approved-only operator apply plan scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE) diff --git a/credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml b/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml similarity index 86% rename from credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml rename to credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml index 499e6cf..bf36234 100644 --- a/credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml +++ b/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml @@ -8,7 +8,7 @@ created: "2026-06-27" updated: "2026-06-27" requester: agent: ops-warden - message_id: "551031d1-335e-4db8-9535-820fea52d0a3" + message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" reason: "Allow ops-warden to proxy caller-scoped access to whynot-design's npm publish token." review: required: true @@ -43,13 +43,17 @@ openbao: ttl: 15m access_frontdoor: type: ops-warden - catalog_id: whynot-design-npm-token - selector: "npm auth token" + catalog_id: whynot-design-npm-publish + selector: "npm publish token" + command: "warden access whynot-design-npm-publish --exec -- npm publish" + resolvable: false + readiness: template activation: "draft-until-ccr-verified" risk: classification: high notes: - "Grants read access to the credential used to publish npm packages." + - "Uses a publish-specific catalog id; a future read-only npm token must use a separate catalog id." - "The proposed OIDC bound claim must be confirmed before apply." - "ops-warden must proxy the read as the caller and must not retain the token value." verification: @@ -70,3 +74,4 @@ state_hub: workplan_id: RAILIANCE-WP-0007 related_workplan_id: RAILIANCE-WP-0006 ops_warden_reply_message_id: "b175c561-7858-43f5-a309-949b0dede1b4" + ops_warden_batch_message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" diff --git a/credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml b/credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml new file mode 100644 index 0000000..8f60f6c --- /dev/null +++ b/credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml @@ -0,0 +1,80 @@ +id: CCR-2026-0002 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: "issue-core runtime ingestion key lane" +status: proposed +created: "2026-06-27" +updated: "2026-06-27" +requester: + agent: ops-warden + message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" + reason: "Confirm and provision the issue-core workload KV lane requested in the ops-warden batch." +review: + required: true + required_approvers: + - platform-operator + - issue-core-owner + comments: [] +target: + domain: financials + tenant: issue-core + workload: issue-core + environment: production + purpose: "issue-core runtime ingestion through OpenBao workload KV and External Secrets" +openbao: + mount: platform + kv_path: platform/workloads/issue-core/issue-core/issue-core-runtime + fields: + - ISSUE_CORE_API_KEY + - GITEA_BACKEND_TOKEN + policy_name: workload-kv-read-issue-core-runtime + policy_file: openbao/policies/workload-kv-read-issue-core-runtime.hcl + auth: + method: kubernetes + mount: kubernetes + role: issue-core-runtime-workload-kv-read + bound_claims: + service_account_names: + - issue-core + service_account_namespaces: + - issue-core + bound_claims_confirmed: false + policies: + - workload-kv-read-issue-core-runtime + ttl: 15m +access_frontdoor: + type: ops-warden + catalog_id: issue-core-ingestion-api-key + selector: "issue-core ingestion API key" + command: "warden access issue-core-ingestion-api-key --fetch ISSUE_CORE_API_KEY" + resolvable: false + readiness: template + activation: "draft-until-ccr-verified" +delivery: + surface: external-secrets + target: "issue-core namespace" +risk: + classification: high + notes: + - "Grants read access to issue-core runtime ingestion credentials." + - "GITEA_BACKEND_TOKEN is included because ops-warden asked to confirm whether it is used; remove it before approval if issue-core does not require it." + - "The Kubernetes service account and namespace binding must be confirmed before apply." + - "ops-warden must proxy reads as the caller and must not retain token values." +verification: + positive: + - "Approved issue-core service account can read the configured fields through OpenBao or External Secrets without printing values." + negative: + - "A service account outside the approved issue-core binding cannot read the path." + activation_conditions: + - "Policy applied with platform-admin/operator authority." + - "Kubernetes auth role bound to the confirmed issue-core service account and namespace." + - "Secret values provisioned directly in OpenBao through approved operator custody." + - "Positive and negative verification recorded with non-secret audit ids or timestamps." +lifecycle: + deactivate: "Disable ops-warden catalog entry and remove or detach auth role policy." + rotate: "Replace issue-core runtime secret values directly in OpenBao and record non-secret rotation evidence." + compromised: "Immediately deactivate access front door, rotate affected values, record blast-radius notes, and open incident follow-up tasks." +state_hub: + workplan_id: RAILIANCE-WP-0007 + ops_warden_batch_message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" diff --git a/credential-change-requests/CCR-2026-0003-llm-connect-openrouter-api-key.yaml b/credential-change-requests/CCR-2026-0003-llm-connect-openrouter-api-key.yaml new file mode 100644 index 0000000..7c3986c --- /dev/null +++ b/credential-change-requests/CCR-2026-0003-llm-connect-openrouter-api-key.yaml @@ -0,0 +1,78 @@ +id: CCR-2026-0003 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: "llm-connect OpenRouter provider key lane" +status: proposed +created: "2026-06-27" +updated: "2026-06-27" +requester: + agent: ops-warden + message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" + reason: "Confirm and provision the llm-connect OpenRouter workload KV lane requested in the ops-warden batch." +review: + required: true + required_approvers: + - platform-operator + - activity-core-owner + comments: [] +target: + domain: financials + tenant: activity-core + workload: llm-connect + environment: production + purpose: "llm-connect provider access through OpenBao workload KV and External Secrets" +openbao: + mount: platform + kv_path: platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets + fields: + - OPENROUTER_API_KEY + policy_name: workload-kv-read-llm-connect-provider-secrets + policy_file: openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl + auth: + method: kubernetes + mount: kubernetes + role: llm-connect-provider-secrets-read + bound_claims: + service_account_names: + - llm-connect + service_account_namespaces: + - activity-core + bound_claims_confirmed: false + policies: + - workload-kv-read-llm-connect-provider-secrets + ttl: 15m +access_frontdoor: + type: ops-warden + catalog_id: llm-connect-openrouter-api-key + selector: "llm-connect OpenRouter API key" + command: "warden access llm-connect-openrouter-api-key --fetch OPENROUTER_API_KEY" + resolvable: false + readiness: template + activation: "draft-until-ccr-verified" +delivery: + surface: external-secrets + target: "Secret llm-connect-provider-secrets in the activity-core namespace" +risk: + classification: high + notes: + - "Grants read access to the provider key used by llm-connect for OpenRouter requests." + - "The Kubernetes service account and namespace binding must be confirmed before apply." + - "ops-warden must proxy reads as the caller and must not retain token values." +verification: + positive: + - "Approved llm-connect service account can read field OPENROUTER_API_KEY through OpenBao or External Secrets without printing the value." + negative: + - "A service account outside the approved activity-core/llm-connect binding cannot read the path." + activation_conditions: + - "Policy applied with platform-admin/operator authority." + - "Kubernetes auth role bound to the confirmed llm-connect service account and namespace." + - "Secret value provisioned directly in OpenBao through approved operator custody." + - "Positive and negative verification recorded with non-secret audit ids or timestamps." +lifecycle: + deactivate: "Disable ops-warden catalog entry and remove or detach auth role policy." + rotate: "Replace OPENROUTER_API_KEY directly in OpenBao and record non-secret rotation evidence." + compromised: "Immediately deactivate access front door, rotate the provider key, record blast-radius notes, and open incident follow-up tasks." +state_hub: + workplan_id: RAILIANCE-WP-0007 + ops_warden_batch_message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" diff --git a/docs/credential-change-approval.md b/docs/credential-change-approval.md index 61799fe..d95499c 100644 --- a/docs/credential-change-approval.md +++ b/docs/credential-change-approval.md @@ -50,6 +50,8 @@ The CCR must be non-secret. It may contain: - proposed auth bindings and bound claims; - delivery surface such as ops-warden, External Secrets, CSI, or direct caller fetch; +- machine-readable front-door readiness, including `readiness` and + `resolvable`; - risk classification and approval requirements; - generated apply plan; - verification plan; @@ -109,7 +111,9 @@ Auth binding: netkingdom OIDC role whynot-design-workload-kv-read bound claim: groups includes whynot-design Access front door: - ops-warden whynot-design-npm-token + ops-warden whynot-design-npm-publish + readiness: template + resolvable: false Risk: grants read access to npm publish credential Checks: @@ -143,6 +147,8 @@ The first implemented CLI slice is: make credential-change-validate make credential-change-render CREDENTIAL_CHANGE=CCR-2026-0001 make credential-change-plan CREDENTIAL_CHANGE=CCR-2026-0001 +make credential-change-status CREDENTIAL_CHANGE=CCR-2026-0001 +make credential-change-status-json CREDENTIAL_CHANGE=CCR-2026-0001 scripts/credential-change.py approve CCR-2026-0001 --reviewer --comment "..." scripts/credential-change.py deny CCR-2026-0001 --reviewer --comment "..." scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer --comment "..." @@ -193,7 +199,8 @@ For draft requests, ops-warden may create a draft catalog entry that points to the CCR, but it should not activate the entry until the CCR is verified. For `warden access --fetch` / `--exec`, the catalog should include the CCR id -and refuse active use when the CCR state is not `active`. +and refuse active use when the CCR state is not `active`, `readiness` is not +`ready`, or `resolvable` is not `true`. ## Interactive Runbook Role diff --git a/docs/workload-kv-access-lanes.md b/docs/workload-kv-access-lanes.md index 5f8ae15..136fd97 100644 --- a/docs/workload-kv-access-lanes.md +++ b/docs/workload-kv-access-lanes.md @@ -16,15 +16,19 @@ The first lane is for ops-warden `warden access --fetch` / `--exec`. ## whynot-design npm Publish Token -Ops-warden request: +Ops-warden original request: `551031d1-335e-4db8-9535-820fea52d0a3` +Ops-warden batch follow-up: +`fe5b1696-8956-4bd5-9d6f-dbde1901a076` + | Item | Value | | --- | --- | -| ops-warden catalog id | `whynot-design-npm-token` | +| ops-warden catalog id | `whynot-design-npm-publish` | | KV mount | `platform` | | OpenBao CLI path | `platform/workloads/whynot-design/whynot-design/npm-publish` | | Secret field | `NPM_AUTH_TOKEN` | +| Front-door readiness | `template`, `resolvable=false` until CCR verification | | Read policy | `workload-kv-read-whynot-design-npm-publish` | | Policy file | `openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl` | | OIDC auth mount | `netkingdom` | @@ -38,12 +42,18 @@ Expected caller login shape: bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read ``` -Expected fetch shape: +Expected OpenBao fetch shape: ```bash bao kv get -field=NPM_AUTH_TOKEN platform/workloads/whynot-design/whynot-design/npm-publish ``` +Expected ops-warden exec shape after activation: + +```bash +warden access whynot-design-npm-publish --exec -- npm publish +``` + The fetch command returns the secret value to the authenticated caller. Run it only in an attended shell or through a process that consumes the value without logging it. @@ -139,7 +149,7 @@ Negative verification: Send ops-warden only these pointers: ```text -catalog id: whynot-design-npm-token +catalog id: whynot-design-npm-publish mount: platform path: platform/workloads/whynot-design/whynot-design/npm-publish field: NPM_AUTH_TOKEN @@ -151,4 +161,5 @@ runbook: docs/workload-kv-access-lanes.md ``` Until live provisioning and verification are complete, ops-warden should keep -the catalog entry in `draft` or equivalent non-active state. +the catalog entry in `template`/`draft` or equivalent non-active state with +`resolvable=false`. diff --git a/openbao/policies/workload-kv-read-issue-core-runtime.hcl b/openbao/policies/workload-kv-read-issue-core-runtime.hcl new file mode 100644 index 0000000..cdb406d --- /dev/null +++ b/openbao/policies/workload-kv-read-issue-core-runtime.hcl @@ -0,0 +1,7 @@ +path "platform/data/workloads/issue-core/issue-core/issue-core-runtime" { + capabilities = ["read"] +} + +path "platform/metadata/workloads/issue-core/issue-core/issue-core-runtime" { + capabilities = ["read"] +} diff --git a/openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl b/openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl new file mode 100644 index 0000000..393f7f3 --- /dev/null +++ b/openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl @@ -0,0 +1,7 @@ +path "platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets" { + capabilities = ["read"] +} + +path "platform/metadata/workloads/activity-core/llm-connect/llm-connect-provider-secrets" { + capabilities = ["read"] +} diff --git a/schemas/credential-change-request.schema.yaml b/schemas/credential-change-request.schema.yaml index f7dbada..e3bbf94 100644 --- a/schemas/credential-change-request.schema.yaml +++ b/schemas/credential-change-request.schema.yaml @@ -71,6 +71,8 @@ workload_kv_read: access_frontdoor: - type - catalog_id + - readiness + - resolvable verification: - positive - negative @@ -80,6 +82,20 @@ workload_kv_read: - rotate - compromised +access_frontdoor_readiness: + allowed: + - template + - pending-review + - approved-pending-apply + - applied-pending-verify + - ready + - disabled + - compromised + resolvable_true_requires_status: active + ops_warden_should_consume_only: + readiness: ready + resolvable: true + guardrails: apply_plan_requires_status: - approved diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 493da5c..30c0551 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -46,6 +46,15 @@ SECRET_MARKERS = [ "sk-", ] DISALLOWED_POLICY_NAMES = {"root", "platform-admin"} +FRONTDOOR_READINESS = { + "template", + "pending-review", + "approved-pending-apply", + "applied-pending-verify", + "ready", + "disabled", + "compromised", +} SAFE_ID_RE = re.compile(r"^[A-Z0-9][A-Z0-9_.-]*$") TTL_RE = re.compile(r"^[1-9][0-9]*[smhd]$") @@ -177,6 +186,19 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: frontdoor = require_object(ccr.get("access_frontdoor"), "access_frontdoor", errors) require_string(frontdoor.get("type"), "access_frontdoor.type", errors) require_string(frontdoor.get("catalog_id"), "access_frontdoor.catalog_id", errors) + readiness = require_string(frontdoor.get("readiness"), "access_frontdoor.readiness", errors) + if readiness and readiness not in FRONTDOOR_READINESS: + errors.append( + f"access_frontdoor.readiness must be one of {sorted(FRONTDOOR_READINESS)}" + ) + resolvable = frontdoor.get("resolvable") + if not isinstance(resolvable, bool): + errors.append("access_frontdoor.resolvable must be a boolean") + if resolvable is True and ccr.get("status") != "active": + errors.append("access_frontdoor.resolvable=true requires status active") + command = frontdoor.get("command") + if command is not None and not isinstance(command, str): + errors.append("access_frontdoor.command must be a string when present") risk = require_object(ccr.get("risk"), "risk", errors) require_string(risk.get("classification"), "risk.classification", errors) @@ -258,8 +280,11 @@ def render_summary(ccr: dict[str, Any], warnings: list[str]) -> str: f" confirmed: {auth.get('bound_claims_confirmed') is True}", "Access front door:", f" {frontdoor['type']} {frontdoor['catalog_id']}", - f"Risk: {risk['classification']}", + f" readiness: {frontdoor.get('readiness')} resolvable={frontdoor.get('resolvable') is True}", ] + if frontdoor.get("command"): + lines.append(f" command: {frontdoor['command']}") + lines.append(f"Risk: {risk['classification']}") for note in risk.get("notes", []): lines.append(f" - {note}") lines.append("Checks:") @@ -298,8 +323,19 @@ def generated_policy_hcl(ccr: dict[str, Any]) -> str: def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]: auth = ccr["openbao"]["auth"] + if auth["method"] == "kubernetes": + claims = auth["bound_claims"] + return { + "bound_service_account_names": claims.get("service_account_names", []), + "bound_service_account_namespaces": claims.get( + "service_account_namespaces", [] + ), + "policies": ",".join(auth["policies"]), + "ttl": auth.get("ttl", "15m"), + } + payload: dict[str, Any] = { - "role_type": "oidc" if auth["method"] == "oidc" else "jwt", + "role_type": "oidc", "user_claim": auth.get("user_claim", "sub"), "policies": ",".join(auth["policies"]), "ttl": auth.get("ttl", "15m"), @@ -347,6 +383,91 @@ def validate_or_exit(path: Path) -> tuple[dict[str, Any], list[str]]: return ccr, warnings +def apply_blockers(ccr: dict[str, Any]) -> list[str]: + blockers: list[str] = [] + if ccr.get("status") not in APPLY_ALLOWED_STATUSES: + blockers.append(f"apply requires status approved, got {ccr.get('status')}") + if ccr["openbao"]["auth"].get("bound_claims_confirmed") is not True: + blockers.append("apply requires confirmed OpenBao auth binding") + return blockers + + +def frontdoor_blockers(ccr: dict[str, Any]) -> list[str]: + frontdoor = ccr["access_frontdoor"] + blockers: list[str] = [] + if ccr.get("status") != "active": + blockers.append(f"front door requires CCR status active, got {ccr.get('status')}") + if frontdoor.get("readiness") != "ready": + blockers.append( + f"front door readiness must be ready, got {frontdoor.get('readiness')}" + ) + if frontdoor.get("resolvable") is not True: + blockers.append("front door is marked resolvable=false") + return blockers + + +def status_payload(ccr: dict[str, Any], warnings: list[str]) -> dict[str, Any]: + apply_blocked_by = apply_blockers(ccr) + frontdoor_blocked_by = frontdoor_blockers(ccr) + frontdoor = ccr["access_frontdoor"] + openbao = ccr["openbao"] + auth = openbao["auth"] + return { + "id": ccr["id"], + "title": ccr["title"], + "status": ccr["status"], + "request_type": ccr["request_type"], + "apply_allowed": not apply_blocked_by, + "apply_blockers": apply_blocked_by, + "frontdoor_resolvable": not frontdoor_blocked_by, + "frontdoor_blockers": frontdoor_blocked_by, + "warnings": warnings, + "openbao": { + "mount": openbao["mount"], + "kv_path": openbao["kv_path"], + "fields": openbao["fields"], + "policy_name": openbao["policy_name"], + "auth_mount": auth["mount"], + "auth_method": auth["method"], + "auth_role": auth["role"], + "bound_claims_confirmed": auth.get("bound_claims_confirmed") is True, + }, + "access_frontdoor": { + "type": frontdoor["type"], + "catalog_id": frontdoor["catalog_id"], + "readiness": frontdoor.get("readiness"), + "resolvable": frontdoor.get("resolvable") is True, + "command": frontdoor.get("command"), + }, + } + + +def render_status(payload: dict[str, Any]) -> str: + lines = [ + f"CCR: {payload['id']} ({payload['status']})", + f"Catalog: {payload['access_frontdoor']['catalog_id']}", + f"Readiness: {payload['access_frontdoor']['readiness']}", + f"Resolvable: {payload['frontdoor_resolvable']}", + f"Apply allowed: {payload['apply_allowed']}", + ] + command = payload["access_frontdoor"].get("command") + if command: + lines.append(f"Command: {command}") + if payload["apply_blockers"]: + lines.append("Apply blockers:") + for blocker in payload["apply_blockers"]: + lines.append(f" - {blocker}") + if payload["frontdoor_blockers"]: + lines.append("Front-door blockers:") + for blocker in payload["frontdoor_blockers"]: + lines.append(f" - {blocker}") + if payload["warnings"]: + lines.append("Warnings:") + for warning in payload["warnings"]: + lines.append(f" - {warning}") + return "\n".join(lines) + + def append_decision(path: Path, status: str, reviewer: str, comment: str) -> None: ccr, _warnings = validate_or_exit(path) review = ccr.setdefault("review", {}) @@ -399,6 +520,21 @@ def command_plan(args: argparse.Namespace) -> int: return 0 +def command_status(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, errors, warnings = validate_ccr(path) + if errors: + for error in errors: + print(f"[FAIL] {path.name}: {error}", file=sys.stderr) + return 1 + payload = status_payload(ccr, warnings) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(render_status(payload)) + return 0 + + def command_apply_plan(args: argparse.Namespace) -> int: path = resolve_ccr(args.ref) ccr, _warnings = validate_or_exit(path) @@ -436,6 +572,11 @@ def build_parser() -> argparse.ArgumentParser: plan.add_argument("ref") plan.set_defaults(func=command_plan) + status = sub.add_parser("status", help="Render machine-readable readiness status") + status.add_argument("ref") + status.add_argument("--json", action="store_true") + status.set_defaults(func=command_status) + apply_plan = sub.add_parser( "apply-plan", help="Render an operator apply plan only for approved CCRs" ) diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index 792973f..16efcc3 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -22,7 +22,11 @@ class CredentialChangeTests(unittest.TestCase): def setUp(self) -> None: self.sample = ( REPO_DIR - / "credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml" + / "credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml" + ) + self.issue_core = ( + REPO_DIR + / "credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml" ) def test_sample_ccr_validates_with_bound_claim_warning(self) -> None: @@ -30,13 +34,38 @@ class CredentialChangeTests(unittest.TestCase): self.assertEqual(errors, []) self.assertIn("bound claim is not confirmed", warnings[0]) + def test_all_repo_ccrs_validate(self) -> None: + for path in sorted((REPO_DIR / "credential-change-requests").glob("*.yaml")): + with self.subTest(path=path.name): + _ccr, errors, _warnings = credential_change.validate_ccr(path) + self.assertEqual(errors, []) + def test_render_summary_contains_review_fields(self) -> None: ccr, _errors, warnings = credential_change.validate_ccr(self.sample) rendered = credential_change.render_summary(ccr, warnings) self.assertIn("whynot-design npm publish token lane", rendered) self.assertIn("platform/workloads/whynot-design/whynot-design/npm-publish", rendered) + self.assertIn("whynot-design-npm-publish", rendered) + self.assertIn("readiness: template resolvable=False", rendered) self.assertIn("approve | deny | needs_changes", rendered) + def test_status_payload_marks_template_not_resolvable(self) -> None: + ccr, _errors, warnings = credential_change.validate_ccr(self.sample) + payload = credential_change.status_payload(ccr, warnings) + self.assertFalse(payload["apply_allowed"]) + self.assertFalse(payload["frontdoor_resolvable"]) + self.assertEqual(payload["access_frontdoor"]["readiness"], "template") + self.assertEqual(payload["access_frontdoor"]["catalog_id"], "whynot-design-npm-publish") + self.assertIn("front door is marked resolvable=false", payload["frontdoor_blockers"]) + + def test_kubernetes_auth_payload_uses_service_account_bounds(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.issue_core) + self.assertEqual(errors, []) + payload = credential_change.auth_payload(ccr) + self.assertEqual(payload["bound_service_account_names"], ["issue-core"]) + self.assertEqual(payload["bound_service_account_namespaces"], ["issue-core"]) + self.assertNotIn("bound_claims", payload) + def test_apply_plan_refuses_unapproved_ccr(self) -> None: with self.assertRaises(SystemExit): credential_change.command_apply_plan(type("Args", (), {"ref": str(self.sample)})()) diff --git a/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md b/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md index c760034..c6fa682 100644 --- a/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md +++ b/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md @@ -30,7 +30,7 @@ holding secret values itself. The immediate request is for `whynot-design` to retrieve its npm publish token. The path must be concrete, policy-scoped, and documented so the ops-warden catalog can replace the current unresolved template path with a live -`whynot-design-npm-token` entry. +`whynot-design-npm-publish` entry. No task in this workplan may paste, commit, log, or send secret values through Git, State Hub, chat, prompts, or workplan text. @@ -47,7 +47,7 @@ Ops-warden message `551031d1-335e-4db8-9535-820fea52d0a3` asks - the flex-auth policy reference, if pre-approval is required. Once these pointers are live, ops-warden will add a dedicated -`whynot-design-npm-token` access catalog entry and a playbook, then notify +`whynot-design-npm-publish` access catalog entry and a playbook, then notify whynot-design. ## Proposed Contract @@ -248,7 +248,7 @@ Acceptance: - The State Hub reply to ops-warden includes only path, field, KV mount, OIDC role, policy name/path, optional flex-auth ref, and runbook location. -- Ops-warden confirms the `whynot-design-npm-token` catalog entry no longer +- Ops-warden confirms the `whynot-design-npm-publish` catalog entry no longer contains unresolved placeholders. - `warden access "npm auth token" --fetch` or the agreed exact selector resolves to the whynot-design lane and proxies the read as the caller. @@ -281,10 +281,11 @@ Acceptance: - If batching is deferred, notify ops-warden that this workplan will deliver whynot-design first and leave the sibling entries for separate planning. -**2026-06-27:** Deferred sibling lanes (`issue-core-ingestion-api-key` and -`openrouter-llm-connect`) so the whynot-design npm token request can be serviced -first. They should get concrete tasks or a follow-up workplan after this access -lane pattern is validated. +**2026-06-27:** Initially deferred sibling lanes (`issue-core-ingestion-api-key` +and `openrouter-llm-connect`) so the whynot-design npm token request could be +serviced first. The later ops-warden batch follow-up is now represented as +proposed CCRs in `RAILIANCE-WP-0007`, still unapproved and unresolvable until +human review and verification. ## Exit Criteria @@ -294,5 +295,5 @@ lane pattern is validated. ops-warden without ops-warden storing the value. - Unauthorized reads are denied. - ops-warden has enough non-secret pointers to activate - `whynot-design-npm-token`. + `whynot-design-npm-publish`. - No secret values appear in Git, State Hub, chat, prompts, logs, or workplans. diff --git a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md index aa4d94a..18b6543 100644 --- a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md +++ b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md @@ -119,7 +119,7 @@ Acceptance: **2026-06-27:** Added `schemas/credential-change-request.schema.yaml`, the `credential-change-requests/` storage directory, and -`credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml` as the +`credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml` as the first non-secret CCR fixture. The whynot CCR is intentionally `proposed` and marks the bound claim as unconfirmed, so apply is blocked until review. @@ -148,7 +148,9 @@ Acceptance: plus Make targets `credential-change-validate` and `credential-change-render`. Validation rejects secret-looking markers and broad/unsafe request shapes; render produces the chat/State Hub review summary and highlights unconfirmed bound -claims. Unit coverage lives in `tests/test_credential_change.py`. +claims. CCRs now also carry machine-readable front-door readiness fields: +`access_frontdoor.readiness` and `access_frontdoor.resolvable`. Unit coverage +lives in `tests/test_credential_change.py`. ## T04 - Generate OpenBao apply plans from approved CCRs @@ -201,8 +203,10 @@ Acceptance: **2026-06-27:** Added file-backed `approve`, `deny`, and `needs-changes` commands that require reviewer and comment text and append non-secret review -comments to the CCR. Remaining T05 work is State Hub decision-event emission and -tighter chat integration. +comments to the CCR. Added `status` plus Make targets +`credential-change-status` and `credential-change-status-json` so ops-warden can +consume `readiness`/`resolvable` without scraping prose. Remaining T05 work is +State Hub decision-event emission and tighter chat integration. ## T06 - Build an interactive runbook for apply and verify @@ -248,6 +252,14 @@ Acceptance: can be rendered for review. It remains proposed/unapproved with unconfirmed bound claims, so live apply and ops-warden activation are correctly blocked. +**2026-06-27:** Converted the ops-warden batch follow-up +`fe5b1696-8956-4bd5-9d6f-dbde1901a076` into three proposed CCRs: +`CCR-2026-0001` for `whynot-design-npm-publish`, `CCR-2026-0002` for +`issue-core-ingestion-api-key`, and `CCR-2026-0003` for +`llm-connect-openrouter-api-key`. All three are explicitly `readiness: template` +and `resolvable: false` until owner confirmation, approval, OpenBao apply, +secret provisioning, and verification are complete. + ## T08 - Add deactivation, rotation, and compromise flows ```task