RPF-WP-0054-T05: issuer binding CCR-2026-0043 (net-kingdom-admins, aal2) and probe session
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

New CCR request type broker-issuer-binding: an OIDC role on netkingdom that
carries only the broker issuer policy (loopback callbacks, 15m, 8 uses, no
default policy); the apply refuses unless the live issuer policy matches
source. Founder chose net-kingdom-admins + aal2 on 2026-10-10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 469992@bnt-lap001
Assistant-Session: e4b8e420-df0a-441d-83ae-7f19130594d9
This commit is contained in:
codex 2026-10-10 10:18:58 +02:00
parent c8022c0798
commit b328d36a20
6 changed files with 434 additions and 6 deletions

View file

@ -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/<client_id> 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

View file

@ -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
}

View file

@ -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",
}

View file

@ -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))

View file

@ -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()

View file

@ -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-<date>`. 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 <<YAML
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: $P
type: oidc-browser-relying-party@1.0
status: materialized
parameters:
client_id: $P
client_type: confidential
YAML
REF="$P@sha256:$(sha256sum ~/$P-case.yaml | cut -d' ' -f1)"
# 3. log in as the bound operator role (token goes to the bao token helper)
bao login -no-print -method=oidc -path=netkingdom role=credential-broker-keycape-client-secret-generate
G="python3 scripts/credential.py generate --grant keycape/client-secret-generate --client-id $P --case-file $HOME/$P-case.yaml --case-ref $REF"
$G --dry-run --receipt ~/$P-dry.json
$G --receipt ~/$P-1.json # expect generated, kv_version 1
$G --receipt ~/$P-2.json # expect exit 3, already_present
bao token revoke -self
cat ~/$P-dry.json ~/$P-1.json ~/$P-2.json
# 4. remove the probe entry
warden access openbao-platform-admin-login --exec -- sh -c "bao delete platform/metadata/workloads/keycape/$P >/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/`.