diff --git a/credential-change-requests/CCR-2026-0040-keycape-client-secret-generate-grant.yaml b/credential-change-requests/CCR-2026-0040-keycape-client-secret-generate-grant.yaml new file mode 100644 index 0000000..1d6b996 --- /dev/null +++ b/credential-change-requests/CCR-2026-0040-keycape-client-secret-generate-grant.yaml @@ -0,0 +1,75 @@ +id: CCR-2026-0040 +kind: credential-change-request +schema_version: 1 +request_type: custody-generate-grant +title: Broker grant keycape/client-secret-generate (create-only, generate in custody) +status: proposed +created: '2026-10-09' +updated: '2026-10-09' +requester: + agent: railiance-platform + reason: RPF-WP-0054-T04. net-kingdom SecurityCaseType oidc-browser-relying-party step + mint-secret (lane keycape-client-secret-generate); ops-warden d9f889b1, ops-mason + 789feb39. Founder chose the templated create-only grant 2026-10-09 (T02 option A). +review: + required: true + required_approvers: + - platform-owner + - key-cape-owner + comments: [] +target: + domain: infotech + tenant: platform + workload: keycape + environment: production + purpose: Let the credential broker create a new KeyCape confidential-client secret at + platform/workloads/keycape/ once, without any value leaving custody. +openbao: + grant_id: keycape/client-secret-generate + kv_prefix: platform/workloads/keycape + reserved_entries: + - secrets-engine-openbao + policy_name: keycape-client-secret-generate + policy_file: openbao/policies/keycape-client-secret-generate.hcl + issuer_policy: credential-broker-keycape-client-secret-generate-issuer + issuer_policy_file: openbao/policies/credential-broker-keycape-client-secret-generate-issuer.hcl + token_role: keycape-client-secret-generate + token_ttl: 5m +founder_confirmation_items: +- This apply writes two policies and one token role. It binds the issuer policy to no + identity, so nothing can mint the token until a separate review binds it to the + caller named in RPF-WP-0054-T01. +- Create-only on keycape/+ means a token can create a new client entry once and can + never change an existing one. Its widest misuse is an unused entry for a client + that does not exist. +access_frontdoor: + type: none + catalog_id: none +risk: + level: medium + notes: + - No data read, no update, no metadata write, no list, no delete. Exact denies on + reserved entries take precedence over the + glob. + - The token lives at most 5m and only inside the broker process. +verification: + positive: + - Readback after apply matches source for both policies and the token role (done by + applier-apply; prints nothing). + - Disposable probe (T05) creates probe-secret-generate- at version 1. + negative: + - A second create on the probe path, a data read, update, delete and any path outside + keycape/ are denied for the grant token (sys/capabilities-self). + - The reserved entry keycape/secrets-engine-openbao reports deny on data and metadata. + activation_conditions: + - Founder approval (two reviews). + - Issuer binding reviewed after RPF-WP-0054-T01; probe passed (T05). + evidence: [] +lifecycle: + deactivate: Delete auth/token/roles/keycape-client-secret-generate, then both policies; + generated entries stay in custody with their cases. + rotate: Not applicable; rotation is a separate lane (RPF-WP-0054-T07). + compromised: Delete the token role and revoke outstanding tokens by accessor; review + metadata for entries without an accepted case. +state_hub: + workplan: RPF-WP-0054 + task: RPF-WP-0054-T04 diff --git a/credential-grants/catalog.yaml b/credential-grants/catalog.yaml index 3add477..690f116 100644 --- a/credential-grants/catalog.yaml +++ b/credential-grants/catalog.yaml @@ -104,6 +104,82 @@ grants: audit: {openbao_audit_required: true, state_hub_metadata_allowed: true, record_secret_values: false} revocation: {required: true, by_accessor: true, on_exec_exit: true, on_denied_request: false} + - id: keycape/client-secret-generate + title: KeyCape confidential-client secret generation in custody + status: pilot + grant_class: approval-required + credential_type: openbao-token + issuer: openbao + audience: railiance-platform-credential-broker + description: > + RPF-WP-0054 / CCR-2026-0040. Short-lived token the broker uses inside its + own process to create platform/workloads/keycape/ once (CAS 0) + for an accepted oidc-browser-relying-party case. Create-only: it cannot + update, read values, write metadata, list or delete. The generated value + never leaves the broker process and the bao child's stdin; the caller gets a + receipt only. Founder decision 2026-10-09 (RPF-WP-0054-T02 option A). + openbao: + namespace: openbao + token_role: keycape-client-secret-generate + issuer_policy: credential-broker-keycape-client-secret-generate-issuer + policies: + - keycape-client-secret-generate + disallowed_policies: + - root + - platform-admin + mount_paths: + - platform/data/workloads/keycape/+ + - platform/metadata/workloads/keycape/+ + ttl: + default: 5m + max: 5m + renewable: false + requires_human_above: 5m + actors: + allowed_types: + - approved-agent + - human-operator + required_subject_binding: keycape-or-kubernetes-service-account + authorization: + flex_auth_required: false + flex_auth_mode: optional-preflight + approval_required: true + purpose_required: true + allowed_purpose_examples: + - oidc-browser-relying-party-mint-secret + delivery: + allowed: + - exec-env + preferred: exec-env + denied: + - chat + - state-hub-body + - git + - command-line-token-argument + - llm-prompt + exec_env: + variable: VAULT_TOKEN + child_only: true + redact_logs: true + audit: + openbao_audit_required: true + state_hub_metadata_allowed: true + record_secret_values: false + metadata_fields: + - grant_id + - actor + - subject + - purpose + - case_ref + - client_id + - kv_version + - lease_accessor + - status + revocation: + required: true + by_accessor: true + on_exec_exit: true + on_denied_request: false - id: ops-warden/warden-sign title: Ops Warden OpenBao SSH signing smoke token status: pilot diff --git a/openbao/policies/credential-broker-keycape-client-secret-generate-issuer.hcl b/openbao/policies/credential-broker-keycape-client-secret-generate-issuer.hcl new file mode 100644 index 0000000..a019a74 --- /dev/null +++ b/openbao/policies/credential-broker-keycape-client-secret-generate-issuer.hcl @@ -0,0 +1,17 @@ +# CCR-2026-0040 (RPF-WP-0054): the credential broker may mint keycape/client-secret-generate +# tokens only through this token role. Bind to the reviewed caller identity only. +path "auth/token/create/keycape-client-secret-generate" { + capabilities = ["create", "update"] +} +path "auth/token/lookup-accessor" { + capabilities = ["update"] +} +path "auth/token/revoke-accessor" { + capabilities = ["update"] +} +path "auth/token/lookup-self" { + capabilities = ["read"] +} +path "sys/capabilities-self" { + capabilities = ["update"] +} diff --git a/openbao/policies/keycape-client-secret-generate.hcl b/openbao/policies/keycape-client-secret-generate.hcl new file mode 100644 index 0000000..6dbb5be --- /dev/null +++ b/openbao/policies/keycape-client-secret-generate.hcl @@ -0,0 +1,17 @@ +# CCR-2026-0040 (RPF-WP-0054): grant keycape/client-secret-generate. Create-only: no update, +# data read, metadata write, list or delete. Exact deny beats the + glob. +path "platform/data/workloads/keycape/+" { + capabilities = ["create"] +} +path "platform/metadata/workloads/keycape/+" { + capabilities = ["read"] +} +path "platform/data/workloads/keycape/secrets-engine-openbao" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/keycape/secrets-engine-openbao" { + capabilities = ["deny"] +} +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/broker_grant_lane.py b/scripts/broker_grant_lane.py new file mode 100644 index 0000000..c603416 --- /dev/null +++ b/scripts/broker_grant_lane.py @@ -0,0 +1,268 @@ +"""custody-generate-grant CCRs: a create-only KV grant the credential broker mints. + +RPF-WP-0054-T04. Loaded by scripts/credential-change.py for request_type +custody-generate-grant. The lane writes three reviewed objects and nothing else: + +* the grant policy: ``create`` on ``/+`` data, ``read`` on ``/+`` + metadata, explicit ``deny`` on reserved entries, ``revoke-self``. It has no + ``update`` anywhere (an existing entry can never be overwritten), no data + read, no metadata write (custom_metadata writes would also let the token + change ``delete_version_after``/``max_versions`` of other entries), no list + and no delete; +* the broker issuer policy: create tokens only through this grant's token role; +* the token role: exactly the grant policy, orphan, non-renewable, no default + policy, at most 15 minutes. + +Binding the issuer policy to the calling identity is a separate review once the +caller is known (RPF-WP-0054-T01); until then nothing can mint the token, so +the apply is inert. Nothing here reads, prints or accepts a secret value. +""" +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path +from typing import Any + +import yaml + +REQUEST_TYPE = "custody-generate-grant" +PREFIX_RE = re.compile(r"^platform/workloads/[a-z0-9-]+$") +ENTRY_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +CATALOG = "credential-grants/catalog.yaml" +MAX_TTL_SECONDS = 15 * 60 +# Same list scripts/openbao-apply-token-grants.py writes; default is excluded by +# token_no_default_policy. +DISALLOWED_POLICIES = ["root", "platform-admin"] + + +def _service_jwt_lane(): + spec = importlib.util.spec_from_file_location( + "service_jwt_lane", Path(__file__).resolve().parent / "service_jwt_lane.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_shared = _service_jwt_lane() +ttl_seconds = _shared.ttl_seconds +normalized = _shared.normalized +BaoError = _shared.BaoError +_run = _shared._run +_read_json = _shared._read_json + + +def _paths(prefix: str) -> tuple[str, str]: + mount, rest = prefix.split("/", 1) + return f"{mount}/data/{rest}", f"{mount}/metadata/{rest}" + + +def generated_policy_hcl(ccr: dict[str, Any]) -> str: + openbao = ccr["openbao"] + data, meta = _paths(openbao["kv_prefix"]) + lines = [ + f"# {ccr['id']} (RPF-WP-0054): grant {openbao['grant_id']}. Create-only: no update,", + "# data read, metadata write, list or delete. Exact deny beats the + glob.", + f'path "{data}/+" {{', ' capabilities = ["create"]', "}", + f'path "{meta}/+" {{', ' capabilities = ["read"]', "}", + ] + for entry in openbao["reserved_entries"]: + for base in (data, meta): + lines += [f'path "{base}/{entry}" {{', ' capabilities = ["deny"]', "}"] + lines += ['path "auth/token/revoke-self" {', ' capabilities = ["update"]', "}"] + return "\n".join(lines) + "\n" + + +def generated_issuer_hcl(ccr: dict[str, Any]) -> str: + openbao = ccr["openbao"] + lines = [ + f"# {ccr['id']} (RPF-WP-0054): the credential broker may mint {openbao['grant_id']}", + "# tokens only through this token role. Bind to the reviewed caller identity only.", + ] + for path, caps in ( + (f"auth/token/create/{openbao['token_role']}", '"create", "update"'), + ("auth/token/lookup-accessor", '"update"'), + ("auth/token/revoke-accessor", '"update"'), + ("auth/token/lookup-self", '"read"'), + ("sys/capabilities-self", '"update"'), + ): + lines += [f'path "{path}" {{', f" capabilities = [{caps}]", "}"] + return "\n".join(lines) + "\n" + + +def role_payload(ccr: dict[str, Any]) -> dict[str, Any]: + openbao = ccr["openbao"] + return { + "allowed_policies": [openbao["policy_name"]], + "disallowed_policies": DISALLOWED_POLICIES, + "orphan": True, + "renewable": False, + "token_explicit_max_ttl": openbao["token_ttl"], + "token_no_default_policy": True, + "token_type": "service", + } + + +def _catalog_grant(repo_dir: Path, grant_id: str) -> dict[str, Any] | None: + catalog = yaml.safe_load((repo_dir / CATALOG).read_text(encoding="utf-8")) or {} + for grant in catalog.get("grants") or []: + if grant.get("id") == grant_id: + return grant + return None + + +def _check_file(repo_dir: Path, rel: str, expected: str, field: str, errors: list[str]) -> None: + path = repo_dir / rel + if not path.exists(): + errors.append(f"{field} does not exist: {rel}") + elif normalized(path.read_text(encoding="utf-8")) != normalized(expected): + errors.append(f"{field} does not match the generated policy") + + +def validate(ccr: dict[str, Any], repo_dir: Path) -> list[str]: + errors: list[str] = [] + openbao = ccr.get("openbao") + if not isinstance(openbao, dict): + return ["openbao must be an object"] + 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") + prefix = str(openbao.get("kv_prefix") or "") + if not PREFIX_RE.match(prefix): + errors.append("openbao.kv_prefix must be platform/workloads/") + reserved = openbao.get("reserved_entries") + if not isinstance(reserved, list) or not all(ENTRY_RE.match(str(e)) for e in reserved): + errors.append("openbao.reserved_entries must list exact entry names") + for key in ("policy_name", "issuer_policy", "token_role"): + if not NAME_RE.match(str(openbao.get(key) or "")): + errors.append(f"openbao.{key} contains unsafe characters") + seconds = ttl_seconds(openbao.get("token_ttl")) + if seconds is None or not 0 < seconds <= MAX_TTL_SECONDS: + errors.append("openbao.token_ttl must be set and at most 15m") + if errors: + return errors + + for key, file_key, expected in ( + ("policy_name", "policy_file", generated_policy_hcl(ccr)), + ("issuer_policy", "issuer_policy_file", generated_issuer_hcl(ccr)), + ): + rel = str(openbao.get(file_key) or "") + if rel != f"openbao/policies/{openbao[key]}.hcl": + errors.append(f"openbao.{file_key} must be openbao/policies/<{key}>.hcl") + else: + _check_file(repo_dir, rel, expected, f"openbao.{file_key}", errors) + + grant = _catalog_grant(repo_dir, str(openbao.get("grant_id") or "")) + if grant is None: + errors.append(f"openbao.grant_id not in {CATALOG}") + else: + gb = grant.get("openbao") or {} + if gb.get("policies") != [openbao["policy_name"]]: + errors.append("catalog grant policies must be exactly [openbao.policy_name]") + if gb.get("token_role") != openbao["token_role"] or gb.get("issuer_policy") != openbao["issuer_policy"]: + errors.append("catalog grant token_role/issuer_policy must match the CCR") + if ttl_seconds((grant.get("ttl") or {}).get("max")) != seconds: + errors.append("catalog grant ttl.max must equal openbao.token_ttl") + 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')}" + ) + 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"] + return { + "id": ccr["id"], + "title": ccr["title"], + "status": ccr["status"], + "environment": ccr["target"]["environment"], + "warnings": warnings, + "source_artifacts": {}, + "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": "policy_write", "openbao_path": f"sys/policies/acl/{openbao['issuer_policy']}", + "policy_name": openbao["issuer_policy"], "source": openbao["issuer_policy_file"], + "body": generated_issuer_hcl(ccr).rstrip()}, + {"kind": "token_role_write", "openbao_path": f"auth/token/roles/{openbao['token_role']}", + "payload": role_payload(ccr)}, + {"kind": "readback_compare", "openbao_path": "both policies, token role", + "payload": {"prints": "booleans only"}}, + ], + "out_of_scope": [ + "secret value reads or writes", + "binding the issuer policy to any identity (separate review after RPF-WP-0054-T01)", + "token creation", + "changes to any other policy or role", + ], + "required_evidence": [ + "CCR id and approval reference", + "readback equality for both policies and the token role", + "disposable probe (RPF-WP-0054-T05): create once, second create and data read denied", + ], + } + + +def apply_details(ccr: dict[str, Any], actor: str) -> list[str]: + openbao = ccr["openbao"] + 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"Issuer policy metadata write: sys/policies/acl/{openbao['issuer_policy']}", + f"Token role metadata write: auth/token/roles/{openbao['token_role']}", + "Readback matched source for both policies and the token role.", + "Issuer policy is not bound to any identity by this apply.", + "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]: + openbao = ccr["openbao"] + mismatches: list[str] = [] + for name, expected in ( + (openbao["policy_name"], generated_policy_hcl(ccr)), + (openbao["issuer_policy"], generated_issuer_hcl(ccr)), + ): + text = _run(bao_bin, ["policy", "read", name], repo_dir, quiet=True, capture=True) + if normalized(text) != normalized(expected): + mismatches.append(f"policy {name} differs from source") + role = _read_json(bao_bin, ["read", f"auth/token/roles/{openbao['token_role']}"], repo_dir).get("data", {}) + for key, want in role_payload(ccr).items(): + got = role.get(key) + if key == "token_explicit_max_ttl": + same = ttl_seconds(got) == ttl_seconds(want) + elif key == "disallowed_policies": + same = sorted(got or []) == sorted(want) + else: + same = got == want + if not same: + mismatches.append(f"token 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"] + _run(bao_bin, ["policy", "write", openbao["policy_name"], openbao["policy_file"]], repo_dir, quiet) + _run(bao_bin, ["policy", "write", openbao["issuer_policy"], openbao["issuer_policy_file"]], repo_dir, quiet) + role = role_payload(ccr) + args = ["write", f"auth/token/roles/{openbao['token_role']}"] + for key, value in role.items(): + rendered = ",".join(value) if isinstance(value, list) else str(value).lower() if isinstance(value, bool) else value + args.append(f"{key}={rendered}") + _run(bao_bin, args, repo_dir, quiet) + mismatches = readback_mismatches(ccr, repo_dir, bao_bin) + if mismatches: + raise BaoError("readback mismatch: " + "; ".join(mismatches)) diff --git a/scripts/credential-change.py b/scripts/credential-change.py index e129d15..5388684 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -823,6 +823,7 @@ def is_service_jwt(ccr: dict[str, Any]) -> bool: LANE_MODULES = { "service-jwt-login": "service_jwt_lane", "workload-kv-operator-matrix": "operator_matrix_lane", + "custody-generate-grant": "broker_grant_lane", } diff --git a/tests/test_broker_grant_lane.py b/tests/test_broker_grant_lane.py new file mode 100644 index 0000000..9a23357 --- /dev/null +++ b/tests/test_broker_grant_lane.py @@ -0,0 +1,137 @@ +"""RPF-WP-0054-T04: custody-generate-grant lane (CCR-2026-0040).""" +from __future__ import annotations + +import importlib.util +import json +import os +import re +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-0040-keycape-client-secret-generate-grant.yaml" + + +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("broker_grant_lane", "scripts/broker_grant_lane.py") +cc = load("credential_change_cli_grant", "scripts/credential-change.py") + + +def ccr() -> dict: + return yaml.safe_load(CCR_PATH.read_text()) + + +# Stateful fake: policies stored as text; token role key=value args stored the way +# OpenBao returns them (lists, booleans, seconds). +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"] +if args[:2] == ["policy", "write"]: + state["policies"][args[2]] = open(args[3]).read() +elif args[:2] == ["policy", "read"]: + print(state["policies"][args[2]]) +elif args[0] == "write": + role = {} + for kv in args[2:]: + k, v = kv.split("=", 1) + if k.endswith("_policies"): role[k] = v.split(",") + elif v in ("true", "false"): role[k] = v == "true" + elif k.endswith("_ttl"): role[k] = int(v[:-1]) * 60 + else: role[k] = v + if state.get("tamper"): role["renewable"] = True + state["roles"][args[1]] = role +elif args[0] == "read": + print(json.dumps({"data": state["roles"][args[1]]})) +json.dump(state, open(path, "w")) +''' + + +def stanzas(text: str) -> dict[str, str]: + return dict(re.findall(r'path "([^"]+)"\s*{\s*capabilities = \[([^\]]*)\]', text)) + + +class BrokerGrantLaneTests(unittest.TestCase): + def test_ccr_validates_and_waits_for_approval(self): + _ccr, errors, _w = cc.validate_ccr(CCR_PATH) + self.assertEqual(errors, []) + blockers = cc.applier_readiness_blockers(_ccr) + self.assertTrue(any("got proposed" in b for b in blockers), blockers) + + def test_grant_policy_is_create_only(self): + policy = stanzas((ROOT / ccr()["openbao"]["policy_file"]).read_text()) + self.assertEqual(policy["platform/data/workloads/keycape/+"], '"create"') + self.assertEqual(policy["platform/metadata/workloads/keycape/+"], '"read"') + for path in ("platform/data/workloads/keycape/secrets-engine-openbao", + "platform/metadata/workloads/keycape/secrets-engine-openbao"): + self.assertEqual(policy[path], '"deny"') + granted = " ".join(c for p, c in policy.items() if p != "auth/token/revoke-self") + for word in ("update", "delete", "list", "patch", "sudo"): + self.assertNotIn(f'"{word}"', granted) + self.assertFalse(any("*" in p for p in policy)) + + def test_issuer_policy_mints_only_through_this_role(self): + issuer = stanzas((ROOT / ccr()["openbao"]["issuer_policy_file"]).read_text()) + creates = [p for p in issuer if p.startswith("auth/token/create")] + self.assertEqual(creates, ["auth/token/create/keycape-client-secret-generate"]) + + def test_validator_rejects_drift(self): + cases = [ + ("long ttl", lambda c: c["openbao"].update(token_ttl="1h")), + ("prefix wildcard", lambda c: c["openbao"].update(kv_prefix="platform/workloads/*")), + ("unknown grant", lambda c: c["openbao"].update(grant_id="keycape/other")), + ("reserved glob", lambda c: c["openbao"].update(reserved_entries=["*"])), + ("policy drift", lambda c: c["openbao"].update(reserved_entries=[])), + ] + for label, mutate in cases: + with self.subTest(label): + c = ccr() + mutate(c) + self.assertNotEqual(lane.validate(c, ROOT), [], label) + + def _apply(self, tamper=False): + tmp = Path(tempfile.mkdtemp()) + (tmp / "state.json").write_text(json.dumps({"policies": {}, "roles": {}, "tamper": tamper})) + 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 test_apply_writes_three_objects_and_reads_back(self): + error, state = self._apply() + self.assertIsNone(error) + self.assertEqual(sorted(state["policies"]), [ + "credential-broker-keycape-client-secret-generate-issuer", + "keycape-client-secret-generate", + ]) + role = state["roles"]["auth/token/roles/keycape-client-secret-generate"] + self.assertEqual(role["allowed_policies"], ["keycape-client-secret-generate"]) + self.assertEqual(role["token_explicit_max_ttl"], 300) + + def test_readback_detects_role_drift(self): + error, _state = self._apply(tamper=True) + self.assertIsNotNone(error) + self.assertIn("renewable", str(error)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_operator_matrix_lane.py b/tests/test_operator_matrix_lane.py index f68b1a2..63f5423 100644 --- a/tests/test_operator_matrix_lane.py +++ b/tests/test_operator_matrix_lane.py @@ -60,12 +60,19 @@ LIVE_BOUNDARY = 'path "platform/data/workloads/forgejo/forgejo-admin" {\n capab class OperatorMatrixLaneTests(unittest.TestCase): - def test_ccr_validates_and_waits_for_approval_and_binding(self): + def test_ccr_validates_and_records_the_delegated_apply(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) - self.assertTrue(any("got proposed" in b for b in blockers)) + self.assertEqual(_ccr["status"], "applied") + self.assertEqual(cc.applier_readiness_blockers(_ccr), []) + self.assertTrue(any(e["kind"] == "delegated_metadata_apply" and e["result"] == "passed" + for e in _ccr["verification"]["evidence"])) + + def test_unconfirmed_binding_blocks_the_applier(self): + c = ccr() + c["openbao"]["auth"]["bound_claims_confirmed"] = False + self.assertIn("applier requires confirmed OpenBao auth binding", + cc.applier_readiness_blockers(c)) def test_policy_is_the_exact_matrix_and_nothing_else(self): policy = (ROOT / ccr()["openbao"]["policy_file"]).read_text()