Confirm whynot credential binding

This commit is contained in:
tegwick 2026-06-27 23:45:31 +02:00
parent aee0dcefad
commit 52687d8b3e
6 changed files with 115 additions and 41 deletions

View file

@ -2,30 +2,36 @@ id: CCR-2026-0001
kind: credential-change-request kind: credential-change-request
schema_version: 1 schema_version: 1
request_type: workload-kv-read request_type: workload-kv-read
title: "whynot-design npm publish token lane" title: whynot-design npm publish token lane
status: proposed status: proposed
created: "2026-06-27" created: '2026-06-27'
updated: "2026-06-27" updated: '2026-06-27'
requester: requester:
agent: ops-warden agent: ops-warden
message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
reason: "Allow ops-warden to proxy caller-scoped access to whynot-design's npm publish token." reason: Allow ops-warden to proxy caller-scoped access to whynot-design's npm publish
token.
review: review:
required: true required: true
required_approvers: required_approvers:
- platform-operator - platform-operator
comments: [] 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: target:
domain: financials domain: financials
tenant: whynot-design tenant: whynot-design
workload: whynot-design workload: whynot-design
environment: production 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: openbao:
mount: platform mount: platform
kv_path: platform/workloads/whynot-design/whynot-design/npm-publish kv_path: platform/workloads/whynot-design/whynot-design/npm-publish
fields: fields:
- NPM_AUTH_TOKEN - NPM_AUTH_TOKEN
policy_name: workload-kv-read-whynot-design-npm-publish policy_name: workload-kv-read-whynot-design-npm-publish
policy_file: openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl policy_file: openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl
auth: auth:
@ -36,42 +42,46 @@ openbao:
groups_claim: groups groups_claim: groups
bound_claims: bound_claims:
groups: groups:
- whynot-design - whynot-design
bound_claims_confirmed: false bound_claims_confirmed: true
policies: policies:
- workload-kv-read-whynot-design-npm-publish - workload-kv-read-whynot-design-npm-publish
ttl: 15m ttl: 15m
access_frontdoor: access_frontdoor:
type: ops-warden type: ops-warden
catalog_id: whynot-design-npm-publish catalog_id: whynot-design-npm-publish
selector: "npm publish token" selector: npm publish token
command: "warden access whynot-design-npm-publish --exec -- npm publish" command: warden access whynot-design-npm-publish --exec -- npm publish
resolvable: false resolvable: false
readiness: template readiness: template
activation: "draft-until-ccr-verified" activation: draft-until-ccr-verified
risk: risk:
classification: high classification: high
notes: notes:
- "Grants read access to the credential used to publish npm packages." - 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." - Uses a publish-specific catalog id; a future read-only npm token must use a separate
- "The proposed OIDC bound claim must be confirmed before apply." catalog id.
- "ops-warden must proxy the read as the caller and must not retain the token value." - 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: verification:
positive: 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: negative:
- "Non-whynot identity cannot read the path or field." - Non-whynot identity cannot read the path or field.
activation_conditions: activation_conditions:
- "Policy applied with platform-admin/operator authority." - Policy applied with platform-admin/operator authority.
- "OIDC role bound to confirmed whynot-design claim or approved service account." - OIDC role bound to confirmed whynot-design claim or approved service account.
- "Secret value provisioned directly in OpenBao through approved operator custody." - Secret value provisioned directly in OpenBao through approved operator custody.
- "Positive and negative verification recorded with non-secret audit ids or timestamps." - Positive and negative verification recorded with non-secret audit ids or timestamps.
lifecycle: lifecycle:
deactivate: "Disable ops-warden catalog entry and remove or detach auth role policy." 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." rotate: Replace NPM_AUTH_TOKEN value directly in OpenBao and record non-secret rotation
compromised: "Immediately deactivate access front door, rotate npm token, record blast-radius notes, and open incident follow-up tasks." evidence.
compromised: Immediately deactivate access front door, rotate npm token, record
blast-radius notes, and open incident follow-up tasks.
state_hub: state_hub:
workplan_id: RAILIANCE-WP-0007 workplan_id: RAILIANCE-WP-0007
related_workplan_id: RAILIANCE-WP-0006 related_workplan_id: RAILIANCE-WP-0006
ops_warden_reply_message_id: "b175c561-7858-43f5-a309-949b0dede1b4" ops_warden_reply_message_id: b175c561-7858-43f5-a309-949b0dede1b4
ops_warden_batch_message_id: "fe5b1696-8956-4bd5-9d6f-dbde1901a076" ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076

View file

@ -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-plan CREDENTIAL_CHANGE=CCR-2026-0001
make credential-change-status CREDENTIAL_CHANGE=CCR-2026-0001 make credential-change-status CREDENTIAL_CHANGE=CCR-2026-0001
make credential-change-status-json 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 <name> --comment "..."
scripts/credential-change.py approve CCR-2026-0001 --reviewer <name> --comment "..." scripts/credential-change.py approve CCR-2026-0001 --reviewer <name> --comment "..."
scripts/credential-change.py deny CCR-2026-0001 --reviewer <name> --comment "..." scripts/credential-change.py deny CCR-2026-0001 --reviewer <name> --comment "..."
scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer <name> --comment "..." scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer <name> --comment "..."

View file

@ -107,9 +107,10 @@ The role must attach only:
workload-kv-read-whynot-design-npm-publish workload-kv-read-whynot-design-npm-publish
``` ```
Before applying the role, confirm the KeyCape/NetKingdom claim that identifies The whynot-design pilot claim is confirmed as `groups=whynot-design`. Before
the whynot-design caller. The role must bind to that claim; do not create an applying any changed role, re-confirm the KeyCape/NetKingdom claim that
unbounded OIDC role that grants this policy to every OIDC user. 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 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 Kubernetes auth with the same role name and bind only the approved namespace

View file

@ -487,6 +487,29 @@ def append_decision(path: Path, status: str, reviewer: str, comment: str) -> Non
dump_yaml(path, ccr) 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: def command_validate(args: argparse.Namespace) -> int:
refs = args.refs or [str(path) for path in sorted(ccr_dir().glob("*.y*ml"))] refs = args.refs or [str(path) for path in sorted(ccr_dir().glob("*.y*ml"))]
if not refs: if not refs:
@ -554,6 +577,13 @@ def command_decision(args: argparse.Namespace, status: str) -> int:
return 0 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: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Validate, render, and review non-secret credential change requests." 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.add_argument("--comment", required=True)
decision.set_defaults(func=lambda args, status=status: command_decision(args, status)) 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 return parser

View file

@ -29,8 +29,14 @@ class CredentialChangeTests(unittest.TestCase):
/ "credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml" / "credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml"
) )
def test_sample_ccr_validates_with_bound_claim_warning(self) -> None: def test_sample_ccr_validates_without_bound_claim_warning(self) -> None:
_ccr, errors, warnings = credential_change.validate_ccr(self.sample) 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.assertEqual(errors, [])
self.assertIn("bound claim is not confirmed", warnings[0]) self.assertIn("bound claim is not confirmed", warnings[0])
@ -56,6 +62,8 @@ class CredentialChangeTests(unittest.TestCase):
self.assertFalse(payload["frontdoor_resolvable"]) self.assertFalse(payload["frontdoor_resolvable"])
self.assertEqual(payload["access_frontdoor"]["readiness"], "template") self.assertEqual(payload["access_frontdoor"]["readiness"], "template")
self.assertEqual(payload["access_frontdoor"]["catalog_id"], "whynot-design-npm-publish") 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"]) self.assertIn("front door is marked resolvable=false", payload["frontdoor_blockers"])
def test_kubernetes_auth_payload_uses_service_account_bounds(self) -> None: def test_kubernetes_auth_payload_uses_service_account_bounds(self) -> None:
@ -75,8 +83,8 @@ class CredentialChangeTests(unittest.TestCase):
tmp_path = Path(tmp) tmp_path = Path(tmp)
ccr_dir = tmp_path / "ccrs" ccr_dir = tmp_path / "ccrs"
ccr_dir.mkdir() ccr_dir.mkdir()
copied = ccr_dir / self.sample.name copied = ccr_dir / self.issue_core.name
shutil.copy2(self.sample, copied) shutil.copy2(self.issue_core, copied)
old_ccr_dir = os.environ.get("CCR_DIR") old_ccr_dir = os.environ.get("CCR_DIR")
os.environ["CCR_DIR"] = str(ccr_dir) os.environ["CCR_DIR"] = str(ccr_dir)
try: try:
@ -89,7 +97,7 @@ class CredentialChangeTests(unittest.TestCase):
self.assertEqual(ccr["review"]["comments"][-1]["comment"], "looks right") self.assertEqual(ccr["review"]["comments"][-1]["comment"], "looks right")
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
credential_change.command_apply_plan( credential_change.command_apply_plan(
type("Args", (), {"ref": "CCR-2026-0001"})() type("Args", (), {"ref": "CCR-2026-0002"})()
) )
finally: finally:
if old_ccr_dir is None: if old_ccr_dir is None:
@ -97,6 +105,19 @@ class CredentialChangeTests(unittest.TestCase):
else: else:
os.environ["CCR_DIR"] = old_ccr_dir 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: def test_generated_policy_is_narrow(self) -> None:
ccr, _errors, _warnings = credential_change.validate_ccr(self.sample) ccr, _errors, _warnings = credential_change.validate_ccr(self.sample)
policy = credential_change.generated_policy_hcl(ccr) policy = credential_change.generated_policy_hcl(ccr)

View file

@ -203,7 +203,8 @@ Acceptance:
**2026-06-27:** Added file-backed `approve`, `deny`, and `needs-changes` **2026-06-27:** Added file-backed `approve`, `deny`, and `needs-changes`
commands that require reviewer and comment text and append non-secret review 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 `credential-change-status` and `credential-change-status-json` so ops-warden can
consume `readiness`/`resolvable` without scraping prose. Remaining T05 work is consume `readiness`/`resolvable` without scraping prose. Remaining T05 work is
State Hub decision-event emission and tighter chat integration. State Hub decision-event emission and tighter chat integration.
@ -249,8 +250,9 @@ Acceptance:
- ops-warden activates its catalog entry only after CCR verification. - ops-warden activates its catalog entry only after CCR verification.
**2026-06-27:** The whynot-design lane is represented as `CCR-2026-0001` and **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 can be rendered for review. The whynot-design bound claim was confirmed from
bound claims, so live apply and ops-warden activation are correctly blocked. 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 **2026-06-27:** Converted the ops-warden batch follow-up
`fe5b1696-8956-4bd5-9d6f-dbde1901a076` into three proposed CCRs: `fe5b1696-8956-4bd5-9d6f-dbde1901a076` into three proposed CCRs: