diff --git a/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml b/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml index bf36234..9654ec8 100644 --- a/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml +++ b/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml @@ -2,30 +2,36 @@ id: CCR-2026-0001 kind: credential-change-request schema_version: 1 request_type: workload-kv-read -title: "whynot-design npm publish token lane" +title: whynot-design npm publish token lane status: proposed -created: "2026-06-27" -updated: "2026-06-27" +created: '2026-06-27' +updated: '2026-06-27' requester: agent: ops-warden - message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" - reason: "Allow ops-warden to proxy caller-scoped access to whynot-design's npm publish token." + message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076 + reason: Allow ops-warden to proxy caller-scoped access to whynot-design's npm publish + token. review: required: true required_approvers: - - platform-operator - comments: [] + - platform-operator + comments: + - at: '2026-06-27T21:40:22+00:00' + reviewer: bernd.worsch + decision: binding_confirmed + comment: 'Confirmed in chat: groups=whynot-design is the intended KeyCape/NetKingdom + binding for the whynot-design npm publish lane.' target: domain: financials tenant: whynot-design workload: whynot-design environment: production - purpose: "npm package publishing through ops-warden caller-scoped fetch/exec" + purpose: npm package publishing through ops-warden caller-scoped fetch/exec openbao: mount: platform kv_path: platform/workloads/whynot-design/whynot-design/npm-publish fields: - - NPM_AUTH_TOKEN + - NPM_AUTH_TOKEN policy_name: workload-kv-read-whynot-design-npm-publish policy_file: openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl auth: @@ -36,42 +42,46 @@ openbao: groups_claim: groups bound_claims: groups: - - whynot-design - bound_claims_confirmed: false + - whynot-design + bound_claims_confirmed: true policies: - - workload-kv-read-whynot-design-npm-publish + - workload-kv-read-whynot-design-npm-publish ttl: 15m access_frontdoor: type: ops-warden catalog_id: whynot-design-npm-publish - selector: "npm publish token" - command: "warden access whynot-design-npm-publish --exec -- npm publish" + selector: npm publish token + command: warden access whynot-design-npm-publish --exec -- npm publish resolvable: false readiness: template - activation: "draft-until-ccr-verified" + activation: draft-until-ccr-verified risk: classification: high notes: - - "Grants read access to the credential used to publish npm packages." - - "Uses a publish-specific catalog id; a future read-only npm token must use a separate catalog id." - - "The proposed OIDC bound claim must be confirmed before apply." - - "ops-warden must proxy the read as the caller and must not retain the token value." + - Grants read access to the credential used to publish npm packages. + - Uses a publish-specific catalog id; a future read-only npm token must use a separate + catalog id. + - The OIDC bound claim was confirmed in review; re-confirm if the claim changes. + - ops-warden must proxy the read as the caller and must not retain the token value. verification: positive: - - "Approved whynot-design identity can fetch field NPM_AUTH_TOKEN through OpenBao or ops-warden." + - Approved whynot-design identity can fetch field NPM_AUTH_TOKEN through OpenBao + or ops-warden. negative: - - "Non-whynot identity cannot read the path or field." + - Non-whynot identity cannot read the path or field. activation_conditions: - - "Policy applied with platform-admin/operator authority." - - "OIDC role bound to confirmed whynot-design claim or approved service account." - - "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. + - OIDC role bound to confirmed whynot-design claim or approved service account. + - 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 NPM_AUTH_TOKEN value directly in OpenBao and record non-secret rotation evidence." - compromised: "Immediately deactivate access front door, rotate npm token, 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 NPM_AUTH_TOKEN value directly in OpenBao and record non-secret rotation + evidence. + compromised: Immediately deactivate access front door, rotate npm token, record + blast-radius notes, and open incident follow-up tasks. state_hub: workplan_id: RAILIANCE-WP-0007 related_workplan_id: RAILIANCE-WP-0006 - ops_warden_reply_message_id: "b175c561-7858-43f5-a309-949b0dede1b4" - ops_warden_batch_message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" + ops_warden_reply_message_id: b175c561-7858-43f5-a309-949b0dede1b4 + ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076 diff --git a/docs/credential-change-approval.md b/docs/credential-change-approval.md index d95499c..4abc34f 100644 --- a/docs/credential-change-approval.md +++ b/docs/credential-change-approval.md @@ -149,6 +149,7 @@ make credential-change-render CREDENTIAL_CHANGE=CCR-2026-0001 make credential-change-plan CREDENTIAL_CHANGE=CCR-2026-0001 make credential-change-status CREDENTIAL_CHANGE=CCR-2026-0001 make credential-change-status-json CREDENTIAL_CHANGE=CCR-2026-0001 +scripts/credential-change.py confirm-binding CCR-2026-0001 --reviewer --comment "..." scripts/credential-change.py approve CCR-2026-0001 --reviewer --comment "..." scripts/credential-change.py deny CCR-2026-0001 --reviewer --comment "..." scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer --comment "..." diff --git a/docs/workload-kv-access-lanes.md b/docs/workload-kv-access-lanes.md index 136fd97..fd48166 100644 --- a/docs/workload-kv-access-lanes.md +++ b/docs/workload-kv-access-lanes.md @@ -107,9 +107,10 @@ The role must attach only: workload-kv-read-whynot-design-npm-publish ``` -Before applying the role, confirm the KeyCape/NetKingdom claim that identifies -the whynot-design caller. The role must bind to that claim; do not create an -unbounded OIDC role that grants this policy to every OIDC user. +The whynot-design pilot claim is confirmed as `groups=whynot-design`. Before +applying any changed role, re-confirm the KeyCape/NetKingdom claim that +identifies the whynot-design caller. The role must bind to that claim; do not +create an unbounded OIDC role that grants this policy to every OIDC user. If the consumer is an in-cluster service account instead of an OIDC caller, use Kubernetes auth with the same role name and bind only the approved namespace diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 30c0551..90bd4b9 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -487,6 +487,29 @@ def append_decision(path: Path, status: str, reviewer: str, comment: str) -> Non dump_yaml(path, ccr) +def confirm_binding(path: Path, reviewer: str, comment: str) -> None: + ccr, errors, _warnings = validate_ccr(path) + if errors: + for error in errors: + print(f"[FAIL] {path.name}: {error}", file=sys.stderr) + raise SystemExit(1) + ccr["openbao"]["auth"]["bound_claims_confirmed"] = True + review = ccr.setdefault("review", {}) + comments = review.setdefault("comments", []) + if not isinstance(comments, list): + fail("review.comments must be a list") + comments.append( + { + "at": utc_now(), + "reviewer": reviewer, + "decision": "binding_confirmed", + "comment": comment, + } + ) + ccr["updated"] = datetime.now(timezone.utc).date().isoformat() + dump_yaml(path, ccr) + + def command_validate(args: argparse.Namespace) -> int: refs = args.refs or [str(path) for path in sorted(ccr_dir().glob("*.y*ml"))] if not refs: @@ -554,6 +577,13 @@ def command_decision(args: argparse.Namespace, status: str) -> int: return 0 +def command_confirm_binding(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + confirm_binding(path, args.reviewer, args.comment) + print(f"[OK] {path.name} -> binding_confirmed") + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Validate, render, and review non-secret credential change requests." @@ -594,6 +624,15 @@ def build_parser() -> argparse.ArgumentParser: decision.add_argument("--comment", required=True) decision.set_defaults(func=lambda args, status=status: command_decision(args, status)) + binding = sub.add_parser( + "confirm-binding", + help="Record that the non-secret OpenBao auth binding was confirmed", + ) + binding.add_argument("ref") + binding.add_argument("--reviewer", required=True) + binding.add_argument("--comment", required=True) + binding.set_defaults(func=command_confirm_binding) + return parser diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index 16efcc3..da10295 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -29,8 +29,14 @@ class CredentialChangeTests(unittest.TestCase): / "credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml" ) - def test_sample_ccr_validates_with_bound_claim_warning(self) -> None: - _ccr, errors, warnings = credential_change.validate_ccr(self.sample) + def test_sample_ccr_validates_without_bound_claim_warning(self) -> None: + ccr, errors, warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + self.assertTrue(ccr["openbao"]["auth"]["bound_claims_confirmed"]) + + def test_unconfirmed_sibling_ccr_keeps_bound_claim_warning(self) -> None: + _ccr, errors, warnings = credential_change.validate_ccr(self.issue_core) self.assertEqual(errors, []) self.assertIn("bound claim is not confirmed", warnings[0]) @@ -56,6 +62,8 @@ class CredentialChangeTests(unittest.TestCase): self.assertFalse(payload["frontdoor_resolvable"]) self.assertEqual(payload["access_frontdoor"]["readiness"], "template") self.assertEqual(payload["access_frontdoor"]["catalog_id"], "whynot-design-npm-publish") + self.assertEqual(payload["apply_blockers"], ["apply requires status approved, got proposed"]) + self.assertEqual(payload["warnings"], []) self.assertIn("front door is marked resolvable=false", payload["frontdoor_blockers"]) def test_kubernetes_auth_payload_uses_service_account_bounds(self) -> None: @@ -75,8 +83,8 @@ class CredentialChangeTests(unittest.TestCase): tmp_path = Path(tmp) ccr_dir = tmp_path / "ccrs" ccr_dir.mkdir() - copied = ccr_dir / self.sample.name - shutil.copy2(self.sample, copied) + copied = ccr_dir / self.issue_core.name + shutil.copy2(self.issue_core, copied) old_ccr_dir = os.environ.get("CCR_DIR") os.environ["CCR_DIR"] = str(ccr_dir) try: @@ -89,7 +97,7 @@ class CredentialChangeTests(unittest.TestCase): self.assertEqual(ccr["review"]["comments"][-1]["comment"], "looks right") with self.assertRaises(SystemExit): credential_change.command_apply_plan( - type("Args", (), {"ref": "CCR-2026-0001"})() + type("Args", (), {"ref": "CCR-2026-0002"})() ) finally: if old_ccr_dir is None: @@ -97,6 +105,19 @@ class CredentialChangeTests(unittest.TestCase): else: os.environ["CCR_DIR"] = old_ccr_dir + def test_confirm_binding_records_comment_and_clears_warning(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + copied = Path(tmp) / self.issue_core.name + shutil.copy2(self.issue_core, copied) + credential_change.confirm_binding( + copied, "unit-test", "service account binding confirmed" + ) + ccr, errors, warnings = credential_change.validate_ccr(copied) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + self.assertTrue(ccr["openbao"]["auth"]["bound_claims_confirmed"]) + self.assertEqual(ccr["review"]["comments"][-1]["decision"], "binding_confirmed") + def test_generated_policy_is_narrow(self) -> None: ccr, _errors, _warnings = credential_change.validate_ccr(self.sample) policy = credential_change.generated_policy_hcl(ccr) diff --git a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md index 18b6543..c8e70a9 100644 --- a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md +++ b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md @@ -203,7 +203,8 @@ Acceptance: **2026-06-27:** Added file-backed `approve`, `deny`, and `needs-changes` commands that require reviewer and comment text and append non-secret review -comments to the CCR. Added `status` plus Make targets +comments to the CCR. Added `confirm-binding` for explicit non-secret auth +binding confirmation. Added `status` plus Make targets `credential-change-status` and `credential-change-status-json` so ops-warden can consume `readiness`/`resolvable` without scraping prose. Remaining T05 work is State Hub decision-event emission and tighter chat integration. @@ -249,8 +250,9 @@ Acceptance: - ops-warden activates its catalog entry only after CCR verification. **2026-06-27:** The whynot-design lane is represented as `CCR-2026-0001` and -can be rendered for review. It remains proposed/unapproved with unconfirmed -bound claims, so live apply and ops-warden activation are correctly blocked. +can be rendered for review. The whynot-design bound claim was confirmed from +operator chat context and recorded in the CCR, but it remains proposed/unapproved, +so live apply and ops-warden activation are correctly blocked. **2026-06-27:** Converted the ops-warden batch follow-up `fe5b1696-8956-4bd5-9d6f-dbde1901a076` into three proposed CCRs: