diff --git a/Makefile b/Makefile index 3fa2193..17fd440 100644 --- a/Makefile +++ b/Makefile @@ -25,8 +25,13 @@ ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap ARGOCD_REPOSITORY_SECRET ?= CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml CREDENTIAL_CHANGE ?= CCR-2026-0001 +CREDENTIAL_CHANGE_EVIDENCE_ARGS ?= +CREDENTIAL_CHANGE_LIFECYCLE_ACTION ?= deactivate +CREDENTIAL_CHANGE_LIFECYCLE_ARGS ?= +CREDENTIAL_CHANGE_IMPORT_ARGS ?= STATE_HUB_URL ?= http://127.0.0.1:8000 OPENBAO_TOKEN_GRANT_ARGS ?= +OPENBAO_CREDENTIAL_CHANGE_APPLIER_ARGS ?= OPENBAO_WORKLOAD_KV_ARGS ?= CREDENTIAL_HELPER_GLOBAL_ARGS ?= CREDENTIAL_HELPER_ARGS ?= @@ -171,6 +176,9 @@ openbao-configure-external-secrets-issue-core: ## Configure OpenBao policy/role OPENBAO_RELEASE=$(OPENBAO_RELEASE) ESO_NAMESPACE=$(EXTERNAL_SECRETS_NAMESPACE) \ scripts/openbao-apply-external-secrets-issue-core.sh +openbao-configure-external-secrets-activity-core: ## Configure OpenBao policy/role for activity-core ESO lane + KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) OPENBAO_RELEASE=$(OPENBAO_RELEASE) ESO_NAMESPACE=$(EXTERNAL_SECRETS_NAMESPACE) OPENBAO_ESO_ROLE=external-secrets-activity-core OPENBAO_ESO_POLICY=workload-kv-read-llm-connect-provider-secrets POLICY_FILE='$(CURDIR)/openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl' scripts/openbao-apply-external-secrets-issue-core.sh + openbao-workload-kv-lanes-dry-run: ## Dry-run OpenBao workload KV read-lane policy apply scripts/openbao-apply-workload-kv-lanes.sh --dry-run $(OPENBAO_WORKLOAD_KV_ARGS) @@ -201,6 +209,9 @@ 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-decision-templates: ## Render CCR approve/deny/needs-changes templates + scripts/credential-change.py decision-templates $(CREDENTIAL_CHANGE) + credential-change-status: ## Render credential change request readiness status scripts/credential-change.py status $(CREDENTIAL_CHANGE) @@ -216,6 +227,38 @@ credential-change-apply-plan: ## Render approved-only operator apply plan credential-change-operator-commands: ## Render approved-only non-secret OpenBao operator commands scripts/credential-change.py operator-commands $(CREDENTIAL_CHANGE) +credential-change-applier-dry-run: ## Validate delegated OpenBao metadata mutations for a CCR + scripts/credential-change.py applier-dry-run $(CREDENTIAL_CHANGE) + +credential-change-applier-apply-plan: ## Render delegated OpenBao metadata apply plan + scripts/credential-change.py applier-apply $(CREDENTIAL_CHANGE) --plan-only + +credential-change-applier-apply: ## Apply delegated metadata; pass confirmation/actor args via CREDENTIAL_CHANGE_EVIDENCE_ARGS + scripts/credential-change.py applier-apply $(CREDENTIAL_CHANGE) $(CREDENTIAL_CHANGE_EVIDENCE_ARGS) + +credential-change-runbook: ## Render the attended CCR apply/verify runbook + scripts/credential-change.py runbook $(CREDENTIAL_CHANGE) + +credential-change-record-evidence: ## Record non-secret CCR evidence; pass CREDENTIAL_CHANGE_EVIDENCE_ARGS + scripts/credential-change.py record-evidence $(CREDENTIAL_CHANGE) $(CREDENTIAL_CHANGE_EVIDENCE_ARGS) + +credential-change-lifecycle-plan: ## Render deactivation/rotation/compromise lifecycle guidance + scripts/credential-change.py lifecycle-plan $(CREDENTIAL_CHANGE) --action $(CREDENTIAL_CHANGE_LIFECYCLE_ACTION) + +credential-change-lifecycle-event: ## Record lifecycle event; pass CREDENTIAL_CHANGE_LIFECYCLE_ARGS + scripts/credential-change.py lifecycle-event $(CREDENTIAL_CHANGE) --action $(CREDENTIAL_CHANGE_LIFECYCLE_ACTION) $(CREDENTIAL_CHANGE_LIFECYCLE_ARGS) + +credential-change-import-inventory: ## Import existing lane as non-secret CCR; pass CREDENTIAL_CHANGE_IMPORT_ARGS + scripts/credential-change.py import-inventory $(CREDENTIAL_CHANGE_IMPORT_ARGS) + +openbao-credential-change-appliers-dry-run: ## Dry-run credential-change applier policies/token roles + scripts/openbao-apply-credential-change-appliers.py --dry-run $(OPENBAO_CREDENTIAL_CHANGE_APPLIER_ARGS) + +openbao-configure-credential-change-appliers: ## Apply credential-change applier policies/token roles + KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \ + OPENBAO_RELEASE=$(OPENBAO_RELEASE) \ + scripts/openbao-apply-credential-change-appliers.py $(OPENBAO_CREDENTIAL_CHANGE_APPLIER_ARGS) + 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) @@ -297,4 +340,4 @@ help: ## Show this help /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) -.PHONY: db-deploy db-status db-shell db-logs apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help +.PHONY: db-deploy db-status db-shell db-logs apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help diff --git a/argocd/platform-addons/openbao-secretstore/kustomization.yaml b/argocd/platform-addons/openbao-secretstore/kustomization.yaml index 2b05892..ee9acfc 100644 --- a/argocd/platform-addons/openbao-secretstore/kustomization.yaml +++ b/argocd/platform-addons/openbao-secretstore/kustomization.yaml @@ -3,3 +3,4 @@ kind: Kustomization resources: - openbao.clustersecretstore.yaml + - openbao-activity-core.clustersecretstore.yaml diff --git a/argocd/platform-addons/openbao-secretstore/openbao-activity-core.clustersecretstore.yaml b/argocd/platform-addons/openbao-secretstore/openbao-activity-core.clustersecretstore.yaml new file mode 100644 index 0000000..a9def5e --- /dev/null +++ b/argocd/platform-addons/openbao-secretstore/openbao-activity-core.clustersecretstore.yaml @@ -0,0 +1,23 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ClusterSecretStore +metadata: + name: openbao-activity-core + labels: + app.kubernetes.io/part-of: railiance-gitops + railiance-platform/component: external-secrets +spec: + provider: + vault: + server: http://openbao.openbao.svc.cluster.local:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-activity-core + serviceAccountRef: + name: external-secrets + namespace: external-secrets + conditions: + - namespaces: + - activity-core 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 index 8f60f6c..9ecf9fc 100644 --- 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 @@ -2,79 +2,104 @@ id: CCR-2026-0002 kind: credential-change-request schema_version: 1 request_type: workload-kv-read -title: "issue-core runtime ingestion key lane" +title: issue-core runtime ingestion key lane status: proposed -created: "2026-06-27" -updated: "2026-06-27" +created: '2026-06-27' +updated: '2026-06-30' 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." + 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: [] + - platform-operator + - issue-core-owner + comments: + - at: '2026-06-29T22:53:03+00:00' + reviewer: codex + decision: metadata_review_binding_confirmed + comment: Live cluster metadata on 2026-06-30 confirms ExternalSecret issue-core/issue-core-runtime + is Ready=True (SecretSynced) and maps ISSUE_CORE_API_KEY plus GITEA_BACKEND_TOKEN + from platform/workloads/issue-core/issue-core/issue-core-runtime. The workload + Deployment uses the default service account; OpenBao auth for this delivery + path is the platform ClusterSecretStore/openbao role external-secrets-issue-core + bound to service account external-secrets/external-secrets. Keep CCR status + proposed until platform/operator and issue-core-owner approval. target: domain: financials tenant: issue-core workload: issue-core environment: production - purpose: "issue-core runtime ingestion through OpenBao workload KV and External Secrets" + 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 + - 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 + role: external-secrets-issue-core bound_claims: service_account_names: - - issue-core + - external-secrets service_account_namespaces: - - issue-core - bound_claims_confirmed: false + - external-secrets + bound_claims_confirmed: true policies: - - workload-kv-read-issue-core-runtime + - 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" + 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" + activation: draft-until-ccr-verified delivery: surface: external-secrets - target: "issue-core namespace" + target: ExternalSecret issue-core/issue-core-runtime -> Secret issue-core-runtime + in the 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." + - Grants read access to issue-core runtime ingestion credentials through the platform + External Secrets path. + - GITEA_BACKEND_TOKEN remains included because the live issue-core ExternalSecret + maps it alongside ISSUE_CORE_API_KEY; remove it before approval only if the issue-core + owner confirms it is no longer required. + - The Kubernetes auth subject is the External Secrets operator service account external-secrets/external-secrets, + with ClusterSecretStore usage limited to the issue-core namespace. + - 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." + - ExternalSecret issue-core/issue-core-runtime is Ready=True and syncs the configured + fields without printing values. + - Approved issue-core runtime can consume the resulting Kubernetes Secret without + exposing values. negative: - - "A service account outside the approved issue-core binding cannot read the path." + - A namespace outside the approved ClusterSecretStore condition cannot use this + store to read the path. + - A service account outside external-secrets/external-secrets cannot authenticate + through the External Secrets OpenBao role. 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." + - Policy applied with platform-admin/operator authority. + - Kubernetes auth role bound to external-secrets/external-secrets for the issue-core + External Secrets delivery path. + - 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." + 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" + 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 index 7c3986c..d3b5dc3 100644 --- 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 @@ -2,77 +2,101 @@ id: CCR-2026-0003 kind: credential-change-request schema_version: 1 request_type: workload-kv-read -title: "llm-connect OpenRouter provider key lane" +title: llm-connect OpenRouter provider key lane status: proposed -created: "2026-06-27" -updated: "2026-06-27" +created: '2026-06-27' +updated: '2026-06-30' 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." + 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: [] + - platform-operator + - activity-core-owner + comments: + - at: '2026-06-29T22:53:03+00:00' + reviewer: codex + decision: metadata_review_binding_confirmed_pending_owner_approval + comment: Live cluster metadata on 2026-06-30 confirms namespace activity-core + exists and the External Secrets operator service account external-secrets/external-secrets + exists. The proposed llm-connect service account does not exist; the llm-connect + Deployment currently uses the default service account. Updated the proposed + OpenBao auth binding to the platform ESO pattern with role external-secrets-activity-core. + No activity-core ExternalSecret exists yet; a namespace-limited ClusterSecretStore + source manifest was added for future rollout. Keep CCR status proposed until + platform/operator and activity-core-owner approval. target: domain: financials tenant: activity-core workload: llm-connect environment: production - purpose: "llm-connect provider access through OpenBao workload KV and External Secrets" + 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 + - 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 + role: external-secrets-activity-core bound_claims: service_account_names: - - llm-connect + - external-secrets service_account_namespaces: - - activity-core - bound_claims_confirmed: false + - external-secrets + bound_claims_confirmed: true policies: - - workload-kv-read-llm-connect-provider-secrets + - 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" + 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" + activation: draft-until-ccr-verified delivery: surface: external-secrets - target: "Secret llm-connect-provider-secrets in the activity-core namespace" + target: ExternalSecret to 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." + - Grants read access to the provider key used by llm-connect for OpenRouter requests + through the platform External Secrets path. + - The Kubernetes auth subject is the External Secrets operator service account external-secrets/external-secrets, + with ClusterSecretStore usage limited to the activity-core namespace. + - 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." + - An approved activity-core ExternalSecret can sync field OPENROUTER_API_KEY to + Secret llm-connect-provider-secrets without printing the value. + - The llm-connect runtime can consume the resulting Kubernetes Secret without exposing + values. negative: - - "A service account outside the approved activity-core/llm-connect binding cannot read the path." + - A namespace outside the approved ClusterSecretStore condition cannot use this + store to read the path. + - A service account outside external-secrets/external-secrets cannot authenticate + through the External Secrets OpenBao role. 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." + - Policy applied with platform-admin/operator authority. + - Kubernetes auth role bound to external-secrets/external-secrets for the activity-core + External Secrets delivery path. + - 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." + 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" + ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076 diff --git a/docs/argocd-gitops.md b/docs/argocd-gitops.md index 8e7f80c..63fa69c 100644 --- a/docs/argocd-gitops.md +++ b/docs/argocd-gitops.md @@ -192,16 +192,22 @@ The GitOps contract uses: `ClusterSecretStore`. - OpenBao Kubernetes auth role `external-secrets-issue-core` for the issue-core pilot. +- OpenBao Kubernetes auth role `external-secrets-activity-core` for the + activity-core/llm-connect provider-secret lane once approved. -The initial `ClusterSecretStore/openbao` is intentionally limited to the -`issue-core` namespace. Broaden it only with a new platform review when another -tenant is ready to consume OpenBao through ESO. +`ClusterSecretStore/openbao` is limited to the `issue-core` namespace. +`ClusterSecretStore/openbao-activity-core` is limited to the `activity-core` +namespace and is intended for the llm-connect provider-secret lane. Broaden or +add stores only with platform review. Configure the OpenBao side without printing token values: ```bash OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token \ make openbao-configure-external-secrets-issue-core + +OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token \ + make openbao-configure-external-secrets-activity-core ``` The helper keeps Kubernetes auth in local-reviewer mode: OpenBao rereads its diff --git a/docs/credential-change-approval.md b/docs/credential-change-approval.md index 342c072..4d54de3 100644 --- a/docs/credential-change-approval.md +++ b/docs/credential-change-approval.md @@ -156,6 +156,12 @@ scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer --com make credential-change-sync-decision 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 +make credential-change-runbook CREDENTIAL_CHANGE=CCR-2026-0001 +scripts/credential-change.py runbook CCR-2026-0001 --execute-metadata --actor --confirm "APPLY CCR-2026-0001" +scripts/credential-change.py record-evidence CCR-2026-0001 --actor --kind positive_verification --result passed --detail "" --record-state-hub +make credential-change-lifecycle-plan CREDENTIAL_CHANGE=CCR-2026-0001 CREDENTIAL_CHANGE_LIFECYCLE_ACTION=deactivate +scripts/credential-change.py lifecycle-event CCR-2026-0001 --action compromise --actor --reason "" --detail "" --blast-radius "" --follow-up "" --record-state-hub +scripts/credential-change.py import-inventory CCR-YYYY-NNNN --title "existing lane" --tenant --workload --environment production --purpose "" --kv-path platform/workloads/// --field --auth-method oidc --auth-mount netkingdom --auth-role --bound-claim groups= --bound-claims-confirmed --frontdoor-type ops-warden --catalog-id --reason "Imported existing lane without secret values" ``` `apply-plan` and `operator-commands` are intentionally guarded: they refuse @@ -229,6 +235,15 @@ The interactive runbook is the operator bridge: 8. record non-secret evidence; 9. notify downstream front doors such as ops-warden. +`credential-change.py runbook ` renders the checklist and exact final +confirmation phrase. `--execute-metadata` is intentionally opt-in and requires +that phrase; it uses the local `bao` CLI with ambient approved operator +authority, writes only policy/auth metadata, and records a non-secret +`metadata_apply` evidence entry. Secret value provisioning stays outside the +script through approved OpenBao/operator custody. Verification, activation, and +manual custody events are recorded with `record-evidence`, whose comments are +scanned for known secret markers before the CCR file or State Hub is updated. + This lets operators safely drive privileged work without needing to remember every OpenBao command. @@ -241,6 +256,16 @@ Every active CCR needs a deactivate and rotate path: - `compromised`: emergency state requiring immediate disablement, rotation, blast-radius notes, and incident follow-up. +`lifecycle-plan` renders the attended checklist for each case, including the +front-door state change and OpenBao metadata disable commands for deactivation +or compromise. `lifecycle-event` records the non-secret lifecycle event in the +CCR, sets the CCR status, and marks the access front door disabled, pending +verification, or compromised as appropriate. For compromise events it accepts +non-secret blast-radius notes and follow-up task references. Existing lanes that +predate CCRs can be imported with `import-inventory`, which writes a CCR and +matching read-policy artifact from metadata only; it never asks for or stores +the secret value. + The workflow must support marking an existing credential or lane as compromised even when the original request predates this system. diff --git a/docs/openbao-approved-automation-delegation.md b/docs/openbao-approved-automation-delegation.md new file mode 100644 index 0000000..fb99b89 --- /dev/null +++ b/docs/openbao-approved-automation-delegation.md @@ -0,0 +1,127 @@ +# OpenBao Approved Automation Delegation + +This document specifies the narrow OpenBao metadata surface that approved +credential-change automation may mutate. It exists to avoid routine use of broad +`platform-admin` while keeping secret values under operator custody. + +## Scope + +The delegated applier is for reviewed metadata only: + +- ACL policies generated from approved CCRs; +- auth roles bound to reviewed OIDC claims or Kubernetes service accounts; +- credential-broker issuer policies and token roles generated from reviewed + grant catalog entries; +- readback and capability checks needed to prove the mutation landed. + +It must not read, write, print, wrap, unwrap, or proxy managed secret values. +Production secret provisioning remains an attended OpenBao/operator custody +step unless a later workplan approves a stronger dual-control flow. + +## Environment Boundaries + +Build and development may use sandbox metadata once a non-production OpenBao +mount or namespace is declared. Generated test secrets must stay in the sandbox +and must never be copied into State Hub, prompts, Git, or chat. + +The non-production applier policy candidate is +`openbao/policies/credential-change-nonprod-applier.hcl`. It currently grants +only metadata writes, matching the no-secret-value rule used in production. +Any future generated test-secret path needs a separate CCR-backed approval so +it cannot silently expand this delegation. + +Test and staging may apply reviewed metadata after owner review. Verification +must include positive and negative access checks, and evidence must be +non-secret. + +Production may apply only reviewed non-secret metadata. The production applier +policy is `openbao/policies/credential-change-prod-applier.hcl`, and every live +run must be preceded by `scripts/credential-change.py applier-dry-run `. +Unapproved CCRs fail closed before any OpenBao mutation is rendered. Live +metadata mutation uses `scripts/credential-change.py applier-apply ` with +an exact `DELEGATED APPLY ` confirmation phrase and the local `bao` CLI +under ambient delegated applier authority; the command does not accept OpenBao +tokens in argv. + +## Production Mutation Surface + +| Change class | Allowed OpenBao path | Notes | +| --- | --- | --- | +| Workload KV read policies | `sys/policies/acl/workload-kv-read-*` | Generated from CCR mount/path/field metadata. | +| Credential broker issuer policies | `sys/policies/acl/credential-broker-*-issuer` | Generated from grant catalog metadata. | +| OIDC workload roles | `auth/netkingdom/role/*-workload-kv-read` | Bound claims must be confirmed before apply. | +| Kubernetes workload roles | `auth/kubernetes/role/*` | Bound service accounts/namespaces must be confirmed before apply. | +| Credential broker token roles | `auth/token/roles/credential-broker-*` | Child-token roles only; no root or platform-admin policies. | +| Self checks | `auth/token/lookup-self`, `sys/capabilities-self` | Read/update only as required by OpenBao. | + +Denied by omission: + +- `platform/data/*`, `platform/metadata/*`, or any other secret value path; +- `sys/*` outside the approved ACL policy prefixes; +- `auth/*` outside the approved role prefixes; +- `identity/*`, unseal/recovery material, audit devices, mounts, and root/admin + policy assignment; +- wildcard, parent-directory, or mismatched policy and role names. + +## Local Dry-Run Guardrails + +The CCR dry-run is deliberately stricter than the OpenBao ACL policy. It must: + +1. validate the CCR schema and secret-marker scan; +2. require CCR status `approved`, `applied`, `verified`, or `active`; +3. require `openbao.auth.bound_claims_confirmed=true`; +4. require mount `platform` and path `platform/workloads/...` for workload KV + requests; +5. require policy names to start with `workload-kv-read-` and remain under + `openbao/policies/.hcl`; +6. require OIDC roles to stay under `auth/netkingdom/role/*-workload-kv-read`; +7. require Kubernetes roles to stay under `auth/kubernetes/role/*-workload-kv-read` + or `auth/kubernetes/role/*-secrets-read`; +8. render only exact policy and auth-role metadata mutations; +9. leave secret value writes and front-door activation out of scope. + +`applier-apply` reuses the same guardrails, renders the dry-run payload before +mutation, requires exact confirmation, writes only policy/auth-role metadata, +and appends non-secret `delegated_metadata_apply` evidence. For approved CCRs it +can advance file-backed status to `applied`; for already applied/verified/active +CCRs it records idempotent evidence without moving the lifecycle backward. + +## Required Evidence + +Record only non-secret evidence: + +- CCR id and approval/decision reference; +- applier identity and timestamp; +- policy name and auth role path; +- OpenBao request id or audit timestamp; +- positive and negative verification references; +- front-door activation confirmation after verification. + +## Applier Identity Setup + +`openbao-apply-credential-change-appliers.py` configures the source-owned +metadata applier policies and matching OpenBao token roles: + +- `credential-change-nonprod-applier` uses + `openbao/policies/credential-change-nonprod-applier.hcl`; +- `credential-change-prod-applier` uses + `openbao/policies/credential-change-prod-applier.hcl`. + +The token roles allow only their matching applier policy, explicitly disallow +`root` and `platform-admin`, disable the default policy, use service tokens, +and do not issue tokens by themselves. Token issuance remains an approved +custody path outside this setup script. Use +`make openbao-credential-change-appliers-dry-run` before any live apply. + +## Current Production Policy Candidate + +`openbao/policies/credential-change-prod-applier.hcl` is the source candidate +for a future production applier identity. It is not a substitute for CCR review; +it is the OpenBao-side capability envelope used after local dry-run validation. + +## Current Non-Production Policy Candidate + +`openbao/policies/credential-change-nonprod-applier.hcl` is the source +candidate for a build/test/staging applier identity. It is intentionally +metadata-only until this repo declares a non-production OpenBao mount or +namespace and records live positive/negative evidence for that lane. diff --git a/docs/workload-kv-access-lanes.md b/docs/workload-kv-access-lanes.md index e74575a..6a48b66 100644 --- a/docs/workload-kv-access-lanes.md +++ b/docs/workload-kv-access-lanes.md @@ -30,7 +30,7 @@ Ops-warden batch follow-up: | KV mount | `platform` | | OpenBao CLI path | `platform/workloads/coulomb/whynot-design/npm-publish` | | Secret field | `NPM_AUTH_TOKEN` | -| Front-door readiness | `applied-pending-verify`, `resolvable=false` until caller verification | +| Front-door readiness | `active`, `resolvable=true` in ops-warden | | 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` | @@ -57,6 +57,13 @@ Expected ops-warden exec shape after activation: warden access whynot-design-npm-publish --exec -- npm publish ``` +Ops-warden confirmed activation in State Hub message +`f76d3a9e-a98f-4081-885d-b79d94312699`: selector +`whynot-design-npm-publish` is active, resolvable, and wired to this +caller-scoped lane. The sibling lanes `issue-core-ingestion-api-key` and +`openrouter-llm-connect` remain draft and are tracked separately by +`RAILIANCE-WP-0009` and `RAILIANCE-WP-0010`. + 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. diff --git a/openbao/policies/credential-change-nonprod-applier.hcl b/openbao/policies/credential-change-nonprod-applier.hcl new file mode 100644 index 0000000..b6c657b --- /dev/null +++ b/openbao/policies/credential-change-nonprod-applier.hcl @@ -0,0 +1,40 @@ +# Non-production metadata applier for reviewed credential changes. +# +# This policy is intended for build/test/staging OpenBao lanes after a +# non-production mount or namespace is declared. It intentionally keeps the same +# no-secret-value rule as production; generated test secrets still require a +# separately approved non-production value path. + +# Workload KV read-lane policies generated from approved CCRs. +path "sys/policies/acl/workload-kv-read-*" { + capabilities = ["create", "update", "read"] +} + +# Credential broker issuer policies generated from approved grant metadata. +path "sys/policies/acl/credential-broker-*-issuer" { + capabilities = ["create", "update", "read"] +} + +# OIDC roles for caller-scoped workload KV lanes. +path "auth/netkingdom/role/*-workload-kv-read" { + capabilities = ["create", "update", "read"] +} + +# Kubernetes roles for in-cluster workload and provider-secret lanes. +path "auth/kubernetes/role/*" { + capabilities = ["create", "update", "read"] +} + +# Token roles for approved credential-broker child-token issuers. +path "auth/token/roles/credential-broker-*" { + capabilities = ["create", "update", "read"] +} + +# Self-checks and capability introspection only. +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "sys/capabilities-self" { + capabilities = ["update"] +} diff --git a/openbao/policies/credential-change-prod-applier.hcl b/openbao/policies/credential-change-prod-applier.hcl new file mode 100644 index 0000000..b8301ea --- /dev/null +++ b/openbao/policies/credential-change-prod-applier.hcl @@ -0,0 +1,41 @@ +# Production metadata applier for reviewed credential changes. +# +# This policy intentionally permits only non-secret OpenBao metadata writes for +# approved CCRs. Secret value paths under platform/data are not granted here. +# The local credential-change applier-dry-run command must validate the CCR +# before this policy is used for any live mutation. + +# Workload KV read-lane policies generated from approved CCRs. +path "sys/policies/acl/workload-kv-read-*" { + capabilities = ["create", "update", "read"] +} + +# Credential broker issuer policies generated from approved grant metadata. +path "sys/policies/acl/credential-broker-*-issuer" { + capabilities = ["create", "update", "read"] +} + +# OIDC roles for caller-scoped workload KV lanes. +path "auth/netkingdom/role/*-workload-kv-read" { + capabilities = ["create", "update", "read"] +} + +# Kubernetes roles for in-cluster workload and provider-secret lanes. The local +# applier dry-run constrains role names and bound service accounts per CCR. +path "auth/kubernetes/role/*" { + capabilities = ["create", "update", "read"] +} + +# Token roles for approved credential-broker child-token issuers. +path "auth/token/roles/credential-broker-*" { + capabilities = ["create", "update", "read"] +} + +# Self-checks and capability introspection only. +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "sys/capabilities-self" { + capabilities = ["update"] +} diff --git a/scripts/credential-change.py b/scripts/credential-change.py index a651609..f44229e 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -2,11 +2,14 @@ from __future__ import annotations import argparse +import difflib import json import os import re import shlex +import subprocess import sys +import tempfile import urllib.error import urllib.request from datetime import datetime, timezone @@ -61,6 +64,42 @@ FRONTDOOR_READINESS = { } SAFE_ID_RE = re.compile(r"^[A-Z0-9][A-Z0-9_.-]*$") TTL_RE = re.compile(r"^[1-9][0-9]*[smhd]$") +LOWER_SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +FIELD_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") +APPLIER_DRY_RUN_ALLOWED_STATUSES = APPLY_ALLOWED_STATUSES | POST_APPLY_STATUSES +APPLIER_ALLOWED_ENVIRONMENTS = { + "build", + "development", + "test", + "staging", + "production", +} +WORKLOAD_KV_POLICY_PREFIX = "workload-kv-read-" +OIDC_WORKLOAD_ROLE_SUFFIX = "-workload-kv-read" +KUBERNETES_ROLE_SUFFIXES = ("-workload-kv-read", "-secrets-read") +KUBERNETES_ROLE_PREFIXES = ("external-secrets-",) +EVIDENCE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") +RUNBOOK_ALLOWED_STATUSES = APPLY_ALLOWED_STATUSES | POST_APPLY_STATUSES +LIFECYCLE_ACTIONS = { + "deactivate": { + "status": "deactivated", + "readiness": "disabled", + "resolvable": False, + "kind": "deactivation", + }, + "rotate": { + "status": "rotated", + "readiness": "applied-pending-verify", + "resolvable": False, + "kind": "rotation", + }, + "compromise": { + "status": "compromised", + "readiness": "compromised", + "resolvable": False, + "kind": "compromise", + }, +} def fail(message: str) -> None: @@ -129,13 +168,25 @@ def require_string(value: Any, field: str, errors: list[str]) -> str: return value.strip() +def contains_secret_marker(text: str, marker: str) -> bool: + if marker == "sk-": + return re.search(r"(? None: text = path.read_text(encoding="utf-8") for marker in SECRET_MARKERS: - if marker in text: + if contains_secret_marker(text, marker): errors.append(f"{path.name} contains rejected secret marker {marker!r}") +def reject_secret_text(text: str, field: str) -> None: + for marker in SECRET_MARKERS: + if contains_secret_marker(text, marker): + fail(f"{field} contains rejected secret marker {marker!r}") + + def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: list[str]) -> None: target = require_object(ccr.get("target"), "target", errors) for field in ("domain", "tenant", "workload", "environment", "purpose"): @@ -351,6 +402,61 @@ def generated_policy_hcl(ccr: dict[str, Any]) -> str: ) + +def display_repo_path(path: Path) -> str: + try: + return str(path.resolve().relative_to(REPO_DIR)) + except ValueError: + return str(path) + + +def policy_artifact_diff(ccr: dict[str, Any]) -> dict[str, Any]: + policy_path = resolve_repo_path(ccr["openbao"]["policy_file"]) + generated = generated_policy_hcl(ccr) + generated_lines = generated.rstrip().splitlines() + result: dict[str, Any] = { + "path": display_repo_path(policy_path), + "status": "missing", + "matches": False, + "diff": [], + } + if policy_path.exists(): + source = policy_path.read_text(encoding="utf-8") + source_lines = source.rstrip().splitlines() + result["matches"] = normalized_policy_body(source) == normalized_policy_body( + generated + ) + result["status"] = "matches" if result["matches"] else "differs" + else: + source_lines = [] + if not result["matches"]: + result["diff"] = list( + difflib.unified_diff( + source_lines, + generated_lines, + fromfile=result["path"], + tofile=f"generated/{ccr['openbao']['policy_name']}.hcl", + lineterm="", + ) + ) + return result + + +def render_policy_artifact_diff(ccr: dict[str, Any], indent: str = "") -> list[str]: + artifact = policy_artifact_diff(ccr) + lines = [ + f"{indent}source artifact: {artifact['path']}", + f"{indent}artifact status: {artifact['status']}", + ] + if artifact["matches"]: + lines.append(f"{indent}diff: none; source artifact matches generated policy body") + return lines + lines.append(f"{indent}diff:") + for line in artifact["diff"]: + lines.append(f"{indent}{line}") + return lines + + def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]: auth = ccr["openbao"]["auth"] if auth["method"] == "kubernetes": @@ -393,6 +499,9 @@ def render_plan(ccr: dict[str, Any]) -> str: "", generated_policy_hcl(ccr).rstrip(), "", + " Source artifact diff:", + *render_policy_artifact_diff(ccr, indent=" "), + "", "2. Create/update auth role payload:", f" path: auth/{auth['mount']}/role/{auth['role']}", json.dumps(payload, indent=2, sort_keys=True), @@ -438,6 +547,832 @@ def render_operator_commands(ccr: dict[str, Any]) -> str: ] return "\n".join(lines) + +def normalized_policy_body(text: str) -> str: + lines: list[str] = [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + lines.append(stripped) + return "\n".join(lines) + + +def string_values(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + values: list[str] = [] + for item in value: + values.extend(string_values(item)) + return values + if isinstance(value, dict): + values = [] + for item in value.values(): + values.extend(string_values(item)) + return values + return [] + + +def applier_policy_violations(ccr: dict[str, Any]) -> list[str]: + violations: list[str] = [] + if ccr.get("request_type") != "workload-kv-read": + return ["delegated applier only supports workload-kv-read CCRs"] + + target = ccr.get("target", {}) + environment = str(target.get("environment") or "") + if environment not in APPLIER_ALLOWED_ENVIRONMENTS: + violations.append(f"target.environment is outside delegated applier scope: {environment}") + + openbao = ccr.get("openbao", {}) + mount = str(openbao.get("mount") or "") + kv_path = str(openbao.get("kv_path") or "") + policy_name = str(openbao.get("policy_name") or "") + policy_file = str(openbao.get("policy_file") or "") + fields = openbao.get("fields") or [] + + if mount != "platform": + violations.append(f"openbao.mount must be platform, got {mount}") + if not kv_path.startswith("platform/workloads/"): + violations.append("openbao.kv_path must stay under platform/workloads/") + if any(fragment in kv_path for fragment in ("*", "..", "//")): + violations.append("openbao.kv_path must not contain wildcard, parent, or empty segments") + if "/data/" in kv_path or "/metadata/" in kv_path: + violations.append("openbao.kv_path must be the logical KV path, not a KV-v2 API path") + + for field in fields: + field_name = str(field) + if not FIELD_NAME_RE.match(field_name): + violations.append(f"openbao.fields contains unsafe field name: {field_name}") + + if policy_name in DISALLOWED_POLICY_NAMES: + violations.append(f"openbao.policy_name is disallowed: {policy_name}") + if not policy_name.startswith(WORKLOAD_KV_POLICY_PREFIX): + violations.append( + f"openbao.policy_name must start with {WORKLOAD_KV_POLICY_PREFIX}" + ) + if not LOWER_SAFE_ID_RE.match(policy_name): + violations.append(f"openbao.policy_name contains unsafe characters: {policy_name}") + + if policy_file: + resolved_policy = resolve_repo_path(policy_file) + policy_dir = (REPO_DIR / "openbao" / "policies").resolve() + try: + resolved_policy.relative_to(policy_dir) + except ValueError: + violations.append("openbao.policy_file must stay under openbao/policies") + expected_name = f"{policy_name}.hcl" + if resolved_policy.name != expected_name: + violations.append( + f"openbao.policy_file name must match policy name: {expected_name}" + ) + if resolved_policy.exists(): + source = normalized_policy_body(resolved_policy.read_text(encoding="utf-8")) + generated = normalized_policy_body(generated_policy_hcl(ccr)) + if source != generated: + violations.append("openbao.policy_file does not match generated CCR policy") + + auth = openbao.get("auth", {}) + method = str(auth.get("method") or "") + auth_mount = str(auth.get("mount") or "") + role = str(auth.get("role") or "") + if method == "oidc" and auth_mount != "netkingdom": + violations.append("OIDC workload CCRs may only mutate auth/netkingdom roles") + elif method == "kubernetes" and auth_mount != "kubernetes": + violations.append("Kubernetes workload CCRs may only mutate auth/kubernetes roles") + elif method not in {"oidc", "kubernetes"}: + violations.append(f"unsupported auth method for delegated applier: {method}") + + if not LOWER_SAFE_ID_RE.match(role): + violations.append(f"openbao.auth.role contains unsafe characters: {role}") + if role in DISALLOWED_POLICY_NAMES: + violations.append(f"openbao.auth.role is disallowed: {role}") + if method == "oidc" and not role.endswith(OIDC_WORKLOAD_ROLE_SUFFIX): + violations.append( + f"OIDC workload role must end with {OIDC_WORKLOAD_ROLE_SUFFIX}" + ) + if method == "kubernetes" and not ( + role.endswith(KUBERNETES_ROLE_SUFFIXES) + or role.startswith(KUBERNETES_ROLE_PREFIXES) + ): + allowed_roles = list(KUBERNETES_ROLE_SUFFIXES) + [ + f"{prefix}*" for prefix in KUBERNETES_ROLE_PREFIXES + ] + violations.append( + "Kubernetes workload role must end with/start with " + + " or ".join(allowed_roles) + ) + + for policy in auth.get("policies") or []: + policy_value = str(policy) + if policy_value != policy_name: + violations.append("openbao.auth.policies must contain only openbao.policy_name") + if policy_value in DISALLOWED_POLICY_NAMES: + violations.append(f"openbao.auth.policies contains disallowed policy: {policy_value}") + + for value in string_values(auth.get("bound_claims") or {}): + if not value.strip() or "*" in value or ".." in value: + violations.append("openbao.auth.bound_claims contains an unsafe value") + break + + return violations + + +def applier_readiness_blockers(ccr: dict[str, Any]) -> list[str]: + blockers: list[str] = [] + status = ccr.get("status") + if status not in APPLIER_DRY_RUN_ALLOWED_STATUSES: + blockers.append( + "applier dry-run requires approved, applied, verified, or active " + f"CCR status, got {status}" + ) + if ccr["openbao"]["auth"].get("bound_claims_confirmed") is not True: + blockers.append("applier dry-run requires confirmed OpenBao auth binding") + blockers.extend(applier_policy_violations(ccr)) + return blockers + + +def applier_dry_run_payload(ccr: dict[str, Any], warnings: list[str]) -> dict[str, Any]: + openbao = ccr["openbao"] + auth = openbao["auth"] + auth_path = f"auth/{auth['mount']}/role/{auth['role']}" + return { + "id": ccr["id"], + "title": ccr["title"], + "status": ccr["status"], + "environment": ccr["target"]["environment"], + "warnings": warnings, + "source_artifacts": { + "policy": policy_artifact_diff(ccr), + }, + "mutations": [ + { + "kind": "policy_write", + "openbao_path": f"sys/policies/acl/{openbao['policy_name']}", + "policy_name": openbao["policy_name"], + "source": openbao["policy_file"], + "body": generated_policy_hcl(ccr).rstrip(), + }, + { + "kind": "auth_role_write", + "openbao_path": auth_path, + "payload": auth_payload(ccr), + }, + ], + "out_of_scope": [ + "secret value writes", + "secret reads", + "front-door activation before verification", + ], + "required_evidence": [ + "CCR id and approval reference", + "policy name and auth role path", + "OpenBao request id or timestamp", + "positive and negative verification references", + ], + } + + +def render_applier_dry_run(payload: dict[str, Any]) -> str: + lines = [ + f"CCR {payload['id']} delegated applier dry-run", + f"Status: {payload['status']}", + f"Environment: {payload['environment']}", + "", + "Allowed metadata mutations:", + ] + for mutation in payload["mutations"]: + lines.append(f"- {mutation['kind']}: {mutation['openbao_path']}") + if mutation["kind"] == "policy_write": + lines.append(f" source: {mutation['source']}") + lines.append(" body:") + for line in mutation["body"].splitlines(): + lines.append(f" {line}") + else: + lines.append(" payload:") + rendered_payload = json.dumps(mutation["payload"], indent=4, sort_keys=True) + for line in rendered_payload.splitlines(): + lines.append(f" {line}") + lines.append("") + lines.append("Out of scope:") + for item in payload["out_of_scope"]: + lines.append(f"- {item}") + lines.append("Required non-secret evidence:") + for item in payload["required_evidence"]: + lines.append(f"- {item}") + policy_artifact = payload.get("source_artifacts", {}).get("policy") + if policy_artifact: + lines.append("Source artifact checks:") + lines.append(f"- policy: {policy_artifact['path']} ({policy_artifact['status']})") + if policy_artifact.get("diff"): + lines.append(" diff:") + for line in policy_artifact["diff"]: + lines.append(f" {line}") + if payload["warnings"]: + lines.append("Warnings:") + for warning in payload["warnings"]: + lines.append(f"- {warning}") + return "\n".join(lines) + + + +def applier_confirmation_phrase(ccr: dict[str, Any]) -> str: + return f"DELEGATED APPLY {ccr['id']}" + + +def delegated_apply_details(ccr: dict[str, Any], actor: str) -> list[str]: + openbao = ccr["openbao"] + auth = openbao["auth"] + return [ + f"Delegated metadata applier ran as {actor} using local bao CLI ambient authority.", + f"Policy metadata write: sys/policies/acl/{openbao['policy_name']}", + f"Auth role metadata write: auth/{auth['mount']}/role/{auth['role']}", + "No secret values were read, written, printed, or accepted in argv.", + ] + + +def render_applier_apply_plan(ccr: dict[str, Any], warnings: list[str]) -> str: + payload = applier_dry_run_payload(ccr, warnings) + lines = [render_applier_dry_run(payload), "", "Delegated apply confirmation:"] + lines.append(f" {applier_confirmation_phrase(ccr)}") + lines.extend( + [ + "", + "Live apply command:", + f" scripts/credential-change.py applier-apply {ccr['id']} --actor --confirm \"{applier_confirmation_phrase(ccr)}\" --record-state-hub", + "", + "The command uses the local bao CLI and ambient delegated applier identity.", + "It does not accept OpenBao tokens in argv and never writes secret values.", + ] + ) + return "\n".join(lines) + + +def runbook_readiness_blockers(ccr: dict[str, Any]) -> list[str]: + blockers: list[str] = [] + status = ccr.get("status") + if status not in RUNBOOK_ALLOWED_STATUSES: + blockers.append( + "runbook requires approved, applied, verified, or active CCR status, " + f"got {status}" + ) + if ccr["openbao"]["auth"].get("bound_claims_confirmed") is not True: + blockers.append("runbook requires confirmed OpenBao auth binding") + return blockers + + +def runbook_confirmation_phrase(ccr: dict[str, Any]) -> str: + return f"APPLY {ccr['id']}" + + +def runbook_payload(ccr: dict[str, Any], warnings: list[str]) -> dict[str, Any]: + openbao = ccr["openbao"] + frontdoor = ccr["access_frontdoor"] + verification = ccr["verification"] + auth = openbao["auth"] + return { + "id": ccr["id"], + "title": ccr["title"], + "status": ccr["status"], + "target": ccr["target"], + "warnings": warnings, + "decision_link": ccr.get("state_hub", {}).get("decision_api_url") + or ccr.get("state_hub", {}).get("decision_dashboard_url"), + "confirmation_phrase": runbook_confirmation_phrase(ccr), + "policy": { + "name": openbao["policy_name"], + "source": openbao["policy_file"], + "artifact": policy_artifact_diff(ccr), + }, + "auth_role": { + "path": f"auth/{auth['mount']}/role/{auth['role']}", + "method": auth["method"], + }, + "secret_provisioning": { + "path": openbao["kv_path"], + "fields": openbao["fields"], + "instruction": ( + "Enter or rotate secret values only through approved OpenBao/operator " + "custody; do not paste values into Git, State Hub, prompts, chat, " + "argv, or logs." + ), + }, + "verification": { + "positive": verification.get("positive", []), + "negative": verification.get("negative", []), + "activation_conditions": verification.get("activation_conditions", []), + }, + "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_runbook(payload: dict[str, Any]) -> str: + fields = ", ".join(payload["secret_provisioning"]["fields"]) + lines = [ + f"CCR {payload['id']} operator runbook", + f"Title: {payload['title']}", + f"Status: {payload['status']}", + f"Target: {payload['target']['tenant']}/{payload['target']['workload']} ({payload['target']['environment']})", + ] + if payload.get("decision_link"): + lines.append(f"Decision: {payload['decision_link']}") + lines.extend( + [ + "", + "Final attended confirmation:", + f" {payload['confirmation_phrase']}", + "", + "1. Review generated metadata plan:", + f" policy: {payload['policy']['name']}", + f" source: {payload['policy']['source']}", + f" artifact: {payload['policy']['artifact']['status']}", + f" auth role: {payload['auth_role']['path']}", + "", + "2. Apply non-secret metadata:", + " scripts/credential-change.py runbook --execute-metadata --actor ", + " The command uses the local bao CLI and ambient approved operator authority;", + " it does not accept OpenBao tokens in argv and it does not write secret values.", + "", + "3. Provision secret value through approved custody:", + f" path: {payload['secret_provisioning']['path']}", + f" fields: {fields}", + f" {payload['secret_provisioning']['instruction']}", + "", + "4. Positive verification:", + ] + ) + for item in payload["verification"]["positive"]: + lines.append(f" - {item}") + lines.append("") + lines.append("5. Negative verification:") + for item in payload["verification"]["negative"]: + lines.append(f" - {item}") + lines.append("") + lines.append("6. Record non-secret evidence:") + lines.extend( + [ + " scripts/credential-change.py record-evidence --actor --kind metadata_apply --result passed --detail \"OpenBao request id or audit timestamp: \" --status applied --record-state-hub", + " scripts/credential-change.py record-evidence --actor --kind secret_provisioned --result passed --detail \"Field presence checked without printing values\" --record-state-hub", + " scripts/credential-change.py record-evidence --actor --kind positive_verification --result passed --detail \"Positive verification reference: \" --record-state-hub", + " scripts/credential-change.py record-evidence --actor --kind negative_verification --result passed --detail \"Negative verification reference: \" --status verified --record-state-hub", + " scripts/credential-change.py record-evidence --actor --kind frontdoor_activation --result passed --detail \"Front door ready/resolvable after verification\" --status active --frontdoor-ready --record-state-hub", + "", + "Activation conditions:", + ] + ) + for item in payload["verification"]["activation_conditions"]: + lines.append(f" - {item}") + if payload["frontdoor"].get("command"): + lines.extend(["", f"Front-door command: {payload['frontdoor']['command']}"]) + if payload["warnings"]: + lines.append("") + lines.append("Warnings:") + for warning in payload["warnings"]: + lines.append(f" - {warning}") + return "\n".join(lines) + + +def run_bao_metadata_apply(ccr: dict[str, Any], bao_bin: str) -> None: + openbao = ccr["openbao"] + auth = openbao["auth"] + auth_path = f"auth/{auth['mount']}/role/{auth['role']}" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as role_file: + role_file.write(json.dumps(auth_payload(ccr), indent=2, sort_keys=True)) + role_file.write("\n") + role_path = Path(role_file.name) + try: + commands = [ + [bao_bin, "policy", "write", openbao["policy_name"], openbao["policy_file"]], + [bao_bin, "write", auth_path, f"@{role_path}"], + ] + for command in commands: + subprocess.run(command, cwd=REPO_DIR, check=True) + finally: + try: + role_path.unlink() + except FileNotFoundError: + pass + + +def validate_evidence_text(kind: str, result: str, details: list[str]) -> None: + if not EVIDENCE_ID_RE.match(kind): + fail("evidence kind must contain only lowercase letters, digits, underscore, or dash") + if not EVIDENCE_ID_RE.match(result): + fail("evidence result must contain only lowercase letters, digits, underscore, or dash") + reject_secret_text(kind, "evidence kind") + reject_secret_text(result, "evidence result") + for detail in details: + reject_secret_text(detail, "evidence detail") + + +def append_evidence( + path: Path, + actor: str, + kind: str, + result: str, + details: list[str], + set_status: str | None = None, + frontdoor_ready: bool = False, +) -> dict[str, Any]: + validate_evidence_text(kind, result, details) + reject_secret_text(actor, "evidence actor") + ccr, errors, warnings = validate_ccr(path) + if errors: + for error in errors: + print(f"[FAIL] {path.name}: {error}", file=sys.stderr) + raise SystemExit(1) + for warning in warnings: + print(f"[WARN] {path.name}: {warning}", file=sys.stderr) + verification = ccr.setdefault("verification", {}) + evidence = verification.setdefault("evidence", []) + if not isinstance(evidence, list): + fail("verification.evidence must be a list") + evidence.append( + { + "at": utc_now(), + "actor": actor, + "kind": kind, + "result": result, + "details": details, + } + ) + if set_status: + if set_status not in ALLOWED_STATUSES: + fail(f"status must be one of {sorted(ALLOWED_STATUSES)}") + ccr["status"] = set_status + if frontdoor_ready: + frontdoor = ccr.setdefault("access_frontdoor", {}) + frontdoor["readiness"] = "ready" + frontdoor["resolvable"] = True + ccr["updated"] = datetime.now(timezone.utc).date().isoformat() + dump_yaml(path, ccr) + return ccr + + +def record_evidence_state_hub( + ccr: dict[str, Any], base_url: str, actor: str, kind: str, result: str, details: list[str] +) -> dict[str, Any]: + openbao = ccr["openbao"] + summary = ( + f"CCR {ccr['id']} evidence {kind}/{result} by {actor}: " + f"status={ccr['status']} path={openbao['kv_path']} " + f"policy={openbao['policy_name']}; " + + "; ".join(details) + ) + return state_hub_post_json( + base_url, + "/progress/", + { + "summary": summary, + "event_type": "credential_change_evidence", + "author": actor, + }, + ) + + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "item" + + +def reject_secret_values(values: list[str], field: str) -> None: + for value in values: + reject_secret_text(value, field) + + +def parse_key_values(values: list[str]) -> dict[str, list[str]]: + parsed: dict[str, list[str]] = {} + for raw in values: + reject_secret_text(raw, "bound claim") + key, separator, value = raw.partition("=") + key = key.strip() + value = value.strip() + if separator != "=" or not key or not value: + fail("bound claims must use key=value syntax") + parsed.setdefault(key, []).append(value) + return parsed + + +def lifecycle_action_config(action: str) -> dict[str, Any]: + try: + return LIFECYCLE_ACTIONS[action] + except KeyError: + fail(f"lifecycle action must be one of {sorted(LIFECYCLE_ACTIONS)}") + + +def lifecycle_payload(ccr: dict[str, Any], action: str) -> dict[str, Any]: + config = lifecycle_action_config(action) + openbao = ccr["openbao"] + auth = openbao["auth"] + frontdoor = ccr["access_frontdoor"] + auth_role_path = f"auth/{auth['mount']}/role/{auth['role']}" + disable_commands = [ + f"bao delete {shlex.quote(auth_role_path)}", + f"bao policy delete {shlex.quote(openbao['policy_name'])}", + ] + return { + "id": ccr["id"], + "title": ccr["title"], + "action": action, + "target_status": config["status"], + "target_readiness": config["readiness"], + "target_resolvable": config["resolvable"], + "frontdoor": { + "type": frontdoor["type"], + "catalog_id": frontdoor["catalog_id"], + "command": frontdoor.get("command"), + }, + "openbao": { + "secret_path": openbao["kv_path"], + "fields": openbao["fields"], + "policy_name": openbao["policy_name"], + "auth_role_path": auth_role_path, + "disable_commands": disable_commands, + }, + "record_command": ( + "scripts/credential-change.py lifecycle-event " + f"{ccr['id']} --action {action} --actor " + "--reason \"\" --detail \"\" " + "--record-state-hub" + ), + } + + +def render_lifecycle_plan(payload: dict[str, Any]) -> str: + action = payload["action"] + lines = [ + f"CCR {payload['id']} lifecycle plan: {action}", + f"Title: {payload['title']}", + f"Target CCR status: {payload['target_status']}", + f"Target front door: readiness={payload['target_readiness']} resolvable={payload['target_resolvable']}", + "", + "1. Record lifecycle event:", + f" {payload['record_command']}", + "", + "2. Front-door action:", + f" Mark {payload['frontdoor']['type']} catalog {payload['frontdoor']['catalog_id']} as {payload['target_readiness']} before any further use.", + ] + if payload["frontdoor"].get("command"): + lines.append(f" Existing command to disable/check externally: {payload['frontdoor']['command']}") + lines.extend(["", "3. OpenBao metadata action:"]) + if action in {"deactivate", "compromise"}: + lines.append(" Disable caller access by removing the auth role and read policy with approved operator authority:") + for command in payload["openbao"]["disable_commands"]: + lines.append(f" {command}") + lines.append(" Secret values are not printed or copied; rotate/delete values only through approved custody.") + elif action == "rotate": + lines.append(" Keep the front door non-resolvable while the replacement value is entered through approved custody.") + lines.append(f" Secret path: {payload['openbao']['secret_path']}") + lines.append(f" Fields: {', '.join(payload['openbao']['fields'])}") + lines.append(" After positive and negative verification, record front-door activation evidence to return the lane to active.") + lines.extend(["", "4. Required non-secret notes:"]) + if action == "compromise": + lines.append(" Include blast-radius notes and follow-up task references; never include the exposed value.") + elif action == "rotate": + lines.append(" Include old-value revocation evidence, new-value field presence evidence, and verification references.") + else: + lines.append(" Include the reason for disablement, OpenBao audit/request reference, and front-door disable reference.") + return "\n".join(lines) + + +def append_lifecycle_event( + path: Path, + actor: str, + action: str, + reason: str, + details: list[str], + blast_radius: list[str] | None = None, + follow_up: list[str] | None = None, +) -> dict[str, Any]: + config = lifecycle_action_config(action) + reject_secret_text(actor, "lifecycle actor") + reject_secret_text(reason, "lifecycle reason") + reject_secret_values(details, "lifecycle detail") + blast_radius = blast_radius or [] + follow_up = follow_up or [] + reject_secret_values(blast_radius, "lifecycle blast radius") + reject_secret_values(follow_up, "lifecycle follow-up") + ccr, errors, warnings = validate_ccr(path) + if errors: + for error in errors: + print(f"[FAIL] {path.name}: {error}", file=sys.stderr) + raise SystemExit(1) + for warning in warnings: + print(f"[WARN] {path.name}: {warning}", file=sys.stderr) + lifecycle = ccr.setdefault("lifecycle", {}) + events = lifecycle.setdefault("events", []) + if not isinstance(events, list): + fail("lifecycle.events must be a list") + event = { + "at": utc_now(), + "actor": actor, + "action": action, + "status": config["status"], + "reason": reason, + "details": details, + } + if blast_radius: + event["blast_radius"] = blast_radius + if follow_up: + event["follow_up"] = follow_up + events.append(event) + ccr["status"] = config["status"] + frontdoor = ccr.setdefault("access_frontdoor", {}) + frontdoor["readiness"] = config["readiness"] + frontdoor["resolvable"] = config["resolvable"] + ccr["updated"] = datetime.now(timezone.utc).date().isoformat() + dump_yaml(path, ccr) + return ccr + + +def record_lifecycle_state_hub( + ccr: dict[str, Any], base_url: str, actor: str, action: str, reason: str, details: list[str] +) -> dict[str, Any]: + openbao = ccr["openbao"] + frontdoor = ccr["access_frontdoor"] + summary = ( + f"CCR {ccr['id']} lifecycle {action} by {actor}: " + f"status={ccr['status']} readiness={frontdoor.get('readiness')} " + f"resolvable={frontdoor.get('resolvable') is True} path={openbao['kv_path']} " + f"policy={openbao['policy_name']}; reason={reason}; " + + "; ".join(details) + ) + return state_hub_post_json( + base_url, + "/progress/", + { + "summary": summary, + "event_type": "credential_change_lifecycle", + "author": actor, + }, + ) + + +def inventory_ccr_from_args(args: argparse.Namespace) -> dict[str, Any]: + fields = list(args.field or []) + if not fields: + fail("at least one --field is required") + reject_secret_values(fields, "inventory field") + for value in ( + args.id, + args.title, + args.tenant, + args.workload, + args.environment, + args.purpose, + args.kv_path, + args.policy_name or "", + args.policy_file or "", + args.auth_mount, + args.auth_role, + args.frontdoor_type, + args.catalog_id, + args.reason, + ): + reject_secret_text(str(value), "inventory metadata") + policy_name = args.policy_name or f"workload-kv-read-{slugify(args.workload)}-{slugify(args.purpose)}" + policy_file = args.policy_file or f"openbao/policies/{policy_name}.hcl" + bound_claims = parse_key_values(args.bound_claim or []) + if args.auth_method == "kubernetes": + if args.service_account: + bound_claims["service_account_names"] = list(args.service_account) + if args.service_account_namespace: + bound_claims["service_account_namespaces"] = list(args.service_account_namespace) + if not bound_claims: + fail("at least one --bound-claim or Kubernetes service account binding is required") + allowed_redirect_uris = list(getattr(args, "redirect_uri", None) or []) + if args.auth_method == "oidc" and not allowed_redirect_uris: + allowed_redirect_uris = [ + "https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback", + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", + ] + + ccr = { + "id": args.id, + "kind": "credential-change-request", + "schema_version": 1, + "request_type": "workload-kv-read", + "title": args.title, + "status": args.status, + "created": datetime.now(timezone.utc).date().isoformat(), + "updated": datetime.now(timezone.utc).date().isoformat(), + "requester": { + "agent": args.requester_agent, + "reason": args.reason, + }, + "review": { + "required": True, + "required_approvers": ["platform-operator"], + "comments": [ + { + "at": utc_now(), + "reviewer": args.actor, + "decision": "inventory_imported", + "comment": args.reason, + } + ], + }, + "target": { + "domain": "financials", + "tenant": args.tenant, + "workload": args.workload, + "environment": args.environment, + "purpose": args.purpose, + }, + "openbao": { + "mount": args.mount, + "kv_path": args.kv_path, + "fields": fields, + "policy_name": policy_name, + "policy_file": policy_file, + "auth": { + "method": args.auth_method, + "mount": args.auth_mount, + "role": args.auth_role, + "bound_claims": bound_claims, + "bound_claims_confirmed": args.bound_claims_confirmed, + "policies": [policy_name], + "ttl": args.ttl, + }, + }, + "access_frontdoor": { + "type": args.frontdoor_type, + "catalog_id": args.catalog_id, + "readiness": args.readiness, + "resolvable": args.resolvable, + "activation": "imported-existing-inventory", + }, + "risk": { + "classification": args.risk, + "notes": ["Imported existing credential lane as non-secret CCR-backed inventory."], + }, + "verification": { + "positive": [args.positive_check], + "negative": [args.negative_check], + "activation_conditions": [ + "Existing policy/auth metadata confirmed without printing secret values.", + "Existing secret value remains under approved OpenBao/operator custody.", + ], + "evidence": [], + }, + "lifecycle": { + "deactivate": "Disable the access front door and remove or detach auth role policy.", + "rotate": "Replace the secret value through approved custody and re-run verification.", + "compromised": "Immediately disable access, rotate value, record blast-radius notes, and open follow-up tasks.", + "events": [], + }, + } + if args.auth_method == "oidc": + auth = ccr["openbao"]["auth"] + auth["allowed_redirect_uris"] = allowed_redirect_uris + auth["oidc_scopes"] = ["openid", "profile", "email", "groups"] + auth["user_claim"] = "sub" + if "groups" in bound_claims: + auth["groups_claim"] = "groups" + + if args.command: + reject_secret_text(args.command, "inventory command") + ccr["access_frontdoor"]["command"] = args.command + if args.selector: + reject_secret_text(args.selector, "inventory selector") + ccr["access_frontdoor"]["selector"] = args.selector + return ccr + + +def inventory_output_path(ccr: dict[str, Any], output_dir: str) -> Path: + output = resolve_repo_path(output_dir) + filename = f"{ccr['id']}-{slugify(ccr['title'])}.yaml" + return output / filename + + +def write_inventory_ccr(args: argparse.Namespace) -> Path: + ccr = inventory_ccr_from_args(args) + output_path = inventory_output_path(ccr, args.output_dir) + output_path.parent.mkdir(parents=True, exist_ok=True) + policy_path = resolve_repo_path(ccr["openbao"]["policy_file"]) + if args.write_policy: + policy_path.parent.mkdir(parents=True, exist_ok=True) + if not policy_path.exists(): + policy_path.write_text(generated_policy_hcl(ccr), encoding="utf-8") + dump_yaml(output_path, ccr) + _ccr, errors, warnings = validate_ccr(output_path) + for warning in warnings: + print(f"[WARN] {output_path.name}: {warning}", file=sys.stderr) + if errors: + for error in errors: + print(f"[FAIL] {output_path.name}: {error}", file=sys.stderr) + raise SystemExit(1) + return output_path + + def validate_or_exit(path: Path) -> tuple[dict[str, Any], list[str]]: ccr, errors, warnings = validate_ccr(path) for warning in warnings: @@ -550,7 +1485,8 @@ def render_status(payload: dict[str, Any]) -> str: return "\n".join(lines) -def append_decision(path: Path, status: str, reviewer: str, comment: str) -> None: +def append_decision(path: Path, status: str, reviewer: str, comment: str) -> dict[str, Any]: + reject_secret_text(comment, "review comment") ccr, _warnings = validate_or_exit(path) review = ccr.setdefault("review", {}) comments = review.setdefault("comments", []) @@ -567,9 +1503,11 @@ def append_decision(path: Path, status: str, reviewer: str, comment: str) -> Non ccr["status"] = status ccr["updated"] = datetime.now(timezone.utc).date().isoformat() dump_yaml(path, ccr) + return ccr def confirm_binding(path: Path, reviewer: str, comment: str) -> None: + reject_secret_text(comment, "binding comment") ccr, errors, _warnings = validate_ccr(path) if errors: for error in errors: @@ -619,6 +1557,89 @@ def state_hub_get_json(base_url: str, path: str) -> dict[str, Any]: return data + +def state_hub_post_json(base_url: str, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = f"{base_url.rstrip('/')}/{path.lstrip('/')}" + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + data = json.load(response) + except urllib.error.HTTPError as exc: + fail(f"State Hub POST {url} failed with HTTP {exc.code}") + except OSError as exc: + fail(f"State Hub POST {url} failed: {exc}") + if not isinstance(data, dict): + fail(f"State Hub POST {url} returned non-object JSON") + return data + + +def decision_template_context(ccr: dict[str, Any]) -> dict[str, str]: + openbao = ccr["openbao"] + auth = openbao["auth"] + state_hub = ccr.get("state_hub", {}) + return { + "id": ccr["id"], + "kv_path": openbao["kv_path"], + "policy_name": openbao["policy_name"], + "auth_role_path": f"auth/{auth['mount']}/role/{auth['role']}", + "decision_link": state_hub.get("decision_api_url") + or state_hub.get("decision_dashboard_url") + or "", + } + + +def decision_templates(ccr: dict[str, Any] | None = None) -> dict[str, str]: + if ccr: + context = decision_template_context(ccr) + else: + context = { + "id": "", + "kv_path": "", + "policy_name": "", + "auth_role_path": "auth//role/", + "decision_link": "", + } + scope = ( + f"{context['id']} path={context['kv_path']} " + f"policy={context['policy_name']} auth_role={context['auth_role_path']}" + ) + return { + "approve": f"APPROVE: {scope}; rationale=", + "deny": f"DENY: {scope}; rationale=", + "needs_changes": f"NEEDS_CHANGES: {scope}; rationale=", + } + + +def render_decision_templates(ccr: dict[str, Any]) -> str: + context = decision_template_context(ccr) + templates = decision_templates(ccr) + lines = [ + f"CCR {context['id']} decision templates", + f"Decision link: {context['decision_link']}", + "Use one of these accepted prefixes exactly:", + ] + for key in ("approve", "deny", "needs_changes"): + lines.append(f"- {templates[key]}") + return "\n".join(lines) + + +def invalid_decision_template_message(ccr: dict[str, Any] | None = None) -> str: + templates = decision_templates(ccr) + return ( + "resolved State Hub decision rationale must start with a recognized " + "decision template:\n" + f" {templates['approve']}\n" + f" {templates['deny']}\n" + f" {templates['needs_changes']}" + ) + + def state_hub_decision_status(ccr: dict[str, Any], base_url: str) -> dict[str, Any]: decision_id = ccr.get("state_hub", {}).get("decision_id") if not decision_id: @@ -626,15 +1647,14 @@ def state_hub_decision_status(ccr: dict[str, Any], base_url: str) -> dict[str, A return state_hub_get_json(base_url, f"/decisions/{decision_id}") -def ccr_status_from_state_hub_rationale(rationale: str) -> str: +def ccr_status_from_state_hub_rationale( + rationale: str, ccr: dict[str, Any] | None = None +) -> str: normalized = rationale.strip().upper().replace("-", "_") for prefix, status in STATE_HUB_DECISION_PREFIXES: if normalized == prefix or normalized.startswith(f"{prefix}:"): return status - fail( - "resolved State Hub decision rationale must start with " - "APPROVE:, DENY:, or NEEDS_CHANGES:" - ) + fail(invalid_decision_template_message(ccr)) def sync_state_hub_decision(path: Path, base_url: str) -> dict[str, Any]: @@ -651,7 +1671,7 @@ def sync_state_hub_decision(path: Path, base_url: str) -> dict[str, Any]: return decision rationale = str(decision.get("rationale") or "") - status = ccr_status_from_state_hub_rationale(rationale) + status = ccr_status_from_state_hub_rationale(rationale, ccr) reviewer = str(decision.get("decided_by") or "state-hub") append_decision( path, @@ -699,6 +1719,13 @@ def command_plan(args: argparse.Namespace) -> int: return 0 +def command_decision_templates(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, _warnings = validate_or_exit(path) + print(render_decision_templates(ccr)) + return 0 + + def command_status(args: argparse.Namespace) -> int: path = resolve_ccr(args.ref) ccr, errors, warnings = validate_ccr(path) @@ -738,10 +1765,148 @@ def command_operator_commands(args: argparse.Namespace) -> int: return 0 + +def command_applier_dry_run(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, warnings = validate_or_exit(path) + blockers = applier_readiness_blockers(ccr) + if blockers: + for blocker in blockers: + print(f"[BLOCK] {path.name}: {blocker}", file=sys.stderr) + return 1 + payload = applier_dry_run_payload(ccr, warnings) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(render_applier_dry_run(payload)) + return 0 + + + +def command_applier_apply(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, warnings = validate_or_exit(path) + blockers = applier_readiness_blockers(ccr) + if blockers: + for blocker in blockers: + print(f"[BLOCK] {path.name}: {blocker}", file=sys.stderr) + return 1 + if args.json: + print(json.dumps(applier_dry_run_payload(ccr, warnings), indent=2, sort_keys=True)) + elif not args.quiet: + print(render_applier_apply_plan(ccr, warnings)) + if args.plan_only: + return 0 + expected = applier_confirmation_phrase(ccr) + phrase = args.confirm or input("Type delegated apply confirmation phrase: ") + if phrase != expected: + fail(f"confirmation phrase mismatch; expected {expected!r}") + run_bao_metadata_apply(ccr, args.bao_bin) + set_status = "applied" if ccr.get("status") == "approved" else None + details = delegated_apply_details(ccr, args.actor) + ccr = append_evidence( + path, + args.actor, + "delegated_metadata_apply", + "passed", + details, + set_status=set_status, + ) + print(f"[OK] {path.name} delegated metadata apply recorded") + if args.record_state_hub: + event = record_evidence_state_hub( + ccr, + args.state_hub_url, + args.actor, + "delegated_metadata_apply", + "passed", + details, + ) + print(f"[OK] State Hub progress event {event.get('id', '')}") + return 0 + + +def command_runbook(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, warnings = validate_or_exit(path) + blockers = runbook_readiness_blockers(ccr) + if blockers: + for blocker in blockers: + print(f"[BLOCK] {path.name}: {blocker}", file=sys.stderr) + return 1 + payload = runbook_payload(ccr, warnings) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(render_runbook(payload)) + if args.execute_metadata: + require_apply_ready(ccr, "runbook --execute-metadata") + phrase = args.confirm or input("Type final confirmation phrase: ") + expected = runbook_confirmation_phrase(ccr) + if phrase != expected: + fail(f"confirmation phrase mismatch; expected {expected!r}") + run_bao_metadata_apply(ccr, args.bao_bin) + details = ["OpenBao policy and auth-role metadata apply completed without secret values"] + ccr = append_evidence( + path, + args.actor, + "metadata_apply", + "passed", + details, + set_status="applied", + ) + print(f"[OK] {path.name} metadata applied and evidence recorded") + if args.record_state_hub: + event = record_evidence_state_hub( + ccr, args.state_hub_url, args.actor, "metadata_apply", "passed", details + ) + print(f"[OK] State Hub progress event {event.get('id', '')}") + return 0 + + +def command_record_evidence(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr = append_evidence( + path, + args.actor, + args.kind, + args.result, + args.detail, + set_status=args.status, + frontdoor_ready=args.frontdoor_ready, + ) + print(f"[OK] {path.name} evidence {args.kind}/{args.result} recorded") + if args.record_state_hub: + event = record_evidence_state_hub( + ccr, args.state_hub_url, args.actor, args.kind, args.result, args.detail + ) + print(f"[OK] State Hub progress event {event.get('id', '')}") + return 0 + + def command_decision(args: argparse.Namespace, status: str) -> int: path = resolve_ccr(args.ref) - append_decision(path, status, args.reviewer, args.comment) + ccr = append_decision(path, status, args.reviewer, args.comment) print(f"[OK] {path.name} -> {status}") + if args.record_state_hub: + openbao = ccr["openbao"] + auth = openbao["auth"] + event = state_hub_post_json( + args.state_hub_url, + "/progress/", + { + "summary": ( + f"CCR {ccr['id']} decision {status} by {args.reviewer}: " + f"path={openbao['kv_path']} policy={openbao['policy_name']} " + f"fields={','.join(openbao['fields'])} " + f"auth_role=auth/{auth['mount']}/role/{auth['role']}; " + f"{args.comment}" + ), + "event_type": "credential_change_decision", + "author": args.reviewer, + }, + ) + print(f"[OK] State Hub progress event {event.get('id', '')}") return 0 @@ -752,6 +1917,44 @@ def command_confirm_binding(args: argparse.Namespace) -> int: return 0 + +def command_lifecycle_plan(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, _warnings = validate_or_exit(path) + payload = lifecycle_payload(ccr, args.action) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(render_lifecycle_plan(payload)) + return 0 + + +def command_lifecycle_event(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr = append_lifecycle_event( + path, + args.actor, + args.action, + args.reason, + args.detail, + blast_radius=args.blast_radius, + follow_up=args.follow_up, + ) + print(f"[OK] {path.name} lifecycle {args.action} -> {ccr['status']}") + if args.record_state_hub: + event = record_lifecycle_state_hub( + ccr, args.state_hub_url, args.actor, args.action, args.reason, args.detail + ) + print(f"[OK] State Hub progress event {event.get('id', '')}") + return 0 + + +def command_import_inventory(args: argparse.Namespace) -> int: + path = write_inventory_ccr(args) + print(f"[OK] inventory CCR written: {display_repo_path(path)}") + return 0 + + def command_sync_decision(args: argparse.Namespace) -> int: path = resolve_ccr(args.ref) decision = sync_state_hub_decision(path, args.state_hub_url) @@ -783,6 +1986,13 @@ def build_parser() -> argparse.ArgumentParser: plan.add_argument("ref") plan.set_defaults(func=command_plan) + templates = sub.add_parser( + "decision-templates", + help="Render State Hub/chat decision rationale templates", + ) + templates.add_argument("ref") + templates.set_defaults(func=command_decision_templates) + status = sub.add_parser("status", help="Render machine-readable readiness status") status.add_argument("ref") status.add_argument("--json", action="store_true") @@ -801,6 +2011,135 @@ def build_parser() -> argparse.ArgumentParser: operator_commands.add_argument("ref") operator_commands.set_defaults(func=command_operator_commands) + applier_dry_run = sub.add_parser( + "applier-dry-run", + help="Validate and render delegated OpenBao metadata mutations", + ) + applier_dry_run.add_argument("ref") + applier_dry_run.add_argument("--json", action="store_true") + applier_dry_run.set_defaults(func=command_applier_dry_run) + + applier_apply = sub.add_parser( + "applier-apply", + help="Apply delegated OpenBao metadata after dry-run guardrails", + ) + applier_apply.add_argument("ref") + applier_apply.add_argument("--actor", default=os.environ.get("USER", "delegated-applier")) + applier_apply.add_argument("--confirm") + applier_apply.add_argument("--bao-bin", default=os.environ.get("BAO_BIN", "bao")) + applier_apply.add_argument("--plan-only", action="store_true") + applier_apply.add_argument("--json", action="store_true") + applier_apply.add_argument("--quiet", action="store_true") + applier_apply.add_argument("--record-state-hub", action="store_true") + applier_apply.add_argument( + "--state-hub-url", + default=os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000"), + ) + applier_apply.set_defaults(func=command_applier_apply) + + runbook = sub.add_parser( + "runbook", + help="Render or execute the attended operator apply/verify runbook", + ) + runbook.add_argument("ref") + runbook.add_argument("--json", action="store_true") + runbook.add_argument("--execute-metadata", action="store_true") + runbook.add_argument("--actor", default=os.environ.get("USER", "operator")) + runbook.add_argument("--confirm") + runbook.add_argument("--bao-bin", default=os.environ.get("BAO_BIN", "bao")) + runbook.add_argument("--record-state-hub", action="store_true") + runbook.add_argument( + "--state-hub-url", + default=os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000"), + ) + runbook.set_defaults(func=command_runbook) + + evidence = sub.add_parser( + "record-evidence", + help="Append non-secret apply/verification evidence to a CCR", + ) + evidence.add_argument("ref") + evidence.add_argument("--actor", required=True) + evidence.add_argument("--kind", required=True) + evidence.add_argument("--result", required=True) + evidence.add_argument("--detail", action="append", required=True) + evidence.add_argument("--status", choices=sorted(ALLOWED_STATUSES)) + evidence.add_argument("--frontdoor-ready", action="store_true") + evidence.add_argument("--record-state-hub", action="store_true") + evidence.add_argument( + "--state-hub-url", + default=os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000"), + ) + evidence.set_defaults(func=command_record_evidence) + + lifecycle_plan = sub.add_parser( + "lifecycle-plan", + help="Render deactivation, rotation, or compromise lifecycle guidance", + ) + lifecycle_plan.add_argument("ref") + lifecycle_plan.add_argument("--action", choices=sorted(LIFECYCLE_ACTIONS), required=True) + lifecycle_plan.add_argument("--json", action="store_true") + lifecycle_plan.set_defaults(func=command_lifecycle_plan) + + lifecycle_event = sub.add_parser( + "lifecycle-event", + help="Record a non-secret deactivation, rotation, or compromise event", + ) + lifecycle_event.add_argument("ref") + lifecycle_event.add_argument("--action", choices=sorted(LIFECYCLE_ACTIONS), required=True) + lifecycle_event.add_argument("--actor", required=True) + lifecycle_event.add_argument("--reason", required=True) + lifecycle_event.add_argument("--detail", action="append", required=True) + lifecycle_event.add_argument("--blast-radius", action="append", default=[]) + lifecycle_event.add_argument("--follow-up", action="append", default=[]) + lifecycle_event.add_argument("--record-state-hub", action="store_true") + lifecycle_event.add_argument( + "--state-hub-url", + default=os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000"), + ) + lifecycle_event.set_defaults(func=command_lifecycle_event) + + inventory = sub.add_parser( + "import-inventory", + help="Create a non-secret CCR for an existing credential lane", + ) + inventory.add_argument("id") + inventory.add_argument("--title", required=True) + inventory.add_argument("--tenant", required=True) + inventory.add_argument("--workload", required=True) + inventory.add_argument("--environment", required=True) + inventory.add_argument("--purpose", required=True) + inventory.add_argument("--mount", default="platform") + inventory.add_argument("--kv-path", required=True) + inventory.add_argument("--field", action="append", required=True) + inventory.add_argument("--policy-name") + inventory.add_argument("--policy-file") + inventory.add_argument("--auth-method", choices=("oidc", "kubernetes"), required=True) + inventory.add_argument("--auth-mount", required=True) + inventory.add_argument("--auth-role", required=True) + inventory.add_argument("--bound-claim", action="append", default=[]) + inventory.add_argument("--redirect-uri", action="append") + inventory.add_argument("--service-account", action="append") + inventory.add_argument("--service-account-namespace", action="append") + inventory.add_argument("--bound-claims-confirmed", action="store_true") + inventory.add_argument("--ttl", default="15m") + inventory.add_argument("--frontdoor-type", required=True) + inventory.add_argument("--catalog-id", required=True) + inventory.add_argument("--selector") + inventory.add_argument("--command") + inventory.add_argument("--status", choices=sorted(ALLOWED_STATUSES), default="active") + inventory.add_argument("--readiness", choices=sorted(FRONTDOOR_READINESS), default="ready") + inventory.add_argument("--resolvable", action="store_true") + inventory.add_argument("--risk", default="high") + inventory.add_argument("--positive-check", default="Authorized caller can fetch the named field without printing the value.") + inventory.add_argument("--negative-check", default="Unauthorized caller cannot read the path or field.") + inventory.add_argument("--requester-agent", default="codex") + inventory.add_argument("--actor", default=os.environ.get("USER", "operator")) + inventory.add_argument("--reason", required=True) + inventory.add_argument("--output-dir", default=str(DEFAULT_CCR_DIR)) + inventory.add_argument("--write-policy", action=argparse.BooleanOptionalAction, default=True) + inventory.set_defaults(func=command_import_inventory) + for name, status in ( ("approve", "approved"), ("deny", "denied"), @@ -810,6 +2149,11 @@ def build_parser() -> argparse.ArgumentParser: decision.add_argument("ref") decision.add_argument("--reviewer", required=True) decision.add_argument("--comment", required=True) + decision.add_argument("--record-state-hub", action="store_true") + decision.add_argument( + "--state-hub-url", + default=os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000"), + ) decision.set_defaults(func=lambda args, status=status: command_decision(args, status)) binding = sub.add_parser( diff --git a/scripts/openbao-apply-credential-change-appliers.py b/scripts/openbao-apply-credential-change-appliers.py new file mode 100755 index 0000000..a24af08 --- /dev/null +++ b/scripts/openbao-apply-credential-change-appliers.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import getpass +import os +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any + +REPO_DIR = Path(__file__).resolve().parents[1] + +APPLIERS: dict[str, dict[str, Any]] = { + "nonprod": { + "title": "Credential change non-production metadata applier", + "policy_name": "credential-change-nonprod-applier", + "policy_file": "openbao/policies/credential-change-nonprod-applier.hcl", + "token_role": "credential-change-nonprod-applier", + "max_ttl": "1h", + }, + "prod": { + "title": "Credential change production metadata applier", + "policy_name": "credential-change-prod-applier", + "policy_file": "openbao/policies/credential-change-prod-applier.hcl", + "token_role": "credential-change-prod-applier", + "max_ttl": "30m", + }, +} + +DISALLOWED_POLICIES = ("root", "platform-admin") + + +class BaoRunner: + def __init__( + self, + *, + kubectl: str, + namespace: str, + release: str, + dry_run: bool, + use_token_helper: bool, + token: str | None, + ) -> None: + self.kubectl_parts = shlex.split(kubectl) + self.namespace = namespace + self.pod = f"{release}-0" + self.dry_run = dry_run + self.use_token_helper = use_token_helper + self.token = token + + def run( + self, args: list[str], input_text: str | None = None + ) -> subprocess.CompletedProcess[str]: + rendered = "bao " + shlex.join(args) + if self.dry_run: + print(f"DRY-RUN: {rendered}") + return subprocess.CompletedProcess(args, 0, "", "") + + if self.use_token_helper: + cmd = ( + self.kubectl_parts + + ["exec", "-i", "-n", self.namespace, self.pod, "--", "bao"] + + args + ) + proc_input = input_text + else: + if not self.token: + raise RuntimeError( + "OpenBao token is required unless --use-token-helper is set" + ) + cmd = ( + self.kubectl_parts + + [ + "exec", + "-i", + "-n", + self.namespace, + self.pod, + "--", + "sh", + "-c", + 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"', + "sh", + ] + + args + ) + proc_input = self.token + "\n" + (input_text or "") + + result = subprocess.run(cmd, input=proc_input, capture_output=True, text=True) + if result.stdout: + print(result.stdout, end="") + if result.returncode != 0: + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + raise SystemExit(result.returncode) + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + return result + + +def read_token( + token_file: str | None, dry_run: bool, use_token_helper: bool +) -> str | None: + if dry_run or use_token_helper: + return None + if token_file: + path = Path(token_file) + if not path.exists(): + raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE does not exist: {path}") + lines = path.read_text(encoding="utf-8").splitlines() + token = lines[0].strip() if lines else "" + if not token: + raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE is empty: {path}") + return token + token = getpass.getpass("OpenBao token: ") + if not token: + raise SystemExit("ERROR: empty OpenBao token") + return token + + +def selected_appliers(selector: str) -> list[dict[str, Any]]: + if selector == "all": + return [APPLIERS["nonprod"], APPLIERS["prod"]] + try: + return [APPLIERS[selector]] + except KeyError: + raise SystemExit(f"ERROR: applier must be one of {sorted(APPLIERS) + ['all']}") + + +def role_args(applier: dict[str, Any]) -> list[str]: + return [ + "write", + f"auth/token/roles/{applier['token_role']}", + f"allowed_policies={applier['policy_name']}", + f"disallowed_policies={','.join(DISALLOWED_POLICIES)}", + "orphan=true", + "renewable=false", + f"token_explicit_max_ttl={applier['max_ttl']}", + "token_no_default_policy=true", + "token_type=service", + ] + + +def write_policy(runner: BaoRunner, applier: dict[str, Any], policy_dir: Path) -> None: + policy_file = policy_dir / Path(applier["policy_file"]).name + if not policy_file.exists(): + raise SystemExit(f"ERROR: missing policy file: {policy_file}") + if runner.dry_run: + print(f"DRY-RUN: bao policy write {applier['policy_name']} {policy_file}") + return + runner.run( + ["policy", "write", applier["policy_name"], "-"], + input_text=policy_file.read_text(encoding="utf-8"), + ) + print(f"OK: policy {applier['policy_name']} applied") + + +def apply_applier(runner: BaoRunner, applier: dict[str, Any], policy_dir: Path) -> None: + write_policy(runner, applier, policy_dir) + runner.run(role_args(applier)) + runner.run(["read", f"auth/token/roles/{applier['token_role']}"]) + print( + "OK: applier role " + f"{applier['token_role']} configured for policy {applier['policy_name']}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Apply OpenBao credential-change delegated applier policies and token roles." + ) + parser.add_argument( + "--applier", choices=["nonprod", "prod", "all"], default="all" + ) + parser.add_argument("--policy-dir", default="openbao/policies") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument( + "--use-token-helper", + action="store_true", + help="Use the OpenBao CLI token helper inside the pod", + ) + parser.add_argument("--namespace", default=None) + parser.add_argument("--release", default=None) + parser.add_argument("--kubectl", default=None) + args = parser.parse_args() + + namespace = args.namespace or os.environ.get("OPENBAO_NAMESPACE", "openbao") + release = args.release or os.environ.get("OPENBAO_RELEASE", "openbao") + kubectl = args.kubectl or os.environ.get("KUBECTL", "kubectl") + token_file = os.environ.get("OPENBAO_TOKEN_FILE") + token = read_token(token_file, args.dry_run, args.use_token_helper) + + runner = BaoRunner( + kubectl=kubectl, + namespace=namespace, + release=release, + dry_run=args.dry_run, + use_token_helper=args.use_token_helper, + token=token, + ) + + if not args.dry_run: + runner.run(["status"]) + + policy_dir = REPO_DIR / args.policy_dir + for applier in selected_appliers(args.applier): + apply_applier(runner, applier, policy_dir) + + print("NEXT: issue short-lived child tokens through an approved custody path only.") + print("NEXT: run scripts/credential-change.py applier-apply with that ambient delegated authority.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/openbao-apply-external-secrets-issue-core.sh b/scripts/openbao-apply-external-secrets-issue-core.sh index be2812d..b50f183 100755 --- a/scripts/openbao-apply-external-secrets-issue-core.sh +++ b/scripts/openbao-apply-external-secrets-issue-core.sh @@ -11,6 +11,9 @@ ESO_NAMESPACE="${ESO_NAMESPACE:-external-secrets}" ESO_SERVICE_ACCOUNT="${ESO_SERVICE_ACCOUNT:-external-secrets}" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" POLICY_FILE="${POLICY_FILE:-$REPO_DIR/openbao/policies/external-secrets-issue-core.hcl}" +NEXT_KV_PATH="${OPENBAO_ESO_NEXT_PATH:-platform/workloads/issue-core/issue-core/issue-core-runtime}" +NEXT_FIELDS="${OPENBAO_ESO_NEXT_FIELDS:-ISSUE_CORE_API_KEY and GITEA_BACKEND_TOKEN}" +NEXT_TARGET="${OPENBAO_ESO_NEXT_TARGET:-ExternalSecret/issue-core-runtime}" DRY_RUN=0 usage() { @@ -125,13 +128,12 @@ remote_bao "$token" write "auth/kubernetes/role/${ROLE_NAME}" \ remote_bao "$token" read "auth/kubernetes/role/${ROLE_NAME}" -cat <<'NEXT' +cat < None: - _ccr, errors, warnings = credential_change.validate_ccr(self.issue_core) + def test_issue_core_ccr_has_confirmed_eso_binding(self) -> None: + ccr, errors, warnings = credential_change.validate_ccr(self.issue_core) self.assertEqual(errors, []) - self.assertIn("bound claim is not confirmed", warnings[0]) + self.assertEqual(warnings, []) + self.assertEqual(ccr["openbao"]["auth"]["role"], "external-secrets-issue-core") def test_all_repo_ccrs_validate(self) -> None: for path in sorted((REPO_DIR / "credential-change-requests").glob("*.yaml")): @@ -125,8 +126,8 @@ class CredentialChangeTests(unittest.TestCase): 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.assertEqual(payload["bound_service_account_names"], ["external-secrets"]) + self.assertEqual(payload["bound_service_account_namespaces"], ["external-secrets"]) self.assertNotIn("bound_claims", payload) def test_oidc_auth_payload_includes_redirect_uris(self) -> None: @@ -150,6 +151,63 @@ class CredentialChangeTests(unittest.TestCase): with self.assertRaises(SystemExit): credential_change.command_apply_plan(type("Args", (), {"ref": str(self.issue_core)})()) + def test_plan_includes_source_artifact_diff_status(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + rendered = credential_change.render_plan(ccr) + self.assertIn("Source artifact diff:", rendered) + self.assertIn("artifact status: matches", rendered) + + def test_decision_templates_prefill_review_context(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + rendered = credential_change.render_decision_templates(ccr) + self.assertIn("APPROVE: CCR-2026-0001", rendered) + self.assertIn("DENY: CCR-2026-0001", rendered) + self.assertIn("NEEDS_CHANGES: CCR-2026-0001", rendered) + self.assertIn("platform/workloads/coulomb/whynot-design/npm-publish", rendered) + self.assertIn("workload-kv-read-whynot-design-npm-publish", rendered) + self.assertIn("auth/netkingdom/role/whynot-design-workload-kv-read", rendered) + + def test_invalid_state_hub_rationale_shows_templates(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + with self.assertRaises(SystemExit) as raised: + credential_change.ccr_status_from_state_hub_rationale("looks good", ccr) + self.assertIn("APPROVE: CCR-2026-0001", str(raised.exception)) + self.assertIn("NEEDS_CHANGES: CCR-2026-0001", str(raised.exception)) + + def test_decision_command_can_record_state_hub_event(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.issue_core.name + shutil.copy2(self.issue_core, copied) + events = [] + original = credential_change.state_hub_post_json + try: + credential_change.state_hub_post_json = ( + lambda _base_url, _path, payload: events.append(payload) or {"id": "event-1"} + ) + exit_code = credential_change.command_decision( + type( + "Args", + (), + { + "ref": str(copied), + "reviewer": "unit-test", + "comment": "scoped metadata looks correct", + "record_state_hub": True, + "state_hub_url": "http://state-hub.test", + }, + )(), + "approved", + ) + finally: + credential_change.state_hub_post_json = original + self.assertEqual(exit_code, 0) + self.assertEqual(events[0]["event_type"], "credential_change_decision") + self.assertIn("CCR-2026-0002", events[0]["summary"]) + self.assertIn("ISSUE_CORE_API_KEY", events[0]["summary"]) + 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, []) @@ -207,6 +265,9 @@ class CredentialChangeTests(unittest.TestCase): credential_change.append_decision( copied, "approved", "unit-test", "looks right" ) + copied_data = credential_change.load_yaml(copied) + copied_data["openbao"]["auth"]["bound_claims_confirmed"] = False + credential_change.dump_yaml(copied, copied_data) ccr, errors, _warnings = credential_change.validate_ccr(copied) self.assertEqual(errors, []) self.assertEqual(ccr["status"], "approved") @@ -241,6 +302,371 @@ class CredentialChangeTests(unittest.TestCase): self.assertNotIn("*", policy) self.assertNotIn("delete", policy) + def test_applier_dry_run_succeeds_for_active_ccr(self) -> None: + ccr, errors, warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + self.assertEqual(credential_change.applier_readiness_blockers(ccr), []) + payload = credential_change.applier_dry_run_payload(ccr, warnings) + self.assertEqual(payload["source_artifacts"]["policy"]["status"], "matches") + self.assertEqual( + payload["mutations"][0]["openbao_path"], + "sys/policies/acl/workload-kv-read-whynot-design-npm-publish", + ) + self.assertEqual( + payload["mutations"][1]["openbao_path"], + "auth/netkingdom/role/whynot-design-workload-kv-read", + ) + rendered = credential_change.render_applier_dry_run(payload) + self.assertIn("Allowed metadata mutations", rendered) + self.assertIn("secret value writes", rendered) + self.assertNotIn("", rendered) + + def test_applier_dry_run_refuses_unapproved_ccr(self) -> None: + exit_code = credential_change.command_applier_dry_run( + type("Args", (), {"ref": str(self.issue_core), "json": False})() + ) + self.assertEqual(exit_code, 1) + + def test_applier_dry_run_rejects_out_of_policy_policy_name(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + ccr["status"] = "approved" + ccr["openbao"]["policy_name"] = "platform-admin" + ccr["openbao"]["auth"]["policies"] = ["platform-admin"] + blockers = credential_change.applier_readiness_blockers(ccr) + self.assertTrue( + any("disallowed" in blocker for blocker in blockers), + blockers, + ) + + def test_applier_dry_run_rejects_out_of_policy_auth_role(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + ccr["status"] = "approved" + ccr["openbao"]["auth"]["role"] = "platform-admin" + blockers = credential_change.applier_readiness_blockers(ccr) + self.assertTrue( + any("auth.role is disallowed" in blocker for blocker in blockers), + blockers, + ) + + def test_applier_dry_run_rejects_out_of_scope_mount_and_path(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + ccr["status"] = "approved" + ccr["openbao"]["mount"] = "secret" + ccr["openbao"]["kv_path"] = "secret/platform-admin" + blockers = credential_change.applier_readiness_blockers(ccr) + self.assertIn("openbao.mount must be platform, got secret", blockers) + self.assertIn("openbao.kv_path must stay under platform/workloads/", blockers) + + def test_nonprod_applier_policy_remains_metadata_only(self) -> None: + policy = ( + REPO_DIR / "openbao/policies/credential-change-nonprod-applier.hcl" + ).read_text(encoding="utf-8") + self.assertIn('path "sys/policies/acl/workload-kv-read-*"', policy) + self.assertIn('path "auth/kubernetes/role/*"', policy) + self.assertNotIn('path "platform/data/', policy) + self.assertNotIn('path "platform/metadata/', policy) + + def test_applier_apply_plan_renders_confirmation(self) -> None: + ccr, errors, warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + rendered = credential_change.render_applier_apply_plan(ccr, warnings) + self.assertIn("DELEGATED APPLY CCR-2026-0001", rendered) + self.assertIn("applier-apply CCR-2026-0001", rendered) + self.assertIn("secret value writes", rendered) + + def test_applier_apply_refuses_unapproved_ccr(self) -> None: + exit_code = credential_change.command_applier_apply( + type( + "Args", + (), + { + "ref": str(self.issue_core), + "actor": "unit-test", + "confirm": None, + "bao_bin": "bao", + "plan_only": False, + "json": False, + "quiet": True, + "record_state_hub": False, + "state_hub_url": "http://state-hub.test", + }, + )() + ) + self.assertEqual(exit_code, 1) + + def test_applier_apply_records_metadata_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + ccr = credential_change.load_yaml(copied) + ccr["status"] = "approved" + ccr["access_frontdoor"]["readiness"] = "approved-pending-apply" + ccr["access_frontdoor"]["resolvable"] = False + credential_change.dump_yaml(copied, ccr) + calls = [] + events = [] + original_apply = credential_change.run_bao_metadata_apply + original_post = credential_change.state_hub_post_json + try: + credential_change.run_bao_metadata_apply = lambda ccr, bao_bin: calls.append((ccr["id"], bao_bin)) + credential_change.state_hub_post_json = ( + lambda _base_url, _path, payload: events.append(payload) or {"id": "event-1"} + ) + exit_code = credential_change.command_applier_apply( + type( + "Args", + (), + { + "ref": str(copied), + "actor": "unit-test", + "confirm": "DELEGATED APPLY CCR-2026-0001", + "bao_bin": "bao-test", + "plan_only": False, + "json": False, + "quiet": True, + "record_state_hub": True, + "state_hub_url": "http://state-hub.test", + }, + )() + ) + finally: + credential_change.run_bao_metadata_apply = original_apply + credential_change.state_hub_post_json = original_post + self.assertEqual(exit_code, 0) + self.assertEqual(calls, [("CCR-2026-0001", "bao-test")]) + updated = credential_change.load_yaml(copied) + self.assertEqual(updated["status"], "applied") + self.assertEqual(updated["verification"]["evidence"][-1]["kind"], "delegated_metadata_apply") + self.assertEqual(events[0]["event_type"], "credential_change_evidence") + self.assertIn("delegated_metadata_apply", events[0]["summary"]) + + def test_applier_apply_requires_exact_confirmation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + ccr = credential_change.load_yaml(copied) + ccr["status"] = "approved" + credential_change.dump_yaml(copied, ccr) + with self.assertRaises(SystemExit): + credential_change.command_applier_apply( + type( + "Args", + (), + { + "ref": str(copied), + "actor": "unit-test", + "confirm": "apply it", + "bao_bin": "bao", + "plan_only": False, + "json": False, + "quiet": True, + "record_state_hub": False, + "state_hub_url": "http://state-hub.test", + }, + )() + ) + + def test_runbook_renders_apply_verify_guidance(self) -> None: + ccr, errors, warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + payload = credential_change.runbook_payload(ccr, warnings) + rendered = credential_change.render_runbook(payload) + self.assertIn("APPLY CCR-2026-0001", rendered) + self.assertIn("runbook --execute-metadata", rendered) + self.assertIn("record-evidence ", rendered) + self.assertIn("Field presence checked without printing values", rendered) + self.assertNotIn("npm_", rendered) + + def test_runbook_refuses_unapproved_ccr(self) -> None: + exit_code = credential_change.command_runbook( + type( + "Args", + (), + { + "ref": str(self.issue_core), + "json": False, + "execute_metadata": False, + "actor": "unit-test", + "confirm": None, + "bao_bin": "bao", + "record_state_hub": False, + "state_hub_url": "http://state-hub.test", + }, + )() + ) + self.assertEqual(exit_code, 1) + + def test_record_evidence_appends_non_secret_entry_and_status(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + ccr = credential_change.load_yaml(copied) + ccr["status"] = "approved" + ccr["access_frontdoor"]["readiness"] = "approved-pending-apply" + ccr["access_frontdoor"]["resolvable"] = False + credential_change.dump_yaml(copied, ccr) + updated = credential_change.append_evidence( + copied, + "unit-test", + "metadata_apply", + "passed", + ["OpenBao audit timestamp recorded without secret values"], + set_status="applied", + ) + self.assertEqual(updated["status"], "applied") + self.assertEqual(updated["verification"]["evidence"][-1]["kind"], "metadata_apply") + self.assertEqual( + updated["verification"]["evidence"][-1]["details"], + ["OpenBao audit timestamp recorded without secret values"], + ) + + def test_record_evidence_can_mark_frontdoor_ready(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + updated = credential_change.append_evidence( + copied, + "unit-test", + "frontdoor_activation", + "passed", + ["Catalog readiness checked without secret values"], + set_status="active", + frontdoor_ready=True, + ) + self.assertEqual(updated["status"], "active") + self.assertEqual(updated["access_frontdoor"]["readiness"], "ready") + self.assertTrue(updated["access_frontdoor"]["resolvable"]) + + def test_record_evidence_rejects_secret_markers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + with self.assertRaises(SystemExit): + credential_change.append_evidence( + copied, + "unit-test", + "positive_verification", + "passed", + ["accidentally pasted sk-test"], + ) + + def test_lifecycle_plan_renders_deactivation_steps(self) -> None: + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + payload = credential_change.lifecycle_payload(ccr, "deactivate") + rendered = credential_change.render_lifecycle_plan(payload) + self.assertIn("lifecycle plan: deactivate", rendered) + self.assertIn("readiness=disabled resolvable=False", rendered) + self.assertIn("bao delete auth/netkingdom/role/whynot-design-workload-kv-read", rendered) + self.assertIn("bao policy delete workload-kv-read-whynot-design-npm-publish", rendered) + self.assertNotIn("NPM_AUTH_TOKEN=", rendered) + + def test_lifecycle_event_marks_deactivated_and_disables_frontdoor(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + updated = credential_change.append_lifecycle_event( + copied, + "unit-test", + "deactivate", + "No longer needed", + ["Front door disabled in catalog"], + ) + self.assertEqual(updated["status"], "deactivated") + self.assertEqual(updated["access_frontdoor"]["readiness"], "disabled") + self.assertFalse(updated["access_frontdoor"]["resolvable"]) + self.assertEqual(updated["lifecycle"]["events"][-1]["action"], "deactivate") + + def test_lifecycle_event_records_compromise_blast_radius_and_follow_up(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + updated = credential_change.append_lifecycle_event( + copied, + "unit-test", + "compromise", + "Unexpected exposure signal", + ["Access disabled before rotation"], + blast_radius=["npm publishing lane only"], + follow_up=["incident-task-1"], + ) + event = updated["lifecycle"]["events"][-1] + self.assertEqual(updated["status"], "compromised") + self.assertEqual(updated["access_frontdoor"]["readiness"], "compromised") + self.assertEqual(event["blast_radius"], ["npm publishing lane only"]) + self.assertEqual(event["follow_up"], ["incident-task-1"]) + + def test_lifecycle_event_rejects_secret_markers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.sample.name + shutil.copy2(self.sample, copied) + with self.assertRaises(SystemExit): + credential_change.append_lifecycle_event( + copied, + "unit-test", + "rotate", + "accidentally pasted ghp_bad", + ["rotation needed"], + ) + + def test_import_inventory_writes_non_secret_ccr_and_policy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + output_dir = tmp_path / "ccrs" + policy_file = tmp_path / "policies" / "workload-kv-read-imported-lane.hcl" + args = type( + "Args", + (), + { + "id": "CCR-2099-0001", + "title": "imported lane", + "tenant": "coulomb", + "workload": "imported", + "environment": "production", + "purpose": "runtime token", + "mount": "platform", + "kv_path": "platform/workloads/coulomb/imported/runtime-token", + "field": ["RUNTIME_TOKEN"], + "policy_name": "workload-kv-read-imported-lane", + "policy_file": str(policy_file), + "auth_method": "oidc", + "auth_mount": "netkingdom", + "auth_role": "imported-workload-kv-read", + "bound_claim": ["groups=imported"], + "service_account": None, + "service_account_namespace": None, + "bound_claims_confirmed": True, + "ttl": "15m", + "frontdoor_type": "ops-warden", + "catalog_id": "imported-runtime-token", + "selector": None, + "command": None, + "status": "active", + "readiness": "ready", + "resolvable": True, + "risk": "high", + "positive_check": "Authorized caller can fetch RUNTIME_TOKEN with output suppressed.", + "negative_check": "Unauthorized caller cannot read the imported path.", + "requester_agent": "unit-test", + "actor": "unit-test", + "reason": "Imported existing lane without secret values", + "output_dir": str(output_dir), + "write_policy": True, + }, + )() + path = credential_change.write_inventory_ccr(args) + self.assertTrue(path.exists()) + self.assertTrue(policy_file.exists()) + ccr, errors, warnings = credential_change.validate_ccr(path) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + self.assertEqual(ccr["openbao"]["fields"], ["RUNTIME_TOKEN"]) + self.assertNotIn("ghp_", path.read_text(encoding="utf-8")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_credential_change_appliers.py b/tests/test_credential_change_appliers.py new file mode 100644 index 0000000..fd5525f --- /dev/null +++ b/tests/test_credential_change_appliers.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import io +import sys +import unittest +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "openbao_credential_change_appliers", + REPO_DIR / "scripts/openbao-apply-credential-change-appliers.py", +) +appliers = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = appliers +SPEC.loader.exec_module(appliers) + + +class CredentialChangeApplierSetupTests(unittest.TestCase): + def test_selected_appliers_all_is_stable(self) -> None: + selected = appliers.selected_appliers("all") + self.assertEqual( + [item["token_role"] for item in selected], + ["credential-change-nonprod-applier", "credential-change-prod-applier"], + ) + + def test_role_args_are_bounded(self) -> None: + args = appliers.role_args(appliers.APPLIERS["prod"]) + self.assertIn("auth/token/roles/credential-change-prod-applier", args) + self.assertIn("allowed_policies=credential-change-prod-applier", args) + self.assertIn("disallowed_policies=root,platform-admin", args) + self.assertIn("token_no_default_policy=true", args) + self.assertIn("token_type=service", args) + + def test_dry_run_applies_policy_role_and_readback(self) -> None: + runner = appliers.BaoRunner( + kubectl="kubectl", + namespace="openbao", + release="openbao", + dry_run=True, + use_token_helper=False, + token=None, + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + appliers.apply_applier( + runner, + appliers.APPLIERS["nonprod"], + REPO_DIR / "openbao/policies", + ) + rendered = output.getvalue() + self.assertIn( + "DRY-RUN: bao policy write credential-change-nonprod-applier", + rendered, + ) + self.assertIn( + "DRY-RUN: bao write auth/token/roles/credential-change-nonprod-applier", + rendered, + ) + self.assertIn( + "DRY-RUN: bao read auth/token/roles/credential-change-nonprod-applier", + rendered, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md b/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md index a7c9661..f1b9c96 100644 --- a/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md +++ b/workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md @@ -4,18 +4,19 @@ type: workplan title: "Workload KV Access Lanes for ops-warden Fetch" domain: financials repo: railiance-platform -status: active +status: finished owner: codex topic_slug: railiance planning_priority: high planning_order: 6 created: "2026-06-27" -updated: "2026-06-28" +updated: "2026-06-29" depends_on_workplans: - RAIL-PL-WP-0002 - RAILIANCE-WP-0004 related_state_hub_messages: - "551031d1-335e-4db8-9535-820fea52d0a3" + - "f76d3a9e-a98f-4081-885d-b79d94312699" state_hub_workstream_id: "96c8a93d-7a5a-4fa9-8f7b-865119551da3" --- @@ -268,7 +269,7 @@ groups bound-claim mismatch. `platform-root` was restored to the ```task id: RAILIANCE-WP-0006-T06 -status: progress +status: done priority: high state_hub_task_id: "8e84ec19-01db-4baf-a532-de87e51d4994" ``` @@ -301,6 +302,16 @@ catalog. Keep activation pending until caller verification and catalog update. to confirm that its dedicated `whynot-design-npm-publish` catalog selector resolves through the caller-scoped lane. +**2026-06-29:** ops-warden confirmed in State Hub message +`f76d3a9e-a98f-4081-885d-b79d94312699` that catalog selector +`whynot-design-npm-publish` is `status: active`, `resolvable: true`, and wired +to the owner-confirmed lane: +`platform/workloads/coulomb/whynot-design/npm-publish`, field +`NPM_AUTH_TOKEN`, OIDC role `whynot-design-workload-kv-read`, and policy +`workload-kv-read-whynot-design-npm-publish`. ops-warden also confirmed it +notified whynot-design with `warden access whynot-design-npm-publish --exec -- npm publish`, +and that the sibling lanes remain draft for separate planning. + ## T07 - Decide whether to batch sibling workload-KV requests ```task @@ -329,6 +340,13 @@ 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. +**2026-06-29:** Reviewed the sibling lane suggestions against `INTENT.md`. +Created follow-up workplans `RAILIANCE-WP-0009` for the issue-core runtime +ingestion credential lane and `RAILIANCE-WP-0010` for the llm-connect +OpenRouter provider key lane. Both plans keep this repo's scope limited to +shared platform secret custody, least-privilege OpenBao/External Secrets +delivery, verification, and ops-warden front-door handoff. + ## Exit Criteria - The whynot-design npm publish token has a concrete OpenBao KV path, field, diff --git a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md index 93c8fff..9123edc 100644 --- a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md +++ b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md @@ -4,13 +4,13 @@ type: workplan title: "Credential Change Proposal Review Workflow" domain: financials repo: railiance-platform -status: active +status: finished owner: codex topic_slug: railiance planning_priority: high planning_order: 7 created: "2026-06-27" -updated: "2026-06-28" +updated: "2026-06-30" depends_on_workplans: - RAIL-PL-WP-0002 - RAILIANCE-WP-0005 @@ -156,7 +156,7 @@ lives in `tests/test_credential_change.py`. ```task id: RAILIANCE-WP-0007-T04 -status: progress +status: done priority: high state_hub_task_id: "1b2e7752-815c-46f8-a2e2-212e8d04da80" ``` @@ -184,11 +184,17 @@ from reviewed plan to the interactive live applier. generated role payloads after live OpenBao rejected an OIDC role without callbacks. Unit coverage now checks the generated whynot-design role payload. +**2026-06-30:** Added source-artifact diff rendering to `plan` and delegated +`applier-dry-run` output. The generated plan now reports whether the checked-in +policy artifact matches the CCR-generated HCL and shows a unified diff when it +does not. Approved-only `apply-plan`/`operator-commands` remain gated by CCR +status and confirmed auth binding. + ## T05 - Add chat/CLI approval commands ```task id: RAILIANCE-WP-0007-T05 -status: progress +status: done priority: high state_hub_task_id: "e6d4d2d1-1881-4db7-92f8-05e3fdb846ae" ``` @@ -215,11 +221,16 @@ State Hub decision-event emission and tighter chat integration. Created a State Hub decision for `CCR-2026-0001` and added `sync-decision` so resolved State Hub decisions can update the file-backed CCR status. +**2026-06-30:** Added optional `--record-state-hub` emission for approve, deny, +and needs-changes commands. Review comments are checked for known secret markers +before being written, and the State Hub progress event records only non-secret +CCR id/path/policy/field/auth-role metadata plus the reviewer comment. + ## T06 - Build an interactive runbook for apply and verify ```task id: RAILIANCE-WP-0007-T06 -status: todo +status: done priority: high state_hub_task_id: "3c3fc38c-afa4-4367-b3e6-ba4b286ced30" ``` @@ -235,11 +246,23 @@ Acceptance: - Positive and negative verification steps are guided. - Non-secret evidence is recorded automatically. +**2026-06-30:** Added `scripts/credential-change.py runbook ` and Make +target `credential-change-runbook` to render the attended operator checklist, +final confirmation phrase, metadata apply guidance, secret custody instructions, +positive/negative verification steps, activation conditions, and evidence +commands. `runbook --execute-metadata` is opt-in, requires the exact `APPLY +` confirmation phrase, uses the local `bao` CLI with ambient approved +operator authority, writes only policy/auth metadata, and records a non-secret +`metadata_apply` evidence entry. Added `record-evidence` plus Make target +`credential-change-record-evidence` so operators can append apply, secret +provisioning, verification, and activation evidence to the CCR and optionally +State Hub without storing secret values. + ## T07 - Pilot with whynot-design and ops-warden ```task id: RAILIANCE-WP-0007-T07 -status: progress +status: done priority: high state_hub_task_id: "07a7d8bf-5528-41c8-a791-d6ccd0466a33" ``` @@ -302,11 +325,15 @@ groups bound-claim mismatch, `platform-root` membership was restored afterward, and `CCR-2026-0001` is now active/ready/resolvable. ops-warden catalog confirmation remains the external closeout step. +**2026-06-30:** Closed the pilot task based on the active/ready/resolvable CCR +state and prior ops-warden catalog confirmation that the selector is active and +resolvable. The remaining lifecycle work is now tracked separately in T08. + ## T08 - Add deactivation, rotation, and compromise flows ```task id: RAILIANCE-WP-0007-T08 -status: todo +status: done priority: medium state_hub_task_id: "23d6ef9d-8dbc-4468-b486-5ec8ada71130" ``` @@ -322,11 +349,22 @@ Acceptance: - Deactivation disables the relevant access front door and auth/policy path. - Compromise flow records blast-radius notes and required follow-up tasks. +**2026-06-30:** Added `lifecycle-plan`, `lifecycle-event`, and +`import-inventory` commands plus Make targets. Lifecycle plans render +deactivation, rotation, and compromise guidance, including access-front-door +state changes and OpenBao metadata disable commands for deactivation or +compromise. Lifecycle events update CCR status/front-door readiness, append +non-secret lifecycle evidence, and optionally post State Hub progress. +Compromise events accept non-secret blast-radius and follow-up references. +`import-inventory` can create a CCR-backed inventory file and matching read +policy artifact for an existing lane without asking for or storing secret +values. + ## T09 - Add decision templates and guided review actions ```task id: RAILIANCE-WP-0007-T09 -status: todo +status: done priority: high state_hub_task_id: "c436fd8b-cd82-4600-81b0-87ec069d7ae6" ``` @@ -348,6 +386,12 @@ Acceptance: - Future UI work can replace prefix parsing with structured decision outcomes without changing the CCR audit trail. +**2026-06-30:** Added `scripts/credential-change.py decision-templates ` +and Make target `credential-change-decision-templates`. The generated templates +include accepted prefixes, CCR id, KV path, policy, auth-role path, and the +linked State Hub decision. Ambiguous State Hub rationale text now fails with the +valid templates in the error message. + ## Exit Criteria - A human can review and approve or deny a credential/security change without diff --git a/workplans/RAILIANCE-WP-0008-openbao-approved-automation-delegation.md b/workplans/RAILIANCE-WP-0008-openbao-approved-automation-delegation.md index bac75fe..082a9f0 100644 --- a/workplans/RAILIANCE-WP-0008-openbao-approved-automation-delegation.md +++ b/workplans/RAILIANCE-WP-0008-openbao-approved-automation-delegation.md @@ -10,7 +10,7 @@ topic_slug: railiance planning_priority: high planning_order: 8 created: "2026-06-28" -updated: "2026-06-28" +updated: "2026-06-30" depends_on_workplans: - RAIL-PL-WP-0002 - RAILIANCE-WP-0005 @@ -114,7 +114,7 @@ and the local applier script. ```task id: RAILIANCE-WP-0008-T01 -status: todo +status: done priority: high state_hub_task_id: "d19fdfc5-addb-4813-8086-3aca2e948cea" ``` @@ -129,11 +129,20 @@ Acceptance: - The proposal covers both workload KV read lanes and credential broker issuer policies. +**2026-06-29:** Added `docs/openbao-approved-automation-delegation.md` and +`openbao/policies/credential-change-prod-applier.hcl`. The document defines +build/development, test/staging, and production boundaries, the allowed +production metadata mutation surface, denied secret/admin paths, and required +non-secret evidence. The production policy candidate allows only reviewed +metadata writes for workload KV read policies, credential-broker issuer +policies, approved auth-role prefixes, and self capability checks; it does not +grant secret value reads or writes. + ## T02 - Implement a CCR-aware applier dry-run ```task id: RAILIANCE-WP-0008-T02 -status: todo +status: done priority: high state_hub_task_id: "2613f40d-fbd9-44f3-a864-85ec1d54e8f7" ``` @@ -149,11 +158,22 @@ Acceptance: - Dry-run refuses attempts to create `root`, `platform-admin`, wildcard, or unrelated policy names. +**2026-06-29:** Added `scripts/credential-change.py applier-dry-run ` and +Make target `credential-change-applier-dry-run`. The dry-run validates the CCR, +requires approved/applied/verified/active status, requires confirmed auth +bindings, verifies the OpenBao mount/path/policy/role stay inside the delegated +metadata surface, compares the policy artifact to the generated CCR policy body, +and renders only policy/auth-role mutations. It explicitly leaves secret value +writes, secret reads, and front-door activation out of scope. Unit tests cover +the active whynot-design CCR success path, unapproved CCR refusal, and rejection +of `platform-admin`/out-of-scope mount and path attempts. `make +credential-tests` passed with 28 tests. + ## T03 - Add non-production applier role first ```task id: RAILIANCE-WP-0008-T03 -status: todo +status: progress priority: medium state_hub_task_id: "ff927a19-50fb-4351-8db1-c60a0cce0995" ``` @@ -167,11 +187,32 @@ Acceptance: - Negative checks prove unrelated policy/auth/secret paths are denied. - Evidence is recorded without secret values. +**2026-06-30:** Added the non-production metadata-only policy candidate +`openbao/policies/credential-change-nonprod-applier.hcl` and documented that +generated test-secret paths require separate CCR-backed approval. Live non-prod +identity creation and positive/negative OpenBao evidence remain to close this +task. + +**2026-06-30:** Added the guarded `applier-apply` execution path that reuses the +CCR dry-run guardrails, requires exact `DELEGATED APPLY ` confirmation, +uses the local `bao` CLI with ambient delegated applier authority, writes only +policy/auth-role metadata, and records non-secret `delegated_metadata_apply` +evidence. Non-production task closure still needs a live build/test applier +identity plus positive and negative capability evidence. + +**2026-06-30:** Added `scripts/openbao-apply-credential-change-appliers.py` and +Make target `openbao-credential-change-appliers-dry-run` to install/dry-run the +non-production applier policy plus bounded `auth/token/roles/credential-change- +nonprod-applier` role. The token role allows only the matching applier policy, +disallows `root` and `platform-admin`, disables the default policy, and does not +issue tokens by itself. Live non-production apply and denial evidence remains +the closeout gate. + ## T04 - Add production metadata applier with human approval gate ```task id: RAILIANCE-WP-0008-T04 -status: todo +status: progress priority: high state_hub_task_id: "414abd65-22d3-420f-994d-f7fdd1302db5" ``` @@ -185,6 +226,29 @@ Acceptance: - Unapproved CCRs fail closed. - Secret value provisioning is still not automated in production. +**2026-06-30:** Strengthened the production gate by adding source-artifact +checks to the CCR applier dry-run and documenting that unapproved CCRs fail +closed before OpenBao mutation rendering. The production policy candidate exists +and remains metadata-only; live delegated identity creation/application evidence +still needs an operator-held OpenBao step. + +**2026-06-30:** Added `applier-apply` and Make targets +`credential-change-applier-apply-plan` / `credential-change-applier-apply`. The +command fails closed for unapproved CCRs, renders the dry-run payload before +mutation, requires exact confirmation, does not accept tokens in argv, leaves +secret values out of scope, and appends State Hub/file-backed non-secret apply +evidence when requested. Production closure still requires live execution using +the constrained applier identity rather than broad `platform-admin`. + +**2026-06-30:** Added `scripts/openbao-apply-credential-change-appliers.py` and +Make targets `openbao-credential-change-appliers-dry-run` / +`openbao-configure-credential-change-appliers` to configure the production +`credential-change-prod-applier` policy and bounded token role. The role allows +only `credential-change-prod-applier`, disallows `root` and `platform-admin`, +uses service tokens, disables default policy attachment, and keeps token issuance +outside the setup script. Production closure still needs a live run and +capability evidence using this constrained identity. + ## T05 - Close the whynot-design pilot ```task diff --git a/workplans/RAILIANCE-WP-0009-issue-core-runtime-ingestion-key-lane.md b/workplans/RAILIANCE-WP-0009-issue-core-runtime-ingestion-key-lane.md index 1410125..ae07d17 100644 --- a/workplans/RAILIANCE-WP-0009-issue-core-runtime-ingestion-key-lane.md +++ b/workplans/RAILIANCE-WP-0009-issue-core-runtime-ingestion-key-lane.md @@ -10,7 +10,7 @@ topic_slug: railiance planning_priority: high planning_order: 9 created: "2026-06-29" -updated: "2026-06-29" +updated: "2026-06-30" depends_on_workplans: - RAIL-PL-WP-0002 - RAILIANCE-WP-0004 @@ -80,10 +80,10 @@ The plan supports these `INTENT.md` principles: | Read policy | `workload-kv-read-issue-core-runtime` | | Policy file | `openbao/policies/workload-kv-read-issue-core-runtime.hcl` | | Auth method | Kubernetes auth | -| Auth role | `issue-core-runtime-workload-kv-read` | -| Proposed service account | `issue-core` | -| Proposed namespace | `issue-core` | -| Delivery surface | External Secrets into the `issue-core` namespace | +| Auth role | `external-secrets-issue-core` | +| OpenBao auth service account | `external-secrets` | +| OpenBao auth namespace | `external-secrets` | +| Delivery surface | `ExternalSecret issue-core/issue-core-runtime` to Secret `issue-core-runtime` | | ops-warden command | `warden access issue-core-ingestion-api-key --fetch ISSUE_CORE_API_KEY` | The `GITEA_BACKEND_TOKEN` field remains an explicit review point. Remove it @@ -95,7 +95,7 @@ from the CCR before approval if issue-core no longer needs it in this lane. ```task id: RAILIANCE-WP-0009-T01 -status: todo +status: done priority: high state_hub_task_id: "64d85288-38fb-4374-b889-fd0d136d3bdf" ``` @@ -114,11 +114,19 @@ Acceptance: - The lane remains clearly platform-owned secret custody, not issue-core application logic. +**2026-06-30:** Live cluster metadata confirms +`ExternalSecret issue-core/issue-core-runtime` is `Ready=True` with reason +`SecretSynced` and maps both `ISSUE_CORE_API_KEY` and `GITEA_BACKEND_TOKEN` from +`platform/workloads/issue-core/issue-core/issue-core-runtime`. Retain both +fields in `CCR-2026-0002` unless the issue-core owner later removes one through +review. The CCR remains `proposed`; this task records non-secret scope review, +not approval to apply. + ## T02 - Confirm Kubernetes auth and External Secrets binding ```task id: RAILIANCE-WP-0009-T02 -status: todo +status: done priority: high state_hub_task_id: "7f4a8317-13f0-4be3-948c-a2e2f90447cf" ``` @@ -134,6 +142,17 @@ Acceptance: - The External Secrets target and expected field names are documented. - No direct human or agent read path is activated unless separately approved. +**2026-06-30:** Confirmed the current delivery path uses the platform External +Secrets operator, not a workload pod service account. The `issue-core` +Deployment uses the `default` service account, and no `issue-core` service +account exists. `ClusterSecretStore/openbao` authenticates to OpenBao as +`external-secrets/external-secrets` with role `external-secrets-issue-core` and +is limited to the `issue-core` namespace. Updated `CCR-2026-0002` to this +confirmed auth subject while keeping the exact +`workload-kv-read-issue-core-runtime` policy. `credential-change.py +applier-dry-run CCR-2026-0002` now blocks only because the CCR is still +`proposed`. + ## T03 - Apply or confirm least-privilege OpenBao metadata ```task diff --git a/workplans/RAILIANCE-WP-0010-llm-connect-openrouter-provider-key-lane.md b/workplans/RAILIANCE-WP-0010-llm-connect-openrouter-provider-key-lane.md index 34e8c9d..2f32799 100644 --- a/workplans/RAILIANCE-WP-0010-llm-connect-openrouter-provider-key-lane.md +++ b/workplans/RAILIANCE-WP-0010-llm-connect-openrouter-provider-key-lane.md @@ -10,7 +10,7 @@ topic_slug: railiance planning_priority: high planning_order: 10 created: "2026-06-29" -updated: "2026-06-29" +updated: "2026-06-30" depends_on_workplans: - RAIL-PL-WP-0002 - RAILIANCE-WP-0004 @@ -84,10 +84,10 @@ The plan supports these `INTENT.md` principles: | Read policy | `workload-kv-read-llm-connect-provider-secrets` | | Policy file | `openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl` | | Auth method | Kubernetes auth | -| Auth role | `llm-connect-provider-secrets-read` | -| Proposed service account | `llm-connect` | -| Proposed namespace | `activity-core` | -| Delivery surface | External Secrets to `llm-connect-provider-secrets` in `activity-core` | +| Auth role | `external-secrets-activity-core` | +| OpenBao auth service account | `external-secrets` | +| OpenBao auth namespace | `external-secrets` | +| Delivery surface | Future activity-core ExternalSecret to Secret `llm-connect-provider-secrets` | | ops-warden command | `warden access llm-connect-openrouter-api-key --fetch OPENROUTER_API_KEY` | ## Tasks @@ -96,7 +96,7 @@ The plan supports these `INTENT.md` principles: ```task id: RAILIANCE-WP-0010-T01 -status: todo +status: progress priority: high state_hub_task_id: "307b75a6-a3a8-473b-b171-7379d2848698" ``` @@ -116,11 +116,19 @@ Acceptance: - The lane remains clearly platform-owned secret custody, not llm-connect model routing or provider selection logic. +**2026-06-30:** Confirmed `activity-core` namespace exists and Kubernetes Secret +`activity-core/llm-connect-provider-secrets` exists, but no activity-core +`ExternalSecret` exists yet. Kept canonical CCR catalog id +`llm-connect-openrouter-api-key`; ops-warden previously mentioned +`openrouter-llm-connect`, so selector agreement remains open and this task stays +`progress`. OpenBao public seal status now reports `sealed=false`; the prior +sealed message is no longer the active blocker. + ## T02 - Confirm Kubernetes auth and External Secrets binding ```task id: RAILIANCE-WP-0010-T02 -status: todo +status: done priority: high state_hub_task_id: "829192f5-4502-44e0-8020-656d74d5282a" ``` @@ -138,6 +146,19 @@ Acceptance: `llm-connect-provider-secrets` or updated with the approved alternative. - No direct human or agent read path is activated unless separately approved. +**2026-06-30:** Confirmed the proposed `llm-connect` service account does not +exist and the current `llm-connect` Deployment uses the namespace `default` +service account. Updated `CCR-2026-0003` to the approved platform ESO pattern: +OpenBao Kubernetes auth role `external-secrets-activity-core` bound to +`external-secrets/external-secrets`. Added +`argocd/platform-addons/openbao-secretstore/openbao-activity-core.clustersecretstore.yaml`, +limited to the `activity-core` namespace, and Make target +`openbao-configure-external-secrets-activity-core` for the matching OpenBao +role/policy apply. `kubectl kustomize argocd/platform-addons/openbao-secretstore` +renders both the existing issue-core store and the new activity-core store. +`credential-change.py applier-dry-run CCR-2026-0003` now blocks only because the +CCR is still `proposed`. + ## T03 - Apply or confirm least-privilege OpenBao metadata ```task