"""service-jwt-login CCRs: an isolated JWT auth mount, one role, one login-only policy. RPF-WP-0035-T02. Loaded by scripts/credential-change.py for request_type service-jwt-login. Deliberately narrow: the lane is an identity login, never a lane applier, so the policy may hold only the three self endpoints plus explicit denies on guarded paths (ops-mason MASON-WP-0009 invariant (ii)). Everything written to OpenBao comes from reviewed repo files; nothing here reads, prints or accepts a secret value. """ from __future__ import annotations import json import re import subprocess import tempfile from pathlib import Path from typing import Any REQUEST_TYPE = "service-jwt-login" ALLOWED_AUTH_MOUNTS = {"keycape-services"} SELF_GRANTS = ( ("auth/token/lookup-self", "read"), ("sys/capabilities-self", "update"), ("auth/token/revoke-self", "update"), ) POLICY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*-login-self$") ROLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") TTL_RE = re.compile(r"^([1-9][0-9]*)([sm])$") MAX_TTL_SECONDS = 15 * 60 MAX_TOKEN_USES = 16 CONFIG_KEYS = {"jwks_url", "bound_issuer", "jwt_supported_algs"} ROLE_KEYS = { "role_type", "user_claim", "bound_audiences", "bound_subject", "bound_claims_type", "bound_claims", "token_policies", "token_no_default_policy", "token_type", "token_ttl", "token_max_ttl", "token_explicit_max_ttl", "token_num_uses", "verbose_oidc_logging", } DURATION_KEYS = ("token_ttl", "token_max_ttl", "token_explicit_max_ttl") DISALLOWED_POLICIES = {"root", "platform-admin", "default"} def ttl_seconds(value: Any) -> int | None: if isinstance(value, int) and not isinstance(value, bool): return value match = TTL_RE.match(str(value)) if not match: return None return int(match.group(1)) * (60 if match.group(2) == "m" else 1) def generated_policy_hcl(ccr: dict[str, Any]) -> str: openbao = ccr["openbao"] lines = [f"# {ccr['id']} (RPF-WP-0035-T02): login-only service policy; not a lane applier."] for path, capability in SELF_GRANTS: lines += [f'path "{path}" {{', f' capabilities = ["{capability}"]', "}"] for path in openbao.get("guarded_paths") or []: lines += [f'path "{path}" {{', ' capabilities = ["deny"]', "}"] return "\n".join(lines) + "\n" def normalized(text: str) -> str: return "\n".join( line.rstrip() for line in text.strip().splitlines() if not line.lstrip().startswith("#") ) def _load_json(repo_dir: Path, rel: str, field: str, errors: list[str]) -> dict[str, Any]: path = (repo_dir / rel).resolve() try: path.relative_to((repo_dir / "openbao" / "auth").resolve()) except ValueError: errors.append(f"{field} must stay under openbao/auth") return {} if not path.exists(): errors.append(f"{field} does not exist: {rel}") return {} data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): errors.append(f"{field} must be a JSON object") return {} return data def role_payload(ccr: dict[str, Any], repo_dir: Path) -> dict[str, Any]: return _load_json(repo_dir, ccr["openbao"]["auth"]["role_file"], "openbao.auth.role_file", []) def config_payload(ccr: dict[str, Any], repo_dir: Path) -> dict[str, Any]: return _load_json(repo_dir, ccr["openbao"]["auth"]["config_file"], "openbao.auth.config_file", []) def validate(ccr: dict[str, Any], repo_dir: Path) -> list[str]: """Schema plus every boundary the delegated applier relies on.""" errors: list[str] = [] openbao = ccr.get("openbao") if not isinstance(openbao, dict): return ["openbao must be an object"] auth = openbao.get("auth") if not isinstance(auth, dict): return ["openbao.auth must be an object"] target = ccr.get("target") or {} if target.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") policy_name = str(openbao.get("policy_name") or "") if not POLICY_NAME_RE.match(policy_name) or policy_name in DISALLOWED_POLICIES: errors.append("openbao.policy_name must be a *-login-self policy") policy_file = str(openbao.get("policy_file") or "") if policy_file != f"openbao/policies/{policy_name}.hcl": errors.append("openbao.policy_file must be openbao/policies/.hcl") guarded = openbao.get("guarded_paths") if not isinstance(guarded, list) or not guarded: errors.append("openbao.guarded_paths must list the paths this identity must never reach") guarded = [] for path in guarded: if not isinstance(path, str) or any(f in path for f in ("*", "+", "..", "//")) or not path.strip(): errors.append(f"openbao.guarded_paths entry must be an exact path: {path!r}") policy_path = repo_dir / policy_file if policy_file and policy_path.exists(): if normalized(policy_path.read_text(encoding="utf-8")) != normalized(generated_policy_hcl(ccr)): errors.append("openbao.policy_file does not match the generated login-self policy") elif policy_file: errors.append(f"openbao.policy_file does not exist: {policy_file}") if auth.get("method") != "jwt": errors.append("openbao.auth.method must be jwt") mount = str(auth.get("mount") or "") if mount not in ALLOWED_AUTH_MOUNTS: errors.append(f"openbao.auth.mount must be one of {sorted(ALLOWED_AUTH_MOUNTS)}") role_name = str(auth.get("role") or "") if not ROLE_RE.match(role_name): 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") config = _load_json(repo_dir, str(auth.get("config_file") or ""), "openbao.auth.config_file", errors) if config: if set(config) != CONFIG_KEYS: errors.append(f"auth config keys must be exactly {sorted(CONFIG_KEYS)}") issuer = str(config.get("bound_issuer") or "") jwks = str(config.get("jwks_url") or "") if not issuer.startswith("https://") or not jwks.startswith(issuer.rstrip("/") + "/"): errors.append("auth config needs an https bound_issuer and a jwks_url under it") if config.get("jwt_supported_algs") != ["RS256"]: errors.append("auth config must pin jwt_supported_algs to [RS256]") role = _load_json(repo_dir, str(auth.get("role_file") or ""), "openbao.auth.role_file", errors) if role: extra = set(role) - ROLE_KEYS if extra: errors.append(f"role has keys outside the reviewed set: {sorted(extra)}") if role.get("role_type") != "jwt" or role.get("user_claim") != "sub": errors.append("role must be role_type jwt with user_claim sub") if role.get("token_policies") != [policy_name]: errors.append("role token_policies must be exactly [openbao.policy_name]") if role.get("token_no_default_policy") is not True: errors.append("role must set token_no_default_policy true") if role.get("token_type") != "service": errors.append("role token_type must be service") if role.get("verbose_oidc_logging") is not False: errors.append("role must disable verbose_oidc_logging") audiences = role.get("bound_audiences") if not isinstance(audiences, list) or len(audiences) != 1 or audiences[0] != role_name: errors.append("role bound_audiences must be exactly [] (KeyCape aud = clientId)") if not str(role.get("bound_subject") or "").startswith("service:"): errors.append("role bound_subject must be an exact service: subject") claims = role.get("bound_claims") if role.get("bound_claims_type") != "string" or not isinstance(claims, dict): errors.append("role bound_claims must be string-typed") claims = {} for key in ("principal_type", "tenant", "roles", "scope"): value = claims.get(key) if not isinstance(value, str) or not value or "*" in value or "," in value: errors.append(f"role bound_claims.{key} must be one exact string") if claims.get("principal_type") != "service": errors.append("role bound_claims.principal_type must be service") seconds = [ttl_seconds(role.get(key)) for key in DURATION_KEYS] if any(s is None or s <= 0 or s > MAX_TTL_SECONDS for s in seconds): errors.append("role token TTLs must be set and at most 15m") uses = role.get("token_num_uses") if not isinstance(uses, int) or isinstance(uses, bool) or not 0 < uses <= MAX_TOKEN_USES: errors.append(f"role token_num_uses must be 1..{MAX_TOKEN_USES}") 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": "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": "auth_enable_if_absent", "openbao_path": f"sys/auth/{auth['mount']}", "payload": {"type": "jwt", "refuse_if_existing_type_differs": True}}, {"kind": "auth_config_write", "openbao_path": f"auth/{auth['mount']}/config", "payload": config_payload(ccr, repo_dir)}, {"kind": "auth_role_write", "openbao_path": f"auth/{auth['mount']}/role/{auth['role']}", "payload": role_payload(ccr, repo_dir)}, {"kind": "readback_compare", "openbao_path": "policy, auth mount type, config, role", "payload": {"prints": "booleans only"}}, ], "out_of_scope": [ "secret value reads or writes", "token creation or login", "changes to any other auth mount, role or policy", "front-door activation before the login proof", ], "required_evidence": [ "CCR id and approval reference", "readback equality for policy, mount type, config and role", "login proof from a real KeyCape service JWT (separate, value-free)", ], } 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"Policy metadata write: sys/policies/acl/{openbao['policy_name']}", f"JWT auth mount ensured: sys/auth/{auth['mount']} (type jwt)", f"Auth config write: auth/{auth['mount']}/config", f"Auth role metadata write: auth/{auth['mount']}/role/{auth['role']}", "Readback matched source for policy, mount type, config and role.", "No secret values were read, written, printed, or accepted in argv.", ] class BaoError(RuntimeError): pass def _run(bao_bin: str, args: list[str], repo_dir: Path, quiet: bool, capture: bool = False) -> str: command = [bao_bin, *args] if capture or quiet: result = subprocess.run(command, cwd=repo_dir, capture_output=True, text=True) if result.returncode != 0: raise BaoError(f"bao {' '.join(args[:2])} failed with exit code {result.returncode}") return result.stdout subprocess.run(command, cwd=repo_dir, check=True) return "" def _read_json(bao_bin: str, args: list[str], repo_dir: Path) -> dict[str, Any]: # "auth list -format=json" / "read -format=json ": flags follow the verb. flagged = [*args, "-format=json"] if args[:2] == ["auth", "list"] else [args[0], "-format=json", *args[1:]] out = _run(bao_bin, flagged, repo_dir, quiet=True, capture=True) try: return json.loads(out) except json.JSONDecodeError as exc: raise BaoError(f"bao {args[0]} returned non-JSON output") from exc def readback_mismatches(ccr: dict[str, Any], repo_dir: Path, bao_bin: str) -> list[str]: openbao = ccr["openbao"] auth = openbao["auth"] mismatches: list[str] = [] policy = _run(bao_bin, ["policy", "read", openbao["policy_name"]], repo_dir, quiet=True, capture=True) if normalized(policy) != normalized(generated_policy_hcl(ccr)): mismatches.append("policy text differs from source") mounts = _read_json(bao_bin, ["auth", "list"], repo_dir) mounts = mounts.get("data", mounts) if (mounts.get(f"{auth['mount']}/") or {}).get("type") != "jwt": mismatches.append("auth mount is not type jwt") config = _read_json(bao_bin, ["read", f"auth/{auth['mount']}/config"], repo_dir).get("data", {}) for key, want in config_payload(ccr, repo_dir).items(): if config.get(key) != want: mismatches.append(f"config {key} differs") 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 = ttl_seconds(got) == ttl_seconds(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"] mount = auth["mount"] mounts = _read_json(bao_bin, ["auth", "list"], repo_dir) mounts = mounts.get("data", mounts) existing = mounts.get(f"{mount}/") if existing is not None and existing.get("type") != "jwt": raise BaoError(f"auth mount {mount}/ exists with a different type; review, do not overwrite") _run(bao_bin, ["policy", "write", openbao["policy_name"], openbao["policy_file"]], repo_dir, quiet) if existing is None: _run(bao_bin, ["auth", "enable", f"-path={mount}", f"-description={ccr['id']} KeyCape service JWT login", "jwt"], repo_dir, quiet) with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / "config.json" role_path = Path(tmp) / "role.json" config_path.write_text(json.dumps(config_payload(ccr, repo_dir), sort_keys=True) + "\n") role_path.write_text(json.dumps(role_payload(ccr, repo_dir), sort_keys=True) + "\n") _run(bao_bin, ["write", f"auth/{mount}/config", f"@{config_path}"], repo_dir, quiet) _run(bao_bin, ["write", f"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))