diff --git a/credential-change-requests/CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml b/credential-change-requests/CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml new file mode 100644 index 0000000..c3d624a --- /dev/null +++ b/credential-change-requests/CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml @@ -0,0 +1,72 @@ +id: CCR-2026-0043 +kind: credential-change-request +schema_version: 1 +request_type: broker-issuer-binding +title: Operator OIDC role for the keycape-client-secret-generate broker issuer (net-kingdom-admins, aal2) +status: proposed +created: '2026-10-10' +updated: '2026-10-10' +requester: + agent: railiance-platform + reason: RPF-WP-0054-T05. ops-warden (23985370) names the caller as the human operator who + runs warden case run, using their own OIDC session. Founder chose net-kingdom-admins + aal2 + on 2026-10-10. The issuer policy is live from CCR-2026-0040. +review: + required: true + required_approvers: + - platform-owner + - key-cape-owner + comments: [] +target: + domain: infotech + tenant: platform + workload: credential-broker + environment: production + purpose: Let a net-kingdom-admins member with a verified second factor log in for 15 minutes + with only the broker issuer policy, so credential.py generate can mint the create-only + keycape/client-secret-generate token. +openbao: + issuer_policy: credential-broker-keycape-client-secret-generate-issuer + auth: + method: oidc + mount: netkingdom + role: credential-broker-keycape-client-secret-generate + role_file: openbao/auth/credential-broker-keycape-client-secret-generate-oidc-role.json + bound_claims_confirmed: false +founder_confirmation_items: +- bound_claims_confirmed stays false until the founder confirms groups=[net-kingdom-admins] + and /assurance/level=aal2, the same binding as CCR-2026-0034 (key-cape b2ed890f). +- The role carries only the issuer policy. It cannot read or write KV; it can mint + keycape-client-secret-generate tokens through that token role, and look up and + revoke tokens by accessor. +- Loopback CLI callbacks only (no UI callback); 15m, 8 uses, no default policy. +access_frontdoor: + type: none + catalog_id: none +risk: + level: medium + notes: + - Membership of net-kingdom-admins is the access decision; that group already holds the + KeyCape operator reader (CCR-2026-0034). + - Worst case within 15m and 8 uses is a few unused keycape/ entries; existing + entries cannot be overwritten (CCR-2026-0040 is create-only). +verification: + positive: + - Readback after apply matches source for the role (done by applier-apply; prints nothing). + - A member with aal2 logs in with role credential-broker-keycape-client-secret-generate and + the token's policies are exactly [credential-broker-keycape-client-secret-generate-issuer]. + negative: + - A non-member or a member without aal2 cannot obtain the role. + - The issuer token is denied on platform/* and on token creation through any other role. + activation_conditions: + - Founder approval (two reviews) and bound_claims_confirmed true. + - CCR-2026-0040 applied (done 2026-10-09). + evidence: [] +lifecycle: + deactivate: Delete auth/netkingdom/role/credential-broker-keycape-client-secret-generate. + rotate: Not applicable (no stored credential). + compromised: Delete the role, revoke issued tokens; review keycape/ metadata for entries + without an accepted case. +state_hub: + workplan: RPF-WP-0054 + task: RPF-WP-0054-T05 diff --git a/openbao/auth/credential-broker-keycape-client-secret-generate-oidc-role.json b/openbao/auth/credential-broker-keycape-client-secret-generate-oidc-role.json new file mode 100644 index 0000000..2b701de --- /dev/null +++ b/openbao/auth/credential-broker-keycape-client-secret-generate-oidc-role.json @@ -0,0 +1,23 @@ +{ + "role_type": "oidc", + "allowed_redirect_uris": [ + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback" + ], + "oidc_scopes": ["openid", "profile", "email", "groups"], + "user_claim": "sub", + "groups_claim": "groups", + "bound_claims_type": "string", + "bound_claims": { + "groups": ["net-kingdom-admins"], + "/assurance/level": "aal2" + }, + "token_policies": ["credential-broker-keycape-client-secret-generate-issuer"], + "token_no_default_policy": true, + "token_type": "service", + "token_ttl": "15m", + "token_max_ttl": "15m", + "token_explicit_max_ttl": "15m", + "token_num_uses": 8, + "verbose_oidc_logging": false +} diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 3eaca25..e12b41f 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -824,6 +824,7 @@ LANE_MODULES = { "service-jwt-login": "service_jwt_lane", "workload-kv-operator-matrix": "operator_matrix_lane", "custody-generate-grant": "broker_grant_lane", + "broker-issuer-binding": "issuer_binding_lane", } diff --git a/scripts/issuer_binding_lane.py b/scripts/issuer_binding_lane.py new file mode 100644 index 0000000..e3ce50b --- /dev/null +++ b/scripts/issuer_binding_lane.py @@ -0,0 +1,165 @@ +"""broker-issuer-binding CCRs: an attended OIDC role that carries only a broker issuer policy. + +RPF-WP-0054-T05. Loaded by scripts/credential-change.py for request_type +broker-issuer-binding. The role lets a human operator (group + aal2 on the +existing netkingdom OIDC mount) hold exactly one issuer policy, so their own +session can mint the grant token that ``credential.py generate`` uses. The role +rules (callbacks, scopes, claims, TTLs, no default policy) are the operator-matrix +lane's; the issuer policy must already be live from its own CCR. Nothing here +reads, prints or accepts a secret value; the mount is never enabled here. +""" +from __future__ import annotations + +import importlib.util +import json +import re +import tempfile +from pathlib import Path +from typing import Any + +REQUEST_TYPE = "broker-issuer-binding" +ISSUER_POLICY_RE = re.compile(r"^credential-broker-[a-z0-9-]+-issuer$") +ROLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +def _load(name: str): + spec = importlib.util.spec_from_file_location(name, Path(__file__).resolve().parent / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_matrix = _load("operator_matrix_lane") +BaoError = _matrix.BaoError +normalized = _matrix.normalized +_run = _matrix._run +_read_json = _matrix._read_json +ALLOWED_AUTH_MOUNTS = _matrix.ALLOWED_AUTH_MOUNTS +DURATION_KEYS = _matrix.DURATION_KEYS + + +def role_payload(ccr: dict[str, Any], repo_dir: Path) -> dict[str, Any]: + return _matrix.role_payload(ccr, repo_dir) + + +def validate(ccr: dict[str, Any], repo_dir: Path) -> list[str]: + errors: list[str] = [] + openbao = ccr.get("openbao") + if not isinstance(openbao, dict) or not isinstance(openbao.get("auth"), dict): + return ["openbao.auth must be an object"] + auth = openbao["auth"] + if (ccr.get("target") or {}).get("environment") != "production": + errors.append("target.environment must be production") + review = ccr.get("review") or {} + if review.get("required") is not True or not review.get("required_approvers"): + errors.append("review.required must be true with required_approvers") + issuer = str(openbao.get("issuer_policy") or "") + if not ISSUER_POLICY_RE.match(issuer): + errors.append("openbao.issuer_policy must be credential-broker-<...>-issuer") + elif not (repo_dir / f"openbao/policies/{issuer}.hcl").exists(): + errors.append(f"openbao.issuer_policy has no source file: openbao/policies/{issuer}.hcl") + if auth.get("method") != "oidc": + errors.append("openbao.auth.method must be oidc") + if auth.get("mount") not in ALLOWED_AUTH_MOUNTS: + errors.append(f"openbao.auth.mount must be one of {sorted(ALLOWED_AUTH_MOUNTS)}") + if not ROLE_RE.match(str(auth.get("role") or "")): + errors.append("openbao.auth.role contains unsafe characters") + if not isinstance(auth.get("bound_claims_confirmed"), bool): + errors.append("openbao.auth.bound_claims_confirmed must be a boolean") + role = _matrix._shared._load_json(repo_dir, str(auth.get("role_file") or ""), "openbao.auth.role_file", errors) + if role: + _matrix._validate_role(role, issuer, errors) + return errors + + +def readiness_blockers(ccr: dict[str, Any], repo_dir: Path, allowed_statuses: set[str]) -> list[str]: + blockers: list[str] = [] + if ccr.get("status") not in allowed_statuses: + blockers.append( + "applier requires approved, applied, verified, or active CCR status, " + f"got {ccr.get('status')}" + ) + if ccr["openbao"]["auth"].get("bound_claims_confirmed") is not True: + blockers.append("applier requires confirmed OpenBao auth binding") + blockers.extend(validate(ccr, repo_dir)) + return blockers + + +def dry_run_payload(ccr: dict[str, Any], repo_dir: Path, warnings: list[str]) -> dict[str, Any]: + openbao = ccr["openbao"] + auth = openbao["auth"] + return { + "id": ccr["id"], + "title": ccr["title"], + "status": ccr["status"], + "environment": ccr["target"]["environment"], + "warnings": warnings, + "source_artifacts": {}, + "mutations": [ + {"kind": "issuer_policy_present", "openbao_path": f"sys/policies/acl/{openbao['issuer_policy']}", + "payload": {"live_text_must_match": f"openbao/policies/{openbao['issuer_policy']}.hcl"}}, + {"kind": "auth_role_write", "openbao_path": f"auth/{auth['mount']}/role/{auth['role']}", + "payload": role_payload(ccr, repo_dir), + "precondition": f"auth/{auth['mount']} exists with type oidc (never enabled here)"}, + {"kind": "readback_compare", "openbao_path": "role", "payload": {"prints": "booleans only"}}, + ], + "out_of_scope": [ + "policy writes (the issuer policy belongs to its grant CCR)", + "secret value reads or writes", + "token creation or login", + ], + "required_evidence": [ + "CCR id and approval reference", + "readback equality for the role", + "attended login with this role, then the RPF-WP-0054-T05 probe", + ], + } + + +def 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"Issuer policy confirmed live and matching source: sys/policies/acl/{openbao['issuer_policy']}", + f"OIDC role metadata write: auth/{auth['mount']}/role/{auth['role']}", + "Readback matched source for the role.", + "No secret values were read, written, printed, or accepted in argv.", + ] + + +def readback_mismatches(ccr: dict[str, Any], repo_dir: Path, bao_bin: str) -> list[str]: + auth = ccr["openbao"]["auth"] + mismatches: list[str] = [] + role = _read_json(bao_bin, ["read", f"auth/{auth['mount']}/role/{auth['role']}"], repo_dir).get("data", {}) + for key, want in role_payload(ccr, repo_dir).items(): + got = role.get(key) + if key in DURATION_KEYS: + same = _matrix.ttl_seconds(got) == _matrix.ttl_seconds(want) + elif key in ("allowed_redirect_uris", "oidc_scopes"): + same = sorted(got or []) == sorted(want) + else: + same = got == want + if not same: + mismatches.append(f"role {key} differs") + return mismatches + + +def run_apply(ccr: dict[str, Any], repo_dir: Path, bao_bin: str, quiet: bool = False) -> None: + openbao = ccr["openbao"] + auth = openbao["auth"] + mounts = _read_json(bao_bin, ["auth", "list"], repo_dir) + mounts = mounts.get("data", mounts) + if (mounts.get(f"{auth['mount']}/") or {}).get("type") != "oidc": + raise BaoError(f"auth mount {auth['mount']}/ is absent or not oidc; this lane never enables it") + live = _run(bao_bin, ["policy", "read", openbao["issuer_policy"]], repo_dir, quiet=True, capture=True) + source = (repo_dir / f"openbao/policies/{openbao['issuer_policy']}.hcl").read_text(encoding="utf-8") + if normalized(live) != normalized(source): + raise BaoError("issuer policy is absent or differs from source; apply its grant CCR first") + with tempfile.TemporaryDirectory() as tmp: + role_path = Path(tmp) / "role.json" + role_path.write_text(json.dumps(role_payload(ccr, repo_dir), sort_keys=True) + "\n") + _run(bao_bin, ["write", f"auth/{auth['mount']}/role/{auth['role']}", f"@{role_path}"], repo_dir, quiet) + mismatches = readback_mismatches(ccr, repo_dir, bao_bin) + if mismatches: + raise BaoError("readback mismatch: " + "; ".join(mismatches)) diff --git a/tests/test_issuer_binding_lane.py b/tests/test_issuer_binding_lane.py new file mode 100644 index 0000000..997a132 --- /dev/null +++ b/tests/test_issuer_binding_lane.py @@ -0,0 +1,127 @@ +"""RPF-WP-0054-T05: broker-issuer-binding lane (CCR-2026-0043).""" +from __future__ import annotations + +import importlib.util +import json +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CCR_PATH = ROOT / "credential-change-requests/CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml" +ISSUER = "credential-broker-keycape-client-secret-generate-issuer" + + +def load(name: str, rel: str): + spec = importlib.util.spec_from_file_location(name, ROOT / rel) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +lane = load("issuer_binding_lane", "scripts/issuer_binding_lane.py") +cc = load("credential_change_cli_issuer", "scripts/credential-change.py") + + +def ccr() -> dict: + return yaml.safe_load(CCR_PATH.read_text()) + + +FAKE_BAO = r'''#!/usr/bin/env python3 +import json, os, sys +path = os.environ["FAKE_BAO_STATE"] +state = json.load(open(path)) +args = [a for a in sys.argv[1:] if a != "-format=json"] +state.setdefault("calls", []).append(args) +if args[:2] == ["auth", "list"]: + print(json.dumps({k: {"type": v} for k, v in state["mounts"].items()})) +elif args[:2] == ["policy", "read"]: + if args[2] not in state["policies"]: + json.dump(state, open(path, "w")); sys.exit(2) + print(state["policies"][args[2]]) +elif args[0] == "write": + state["roles"][args[1]] = json.load(open(args[2][1:])) +elif args[0] == "read": + print(json.dumps({"data": state["roles"][args[1]]})) +json.dump(state, open(path, "w")) +''' + + +class IssuerBindingLaneTests(unittest.TestCase): + def test_ccr_validates_and_waits_for_approval_and_binding(self): + _ccr, errors, _w = cc.validate_ccr(CCR_PATH) + self.assertEqual(errors, []) + blockers = cc.applier_readiness_blockers(_ccr) + self.assertIn("applier requires confirmed OpenBao auth binding", blockers) + + def test_role_carries_only_the_issuer_policy(self): + role = json.loads((ROOT / ccr()["openbao"]["auth"]["role_file"]).read_text()) + self.assertEqual(role["token_policies"], [ISSUER]) + self.assertEqual(role["bound_claims"], {"groups": ["net-kingdom-admins"], "/assurance/level": "aal2"}) + self.assertTrue(role["token_no_default_policy"]) + + def test_validator_rejects_widening(self): + good = json.loads((ROOT / ccr()["openbao"]["auth"]["role_file"]).read_text()) + cases = [ + ("other policy", {"token_policies": [ISSUER, "platform-admin"]}), + ("no aal2", {"bound_claims": {"groups": ["net-kingdom-admins"]}}), + ("public callback", {"allowed_redirect_uris": ["https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback"]}), + ("long ttl", {"token_ttl": "1h"}), + ] + for label, change in cases: + with self.subTest(label): + with tempfile.TemporaryDirectory(dir=ROOT / "openbao/auth") as tmp: + role_file = Path(tmp) / "role.json" + role_file.write_text(json.dumps({**good, **change})) + c = ccr() + c["openbao"]["auth"]["role_file"] = str(role_file.relative_to(ROOT)) + self.assertNotEqual(lane.validate(c, ROOT), [], label) + c = ccr() + c["openbao"]["issuer_policy"] = "platform-admin" + self.assertNotEqual(lane.validate(c, ROOT), []) + + def _apply(self, policies, mounts=None): + tmp = Path(tempfile.mkdtemp()) + state = {"mounts": mounts if mounts is not None else {"netkingdom/": "oidc"}, + "policies": policies, "roles": {}} + (tmp / "state.json").write_text(json.dumps(state)) + bao = tmp / "bao" + bao.write_text(FAKE_BAO) + bao.chmod(bao.stat().st_mode | stat.S_IEXEC) + os.environ["FAKE_BAO_STATE"] = str(tmp / "state.json") + error = None + try: + lane.run_apply(ccr(), ROOT, str(bao), quiet=True) + except lane.BaoError as exc: + error = exc + return error, json.loads((tmp / "state.json").read_text()) + + def live_issuer(self): + return {ISSUER: (ROOT / f"openbao/policies/{ISSUER}.hcl").read_text()} + + def test_apply_writes_role_and_reads_back(self): + error, state = self._apply(self.live_issuer()) + self.assertIsNone(error) + self.assertIn("auth/netkingdom/role/credential-broker-keycape-client-secret-generate", state["roles"]) + + def test_apply_refuses_missing_or_drifted_issuer_policy_before_writing(self): + for policies in ({}, {ISSUER: 'path "platform/*" { capabilities = ["read"] }'}): + with self.subTest(policies=list(policies)): + error, state = self._apply(policies) + self.assertIsNotNone(error) + self.assertEqual(state["roles"], {}) + + def test_apply_refuses_non_oidc_mount(self): + error, state = self._apply(self.live_issuer(), {"netkingdom/": "jwt"}) + self.assertIsNotNone(error) + self.assertEqual(state["roles"], {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md b/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md index 3ea022d..080e2bb 100644 --- a/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md +++ b/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md @@ -211,12 +211,52 @@ State Hub evidence step (KeyError on `openbao.auth` for a prefix lane), so warde revoked the session. That was fixed and the hub event was posted afterwards. The OpenBao objects were complete before the crash. -After T01 and T03 (separate session): review and apply the issuer binding for -the named caller, then run `generate` once for a disposable client_id -`probe-secret-generate-`. Verify by metadata only that it is version 1. -Confirm that a second run refuses, and that the grant token is denied a data -read, `update`, `delete` and every path outside `keycape/`. Metadata-delete the -probe. +2026-10-10: the founder chose `net-kingdom-admins` + `aal2` for the caller. +CCR-2026-0043 (request type `broker-issuer-binding`, +`scripts/issuer_binding_lane.py`) adds OIDC role +`credential-broker-keycape-client-secret-generate` on `auth/netkingdom`. It +carries only the issuer policy and has loopback callbacks, a 15m TTL, 8 uses and +no default policy. The apply refuses unless the live issuer policy matches source. + +Founder session (probe uses a synthetic, founder-written case file; the real +Forgejo run waits for key-cape's format confirmation): + +```bash +cd ~/railiance-platform +export BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 +# 1. binding +python3 scripts/credential-change.py approve CCR-2026-0043 --reviewer "platform-owner, key-cape-owner (founder Bernd Worsch)" --comment "Approve issuer binding for keycape-client-secret-generate: net-kingdom-admins + aal2 (RPF-WP-0054)." \ + && python3 scripts/credential-change.py confirm-binding CCR-2026-0043 --reviewer founder --comment "groups=[net-kingdom-admins] and /assurance/level=aal2, same binding as CCR-2026-0034." \ + && warden access openbao-platform-admin-login --exec -- python3 scripts/credential-change.py applier-apply CCR-2026-0043 --confirm "DELEGATED APPLY CCR-2026-0043" --actor founder --record-state-hub --quiet +# 2. probe case (synthetic, materialized, never registered in KeyCape) +P=probe-secret-generate-20261010 +cat > ~/$P-case.yaml </dev/null 2>&1" +``` + +The policy denials (data read, update, delete, other paths) were already proven +by the CCR-2026-0040 readback and its policy tests. The probe proves the live +create-once behaviour. Record the receipts under `docs/evidence/`.