diff --git a/Makefile b/Makefile index eb1ec82..24bbf2c 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap ARGOCD_REPOSITORY_SECRET ?= CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml OPENBAO_TOKEN_GRANT_ARGS ?= +CREDENTIAL_HELPER_GLOBAL_ARGS ?= CREDENTIAL_HELPER_ARGS ?= CREDENTIAL_HELPER_PURPOSE ?= flex-auth-openbao-smoke @@ -202,19 +203,27 @@ openbao-verify-token-grants-smoke: ## Mint/revoke a child token and prove bounde scripts/openbao-verify-token-grants.py --issue-smoke-token $(OPENBAO_TOKEN_GRANT_ARGS) credential-helper-dry-run: ## Dry-run credential request, exec, status, and revoke helper flows - scripts/credential.py request --dry-run --grant ops-warden/warden-sign \ - --purpose $(CREDENTIAL_HELPER_PURPOSE) $(CREDENTIAL_HELPER_ARGS) - scripts/credential.py exec --dry-run --grant ops-warden/warden-sign \ - --purpose $(CREDENTIAL_HELPER_PURPOSE) $(CREDENTIAL_HELPER_ARGS) -- \ - SMOKE_VAULT=1 /bin/true - scripts/credential.py status --dry-run example-accessor - scripts/credential.py revoke --dry-run example-accessor + scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) request --dry-run \ + --grant ops-warden/warden-sign --purpose $(CREDENTIAL_HELPER_PURPOSE) \ + $(CREDENTIAL_HELPER_ARGS) + scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) request --dry-run \ + --grant ops-warden/warden-sign --purpose $(CREDENTIAL_HELPER_PURPOSE) \ + --delivery kubernetes-auth $(CREDENTIAL_HELPER_ARGS) + scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) exec --dry-run \ + --grant ops-warden/warden-sign --purpose $(CREDENTIAL_HELPER_PURPOSE) \ + $(CREDENTIAL_HELPER_ARGS) -- SMOKE_VAULT=1 /bin/true + scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) status --dry-run example-accessor + scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) revoke --dry-run example-accessor + +credential-tests: ## Run offline credential broker unit tests + python3 -m unittest discover -s tests -p 'test_credential*.py' credential-exec-ops-warden-smoke: ## Run ops-warden smoke with an exec-injected warden-sign token KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \ OPENBAO_RELEASE=$(OPENBAO_RELEASE) \ - scripts/credential.py exec --grant ops-warden/warden-sign \ - --purpose ops-warden-production-sign-smoke $(CREDENTIAL_HELPER_ARGS) -- \ + scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) exec \ + --grant ops-warden/warden-sign --purpose ops-warden-production-sign-smoke \ + $(CREDENTIAL_HELPER_ARGS) -- \ SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh ##@ ArgoCD GitOps bootstrap @@ -250,4 +259,4 @@ help: ## Show this help /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) -.PHONY: db-deploy db-status db-shell db-logs apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help +.PHONY: db-deploy db-status db-shell db-logs apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help diff --git a/credential-grants/catalog.yaml b/credential-grants/catalog.yaml index eb18e80..1c7a2be 100644 --- a/credential-grants/catalog.yaml +++ b/credential-grants/catalog.yaml @@ -1,5 +1,5 @@ version: 1 -updated: "2026-06-26" +updated: "2026-06-27" owner_repo: railiance-platform owner_domain: financials workplan_id: RAILIANCE-WP-0005 @@ -73,6 +73,7 @@ grants: - exec-env - response-wrap - local-token-file + - kubernetes-auth preferred: exec-env denied: - chat @@ -90,6 +91,16 @@ grants: local_token_file: directory: .local/credential-leases mode: "0600" + kubernetes_auth: + mount: auth/kubernetes + role: credential-broker-warden-sign + audience: openbao + service_account_names: + - credential-broker + - ops-warden-smoke + namespaces: + - openbao + - ops-warden audit: openbao_audit_required: true state_hub_metadata_allowed: true diff --git a/docs/credential-broker.md b/docs/credential-broker.md index 8ed541a..21c2d96 100644 --- a/docs/credential-broker.md +++ b/docs/credential-broker.md @@ -2,7 +2,7 @@ **Workplan:** `RAILIANCE-WP-0005` **Owner:** `railiance-platform` -**Status:** source implementation started +**Status:** source implementation complete; live verification pending approved token path This document records the Railiance credential broker ownership decision and the first implementation contract for short-lived OpenBao credential leases. @@ -128,6 +128,9 @@ Git. `kubernetes-auth` is for in-cluster workloads. Workloads should authenticate with service-account-bound auth instead of receiving manually handed tokens. +For the pilot grant, `request --delivery kubernetes-auth` returns only +non-secret OpenBao auth metadata such as the auth mount, role, service account +names, and namespaces; it does not mint or print a bearer token. The denied modes are absolute unless a later ADR updates the catalog: @@ -174,10 +177,122 @@ Dry-run all helper paths with: make credential-helper-dry-run ``` +Pass helper global options before the subcommand. For example, if the OpenBao +pod has an approved token helper session: + +```bash +make credential-exec-ops-warden-smoke CREDENTIAL_HELPER_GLOBAL_ARGS=--use-token-helper +``` + The child process receives `VAULT_TOKEN` in its environment. The token is not printed, written to shell history, sent to State Hub, or placed in an LLM prompt. +## Identity And Authorization + +The helper records the following non-secret request identity fields: + +- `actor`: the requester identity, defaulting to `codex:`; +- `actor_type`: one of the grant-approved actor classes such as + `human-operator`, `approved-agent`, or `ci-runner`; +- `subject`: the bound human, agent, CI, or Kubernetes service-account subject. + +Human operators should use the KeyCape/OIDC path with MFA when the grant class +or purpose requires it. Agents and CI runners should use stable subject strings +that can be mapped to IAM profile claims, for example +`agent:codex/railiance-platform` or +`system:serviceaccount::`. Headless automation must +use Kubernetes auth or an explicitly approved non-interactive identity; it must +not reuse a human OpenBao token. + +The helper performs local catalog checks before any issuance: + +- purpose is required; +- requested TTL must not exceed the grant max TTL; +- delivery mode must be allowed by the grant; +- actor type must be allowed by the grant. + +Optional flex-auth preflight is enabled with `--flex-auth-url` or `FLEX_AUTH_URL`. +The helper posts non-secret request metadata to +`/credential-grants/authorize` by default and accepts allow/deny responses using +`allowed`, `decision`, or `status` fields plus optional `decision_id` and +`reason`. Use `--require-flex-auth` when local preauthorization is not +acceptable. Use `--decision-id` to carry an already-approved external decision +without calling flex-auth again. + +## State Hub Metadata + +State Hub recording is opt-in through `--record-state-hub` or +`CREDENTIAL_RECORD_STATE_HUB=1`. The helper writes request lifecycle notes to +`/progress/` with non-secret metadata only: + +- grant id, actor, actor type, subject, purpose, requested TTL, delivery mode; +- authorization mode, decision id, and decision reason; +- lease accessor, wrapping accessor, or wrapped accessor when available; +- status values such as `requested`, `issued`, `wrapped`, `revoked`, or + `delegated`. + +It never records raw child tokens, wrapping tokens, token files, passwords, +OpenBao root/platform-admin tokens, or command output. + +## Verification And Revocation + +Offline checks: + +```bash +make credential-grants-validate +make credential-tests +make openbao-token-grants-dry-run +make openbao-verify-token-grants-dry-run +make credential-helper-dry-run +``` + +Live source-owned checks, once an approved OpenBao issuer token path exists: + +```bash +OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-configure-token-grants +OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-verify-token-grants-smoke +OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make credential-exec-ops-warden-smoke +``` + +Emergency revocation by accessor: + +```bash +scripts/credential.py revoke +``` + +When using `local-token-file`, remove stale local lease material after revoke or +expiry: + +```bash +find .local/credential-leases -type f -maxdepth 1 -print +``` + +Response wrapping live verification is manual until a richer integration test +exists: unwrap the returned wrapping token once with OpenBao, confirm the second +unwrap attempt fails, then revoke the wrapped child token by accessor. + +## Routing And Rollout + +Credential routing remains split by responsibility: + +- `ops-warden` signs SSH certificates only; +- OpenBao token or dynamic-lease needs route to `railiance-platform`; +- login/MFA routes to KeyCape; +- authorization decisions route to flex-auth. + +The rollout sequence is: + +1. `ops-warden/warden-sign` pilot for the flex-auth/ops-warden smoke. +2. Platform-readonly token helper for diagnostics. +3. Workload-specific grants for application repositories. +4. Optional split to a dedicated credential-broker repo if the helper grows + beyond platform ownership. + +The workplan can close only after the live warden-sign pilot runs through the +helper and the credential routing catalog returns this railiance-platform flow +for VAULT_TOKEN/OpenBao-token requests. + ## Implementation Sequence 1. Validate and maintain the non-secret grant catalog. diff --git a/scripts/credential-grants-validate.py b/scripts/credential-grants-validate.py index 8e79ccc..6f6f194 100755 --- a/scripts/credential-grants-validate.py +++ b/scripts/credential-grants-validate.py @@ -240,6 +240,38 @@ def validate_grant( ) if str(local_file.get("mode")) != "0600": errors.append(f"{prefix}.delivery.local_token_file.mode must be 0600") + if "kubernetes-auth" in allowed: + kubernetes_auth = require_dict( + delivery.get("kubernetes_auth"), + f"{prefix}.delivery.kubernetes_auth", + errors, + ) + require_nonempty_string( + kubernetes_auth.get("mount"), + f"{prefix}.delivery.kubernetes_auth.mount", + errors, + ) + require_nonempty_string( + kubernetes_auth.get("role"), + f"{prefix}.delivery.kubernetes_auth.role", + errors, + ) + if not require_list( + kubernetes_auth.get("service_account_names"), + f"{prefix}.delivery.kubernetes_auth.service_account_names", + errors, + ): + errors.append( + f"{prefix}.delivery.kubernetes_auth.service_account_names must not be empty" + ) + if not require_list( + kubernetes_auth.get("namespaces"), + f"{prefix}.delivery.kubernetes_auth.namespaces", + errors, + ): + errors.append( + f"{prefix}.delivery.kubernetes_auth.namespaces must not be empty" + ) audit = require_dict(grant_obj.get("audit"), f"{prefix}.audit", errors) if audit.get("openbao_audit_required") is not True: diff --git a/scripts/credential.py b/scripts/credential.py index 4300181..8ae9e00 100755 --- a/scripts/credential.py +++ b/scripts/credential.py @@ -10,6 +10,9 @@ import re import shlex import subprocess import sys +import urllib.error +import urllib.request +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -20,11 +23,22 @@ REPO_DIR = Path(__file__).resolve().parents[1] DEFAULT_CATALOG = REPO_DIR / "credential-grants/catalog.yaml" DEFAULT_LEASE_DIR = REPO_DIR / ".local/credential-leases" TOKEN_MARKERS = re.compile( - r"\bhv[bcms]\.[A-Za-z0-9._-]+|\b(?:VAULT_TOKEN|BAO_TOKEN)=[^\s]+" + r"\bhv[bcms]\.[A-Za-z0-9._-]+|\b(?:VAULT_TOKEN|BAO_TOKEN|OPENBAO_ROOT_TOKEN)=[^\s]+" ) ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") UNSAFE_TOKEN_ENV = {"VAULT_TOKEN", "BAO_TOKEN", "OPENBAO_ROOT_TOKEN"} UNSAFE_VERBOSE_VALUES = {"debug", "trace"} +DEFAULT_ACTOR_TYPE = "approved-agent" +DEFAULT_ACTOR = f"codex:{os.environ.get('USER', 'unknown')}" +DEFAULT_SUBJECT = "agent:codex/railiance-platform" + + +@dataclass(frozen=True) +class AuthorizationResult: + allowed: bool + mode: str + decision_id: str | None = None + reason: str | None = None def emit_json(payload: dict[str, Any]) -> None: @@ -35,6 +49,10 @@ def fail(message: str) -> None: raise SystemExit(f"ERROR: {message}") +def warn(message: str) -> None: + print(f"WARN: {message}", file=sys.stderr) + + def redact(text: str, extra_secrets: list[str] | None = None) -> str: redacted = TOKEN_MARKERS.sub("[REDACTED]", text) for secret in extra_secrets or []: @@ -116,7 +134,11 @@ def get_grant(catalog: dict[str, Any], grant_id: str) -> dict[str, Any]: def validate_issue_request( - grant: dict[str, Any], ttl: str, purpose: str, delivery: str | None = None + grant: dict[str, Any], + ttl: str, + purpose: str, + delivery: str | None = None, + actor_type: str | None = None, ) -> None: if not purpose.strip(): fail("--purpose is required and must not be empty") @@ -132,6 +154,14 @@ def validate_issue_request( fail( f"delivery mode {delivery!r} is not allowed for grant {grant.get('id')}" ) + if actor_type: + allowed_actor_types = set( + (grant.get("actors") or {}).get("allowed_types") or [] + ) + if actor_type not in allowed_actor_types: + fail( + f"actor type {actor_type!r} is not allowed for grant {grant.get('id')}" + ) def safe_handle(handle: str) -> str: @@ -140,6 +170,239 @@ def safe_handle(handle: str) -> str: return f"{cleaned}-{digest}" +def bool_env(name: str) -> bool: + return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} + + +def join_url(base: str, path: str) -> str: + return base.rstrip("/") + "/" + path.lstrip("/") + + +def post_json( + url: str, payload: dict[str, Any], timeout: float = 10.0 +) -> dict[str, Any]: + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {exc.code} from {url}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"could not reach {url}: {exc.reason}") from exc + if not raw.strip(): + return {} + data = json.loads(raw) + if not isinstance(data, dict): + raise RuntimeError(f"expected JSON object from {url}") + return data + + +def request_metadata( + *, + grant: dict[str, Any], + ttl: str, + purpose: str, + delivery: str, + actor: str, + actor_type: str, + subject: str, +) -> dict[str, Any]: + return { + "grant_id": grant["id"], + "actor": actor, + "actor_type": actor_type, + "subject": subject, + "purpose": purpose, + "requested_ttl": ttl, + "delivery_mode": delivery, + "audience": grant.get("audience"), + "issuer": grant.get("issuer"), + "credential_type": grant.get("credential_type"), + } + + +def authorize_request( + *, + args: argparse.Namespace, + grant: dict[str, Any], + ttl: str, + purpose: str, + delivery: str, +) -> AuthorizationResult: + validate_issue_request(grant, ttl, purpose, delivery, args.actor_type) + if args.decision_id: + return AuthorizationResult( + True, + "provided-decision", + args.decision_id, + "caller supplied prior decision id", + ) + if args.dry_run: + return AuthorizationResult( + True, "dry-run-local", None, "dry-run does not call flex-auth" + ) + if not args.flex_auth_url: + if args.require_flex_auth: + fail( + "--require-flex-auth was set, but no --flex-auth-url or FLEX_AUTH_URL is configured" + ) + return AuthorizationResult( + True, + "local-preauthorized", + None, + "flex-auth unavailable; grant policy is locally preauthorized", + ) + + endpoint = join_url(args.flex_auth_url, args.flex_auth_path) + payload = request_metadata( + grant=grant, + ttl=ttl, + purpose=purpose, + delivery=delivery, + actor=args.actor, + actor_type=args.actor_type, + subject=args.subject, + ) + try: + response = post_json(endpoint, payload, timeout=args.http_timeout) + except Exception as exc: # noqa: BLE001 + if args.require_flex_auth: + fail(f"flex-auth preflight failed: {exc}") + warn( + f"flex-auth preflight unavailable; continuing by local preauthorization: {exc}" + ) + return AuthorizationResult( + True, + "local-preauthorized", + None, + "flex-auth unavailable; local preauthorization used", + ) + + allowed_value = response.get("allowed") + decision_value = str( + response.get("decision") or response.get("status") or "" + ).lower() + allowed = allowed_value is True or decision_value in { + "allow", + "allowed", + "approved", + "pass", + } + denied = allowed_value is False or decision_value in { + "deny", + "denied", + "rejected", + "fail", + } + decision_id = response.get("decision_id") or response.get("id") + reason = response.get("reason") or response.get("message") + if denied or not allowed: + fail(f"flex-auth denied credential request: {reason or 'no reason supplied'}") + return AuthorizationResult( + True, + "flex-auth", + str(decision_id) if decision_id else None, + str(reason) if reason else None, + ) + + +def state_hub_enabled(args: argparse.Namespace) -> bool: + return args.record_state_hub or bool_env("CREDENTIAL_RECORD_STATE_HUB") + + +def state_hub_metadata( + *, + args: argparse.Namespace, + grant: dict[str, Any] | None, + status: str, + purpose: str | None = None, + ttl: str | None = None, + delivery: str | None = None, + authz: AuthorizationResult | None = None, + lease_handle: str | None = None, + wrapping_accessor: str | None = None, + wrapped_accessor: str | None = None, + exit_code: int | None = None, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "status": status, + "actor": args.actor, + "actor_type": args.actor_type, + "subject": args.subject, + } + if grant is not None: + metadata.update( + { + "grant_id": grant.get("id"), + "audience": grant.get("audience"), + "issuer": grant.get("issuer"), + "credential_type": grant.get("credential_type"), + } + ) + if purpose is not None: + metadata["purpose"] = purpose + if ttl is not None: + metadata["requested_ttl"] = ttl + if delivery is not None: + metadata["delivery_mode"] = delivery + if authz is not None: + metadata["authorization_mode"] = authz.mode + metadata["decision_id"] = authz.decision_id + metadata["decision_reason"] = authz.reason + if lease_handle is not None: + metadata["lease_accessor"] = lease_handle + if wrapping_accessor is not None: + metadata["wrapping_accessor"] = wrapping_accessor + if wrapped_accessor is not None: + metadata["wrapped_accessor"] = wrapped_accessor + if exit_code is not None: + metadata["exit_code"] = exit_code + return metadata + + +def record_state_hub(args: argparse.Namespace, metadata: dict[str, Any]) -> None: + if not state_hub_enabled(args): + return + if args.dry_run: + print("DRY-RUN: would record non-secret State Hub credential metadata") + return + url = args.state_hub_url.rstrip("/") + "/progress/" + summary_parts = [ + "credential-broker", + str(metadata.get("status")), + f"grant={metadata.get('grant_id')}", + f"actor={metadata.get('actor')}", + f"purpose={metadata.get('purpose')}", + f"ttl={metadata.get('requested_ttl')}", + f"delivery={metadata.get('delivery_mode')}", + ] + if metadata.get("decision_id"): + summary_parts.append(f"decision={metadata.get('decision_id')}") + if metadata.get("lease_accessor"): + summary_parts.append(f"lease={metadata.get('lease_accessor')}") + payload = { + "summary": " ".join( + part for part in summary_parts if part and not part.endswith("=None") + ), + "detail": json.dumps(metadata, sort_keys=True), + "event_type": "note", + "author": "credential-broker", + } + if args.state_hub_workstream_id: + payload["workstream_id"] = args.state_hub_workstream_id + try: + post_json(url, payload, timeout=args.http_timeout) + except Exception as exc: # noqa: BLE001 + warn(f"could not record State Hub metadata: {exc}") + + class BaoRunner: def __init__( self, @@ -159,11 +422,7 @@ class BaoRunner: self.issuer_token = issuer_token def run( - self, - args: list[str], - *, - input_text: str | None = None, - quiet: bool = False, + self, args: list[str], *, input_text: str | None = None, quiet: bool = False ) -> subprocess.CompletedProcess[str]: if self.dry_run: print("DRY-RUN: bao " + shlex.join(args)) @@ -222,13 +481,7 @@ def token_create_args( args = ["token", "create"] if wrap_ttl: args.append(f"-wrap-ttl={wrap_ttl}") - args.extend( - [ - f"-role={openbao['token_role']}", - f"-ttl={ttl}", - "-format=json", - ] - ) + args.extend([f"-role={openbao['token_role']}", f"-ttl={ttl}", "-format=json"]) for policy in openbao["policies"]: args.append(f"-policy={policy}") return args @@ -278,6 +531,7 @@ def write_local_lease( ttl: str, token: str, accessor: str, + authz: AuthorizationResult, ) -> dict[str, Any]: stem = safe_handle(accessor) token_path = lease_dir / f"{stem}.openbao-token" @@ -292,6 +546,8 @@ def write_local_lease( "expires_at": expires_at(ttl), "ttl": ttl, "delivery_mode": "local-token-file", + "authorization_mode": authz.mode, + "decision_id": authz.decision_id, "token_file": str(token_path), "status": "issued", } @@ -374,11 +630,53 @@ def split_env_prefix(command: list[str]) -> tuple[dict[str, str], list[str]]: return extra_env, program +def kubernetes_auth_payload( + grant: dict[str, Any], ttl: str, purpose: str, authz: AuthorizationResult +) -> dict[str, Any]: + delivery = grant.get("delivery") or {} + kubernetes_auth = delivery.get("kubernetes_auth") or {} + return { + "grant_id": grant["id"], + "purpose": purpose, + "ttl": ttl, + "delivery_mode": "kubernetes-auth", + "status": "delegated", + "authorization_mode": authz.mode, + "decision_id": authz.decision_id, + "openbao_auth_mount": kubernetes_auth.get("mount", "auth/kubernetes"), + "openbao_auth_role": kubernetes_auth.get("role") + or grant["openbao"].get("kubernetes_auth_role"), + "service_account_names": kubernetes_auth.get("service_account_names", []), + "namespaces": kubernetes_auth.get("namespaces", []), + "note": "Workload must authenticate with its Kubernetes service account JWT; no bearer token is issued by this helper.", + } + + def command_request( args: argparse.Namespace, runner: BaoRunner, grant: dict[str, Any] ) -> int: ttl = args.ttl or grant["ttl"]["default"] - validate_issue_request(grant, ttl, args.purpose, args.delivery) + authz = authorize_request( + args=args, grant=grant, ttl=ttl, purpose=args.purpose, delivery=args.delivery + ) + if args.delivery == "kubernetes-auth": + payload = kubernetes_auth_payload(grant, ttl, args.purpose, authz) + payload["status"] = "dry-run" if args.dry_run else payload["status"] + emit_json(payload) + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status=payload["status"], + purpose=args.purpose, + ttl=ttl, + delivery=args.delivery, + authz=authz, + ), + ) + return 0 + if args.dry_run: emit_json( { @@ -388,6 +686,8 @@ def command_request( "purpose": args.purpose, "ttl": ttl, "delivery_mode": args.delivery, + "authorization_mode": authz.mode, + "decision_id": authz.decision_id, } ) if args.delivery == "response-wrap": @@ -397,23 +697,62 @@ def command_request( ) else: print("DRY-RUN: bao " + shlex.join(token_create_args(grant, ttl))) + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="dry-run", + purpose=args.purpose, + ttl=ttl, + delivery=args.delivery, + authz=authz, + ), + ) return 0 + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="requested", + purpose=args.purpose, + ttl=ttl, + delivery=args.delivery, + authz=authz, + ), + ) if args.delivery == "response-wrap": result = runner.run(token_create_args(grant, ttl, args.wrap_ttl), quiet=True) wrap_info = parse_wrap_create(result.stdout) - emit_json( - { - "grant_id": grant["id"], - "purpose": args.purpose, - "ttl": ttl, - "delivery_mode": "response-wrap", - "wrapping_token": wrap_info.get("token"), - "wrapping_accessor": wrap_info.get("accessor"), - "wrapped_accessor": wrap_info.get("wrapped_accessor"), - "wrap_ttl": wrap_info.get("ttl"), - "status": "wrapped", - } + payload = { + "grant_id": grant["id"], + "purpose": args.purpose, + "ttl": ttl, + "delivery_mode": "response-wrap", + "wrapping_token": wrap_info.get("token"), + "wrapping_accessor": wrap_info.get("accessor"), + "wrapped_accessor": wrap_info.get("wrapped_accessor"), + "wrap_ttl": wrap_info.get("ttl"), + "authorization_mode": authz.mode, + "decision_id": authz.decision_id, + "status": "wrapped", + } + emit_json(payload) + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="wrapped", + purpose=args.purpose, + ttl=ttl, + delivery=args.delivery, + authz=authz, + wrapping_accessor=payload.get("wrapping_accessor"), + wrapped_accessor=payload.get("wrapped_accessor"), + ), ) return 0 @@ -426,8 +765,22 @@ def command_request( ttl=ttl, token=token, accessor=accessor, + authz=authz, ) emit_json(payload) + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="issued", + purpose=args.purpose, + ttl=ttl, + delivery=args.delivery, + authz=authz, + lease_handle=accessor, + ), + ) return 0 @@ -435,7 +788,9 @@ def command_exec( args: argparse.Namespace, runner: BaoRunner, grant: dict[str, Any] ) -> int: ttl = args.ttl or grant["ttl"]["default"] - validate_issue_request(grant, ttl, args.purpose, "exec-env") + authz = authorize_request( + args=args, grant=grant, ttl=ttl, purpose=args.purpose, delivery="exec-env" + ) extra_env, program = split_env_prefix(args.command) if args.dry_run: emit_json( @@ -446,22 +801,63 @@ def command_exec( "purpose": args.purpose, "ttl": ttl, "delivery_mode": "exec-env", + "authorization_mode": authz.mode, + "decision_id": authz.decision_id, "child_env": sorted([*extra_env.keys(), "VAULT_TOKEN"]), "child_command": program, } ) print("DRY-RUN: bao " + shlex.join(token_create_args(grant, ttl))) + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="dry-run", + purpose=args.purpose, + ttl=ttl, + delivery="exec-env", + authz=authz, + ), + ) return 0 + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="requested", + purpose=args.purpose, + ttl=ttl, + delivery="exec-env", + authz=authz, + ), + ) result = runner.run(token_create_args(grant, ttl), quiet=True) token, accessor = parse_token_create(result.stdout) + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="issued", + purpose=args.purpose, + ttl=ttl, + delivery="exec-env", + authz=authz, + lease_handle=accessor, + ), + ) env = os.environ.copy() env.update(extra_env) env["VAULT_TOKEN"] = token + exit_code = 1 try: child = subprocess.run( program, env=env, capture_output=True, text=True, check=False ) + exit_code = child.returncode if child.stdout: print(redact(child.stdout, [token]), end="") if child.stderr: @@ -471,6 +867,20 @@ def command_exec( runner.run( ["write", "auth/token/revoke-accessor", f"accessor={accessor}"], quiet=True ) + record_state_hub( + args, + state_hub_metadata( + args=args, + grant=grant, + status="revoked", + purpose=args.purpose, + ttl=ttl, + delivery="exec-env", + authz=authz, + lease_handle=accessor, + exit_code=exit_code, + ), + ) def command_status(args: argparse.Namespace, runner: BaoRunner) -> int: @@ -552,16 +962,53 @@ def build_parser() -> argparse.ArgumentParser: help="Use the OpenBao CLI token helper inside the pod", ) parser.add_argument("--lease-dir", type=Path, default=DEFAULT_LEASE_DIR) + parser.add_argument( + "--actor", default=os.environ.get("CREDENTIAL_ACTOR", DEFAULT_ACTOR) + ) + parser.add_argument( + "--actor-type", + default=os.environ.get("CREDENTIAL_ACTOR_TYPE", DEFAULT_ACTOR_TYPE), + ) + parser.add_argument( + "--subject", default=os.environ.get("CREDENTIAL_SUBJECT", DEFAULT_SUBJECT) + ) + parser.add_argument( + "--decision-id", default=os.environ.get("CREDENTIAL_DECISION_ID") + ) + parser.add_argument("--flex-auth-url", default=os.environ.get("FLEX_AUTH_URL")) + parser.add_argument( + "--flex-auth-path", + default=os.environ.get("FLEX_AUTH_PATH", "/credential-grants/authorize"), + ) + parser.add_argument("--require-flex-auth", action="store_true") + parser.add_argument( + "--state-hub-url", + default=os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000"), + ) + parser.add_argument( + "--state-hub-workstream-id", default=os.environ.get("STATE_HUB_WORKSTREAM_ID") + ) + parser.add_argument( + "--record-state-hub", + action="store_true", + help="Record non-secret request lifecycle metadata to State Hub", + ) + parser.add_argument( + "--http-timeout", + type=float, + default=float(os.environ.get("CREDENTIAL_HTTP_TIMEOUT", "10")), + ) subparsers = parser.add_subparsers(dest="command_name", required=True) request = subparsers.add_parser( - "request", help="Issue a wrapped token or local-token-file lease" + "request", + help="Issue a wrapped token, local-token-file lease, or Kubernetes-auth delegation", ) add_common_issue_args(request) request.add_argument( "--delivery", - choices=["local-token-file", "response-wrap"], + choices=["local-token-file", "response-wrap", "kubernetes-auth"], default="local-token-file", ) request.add_argument("--wrap-ttl", default="5m") @@ -593,6 +1040,8 @@ def main() -> int: args = parser.parse_args() args.lease_dir = resolve_repo_path(args.lease_dir) catalog = load_catalog(resolve_repo_path(Path(args.catalog))) + if not args.state_hub_workstream_id: + args.state_hub_workstream_id = catalog.get("state_hub_workstream_id") grant = None if args.command_name in {"request", "exec"}: grant = get_grant(catalog, args.grant) diff --git a/tests/test_credential_helper.py b/tests/test_credential_helper.py new file mode 100644 index 0000000..0768b3d --- /dev/null +++ b/tests/test_credential_helper.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import importlib.util +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "credential_helper", REPO_DIR / "scripts/credential.py" +) +credential = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = credential +SPEC.loader.exec_module(credential) + + +def sample_grant() -> dict: + return { + "id": "ops-warden/warden-sign", + "issuer": "openbao", + "audience": "ops-warden", + "credential_type": "openbao-token", + "openbao": { + "token_role": "warden-sign", + "policies": ["warden-sign"], + }, + "ttl": {"default": "15m", "max": "1h"}, + "actors": {"allowed_types": ["human-operator", "approved-agent"]}, + "delivery": { + "allowed": [ + "exec-env", + "response-wrap", + "local-token-file", + "kubernetes-auth", + ], + "kubernetes_auth": { + "mount": "auth/kubernetes", + "role": "credential-broker-warden-sign", + "service_account_names": ["credential-broker"], + "namespaces": ["openbao"], + }, + }, + } + + +class CredentialHelperTests(unittest.TestCase): + def test_ttl_over_max_is_rejected(self) -> None: + with self.assertRaises(SystemExit): + credential.validate_issue_request( + sample_grant(), "2h", "purpose", "exec-env", "approved-agent" + ) + + def test_actor_type_is_checked(self) -> None: + with self.assertRaises(SystemExit): + credential.validate_issue_request( + sample_grant(), "15m", "purpose", "exec-env", "unknown-actor" + ) + + def test_split_env_prefix_rejects_token_injection(self) -> None: + with self.assertRaises(SystemExit): + credential.split_env_prefix(["--", "VAULT_TOKEN=hvs.bad", "/bin/true"]) + + def test_split_env_prefix_accepts_safe_assignments(self) -> None: + extra_env, command = credential.split_env_prefix( + ["--", "SMOKE_VAULT=1", "/bin/true"] + ) + self.assertEqual(extra_env, {"SMOKE_VAULT": "1"}) + self.assertEqual(command, ["/bin/true"]) + + def test_redaction_catches_bao_tokens_and_env_assignments(self) -> None: + text = "token=hvb.abc123 VAULT_TOKEN=hvs.secret BAO_TOKEN=hvb.secret" + redacted = credential.redact(text) + self.assertNotIn("hvb.abc123", redacted) + self.assertNotIn("hvs.secret", redacted) + self.assertIn("[REDACTED]", redacted) + + def test_local_lease_is_mode_0600_and_cleanup_stays_in_lease_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + lease_dir = Path(tmp) / "leases" + authz = credential.AuthorizationResult(True, "unit-test", "decision-1") + payload = credential.write_local_lease( + lease_dir=lease_dir, + grant=sample_grant(), + purpose="unit-test", + ttl="15m", + token="hvb.unit-test-secret", + accessor="accessor-unit-test", + authz=authz, + ) + token_file = Path(payload["token_file"]) + metadata_file = Path(payload["metadata_file"]) + self.assertEqual(stat.S_IMODE(token_file.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(metadata_file.stat().st_mode), 0o600) + removed = credential.remove_local_lease_files( + lease_dir, "accessor-unit-test" + ) + self.assertIn(str(token_file), removed) + self.assertIn(str(metadata_file), removed) + self.assertFalse(token_file.exists()) + self.assertFalse(metadata_file.exists()) + + def test_kubernetes_auth_payload_issues_no_token(self) -> None: + authz = credential.AuthorizationResult(True, "dry-run-local", None) + payload = credential.kubernetes_auth_payload( + sample_grant(), "15m", "unit-test", authz + ) + self.assertEqual(payload["delivery_mode"], "kubernetes-auth") + self.assertEqual(payload["openbao_auth_role"], "credential-broker-warden-sign") + self.assertNotIn("token", payload) + self.assertIn("service_account_names", payload) + + def test_lease_paths_are_gitignored(self) -> None: + result = subprocess.run( + ["git", "check-ignore", ".local/credential-leases/example.openbao-token"], + cwd=REPO_DIR, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md b/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md index 0b38a58..e1ef36c 100644 --- a/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md +++ b/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md @@ -4,13 +4,13 @@ type: workplan title: "Credential Request and Lease Broker" domain: financials repo: railiance-platform -status: active +status: blocked owner: codex topic_slug: railiance planning_priority: high planning_order: 5 created: "2026-06-24" -updated: "2026-06-26" +updated: "2026-06-27" depends_on_workplans: - RAIL-PL-WP-0002 state_hub_workstream_id: "2731fece-6c49-45b8-ab8a-4ea6c04ac603" @@ -152,7 +152,7 @@ Acceptance: ```task id: RAILIANCE-WP-0005-T03 -status: progress +status: wait priority: high state_hub_task_id: "d8498e3b-b2fb-47b7-ab88-cd6592c1807e" ``` @@ -176,11 +176,19 @@ to work offline. Live closure still requires an approved OpenBao operator token path and successful runs of `make openbao-configure-token-grants` and `make openbao-verify-token-grants-smoke`, so T03 remains `progress`. +**2026-06-27:** Attempted the live idempotent apply with +`make openbao-configure-token-grants OPENBAO_TOKEN_GRANT_ARGS=--use-token-helper`. +OpenBao was reachable and unsealed, but the pod token helper received +`403 permission denied` while writing +`sys/policies/acl/credential-broker-warden-sign-issuer`. T03 is now `wait` +until an approved OpenBao issuer/platform-admin path applies the policy and +role, or the pod token helper is granted that narrow capability. + ## T04 - Build credential helper MVP ```task id: RAILIANCE-WP-0005-T04 -status: progress +status: wait priority: high state_hub_task_id: "0c543cb3-36cb-4b25-9a58-de8efc1216c9" ``` @@ -205,11 +213,18 @@ revokes exec tokens by accessor in a `finally` block. Added Make dry-run and ops-warden smoke targets. T04 remains `progress` until a live OpenBao issuer token is available to prove `credential-exec-ops-warden-smoke` end to end. +**2026-06-27:** Extended the helper with optional flex-auth preflight, +non-secret State Hub lifecycle metadata, actor/subject binding fields, +`--decision-id` support, and Kubernetes-auth delegation output. Fixed the Make +surface so global helper flags such as `--use-token-helper` are passed before +the subcommand. T04 is now `wait` on the same OpenBao live gate as T03 before +ops-warden smoke can be proven end to end. + ## T05 - Implement secure delivery modes ```task id: RAILIANCE-WP-0005-T05 -status: todo +status: wait priority: high state_hub_task_id: "66f3cd6d-7520-4584-90b8-672866ef3490" ``` @@ -229,11 +244,19 @@ Acceptance: - local-token-file paths are gitignored and rejected by secret scans if accidentally staged. - response-wrap unwraps once and fails on second use. +**2026-06-27:** Source support now covers all four delivery modes: `exec-env`, +`response-wrap`, `local-token-file`, and `kubernetes-auth`. The helper refuses +caller-supplied token env assignments, writes local leases under the ignored +`.local/credential-leases/` path with mode `0600`, and emits only service +account auth metadata for Kubernetes-auth. T05 is `wait` until live response-wrap +single-use behavior and the OpenBao-backed exec path are verified with an +approved issuer token. + ## T06 - Integrate KeyCape identity and agent subject binding ```task id: RAILIANCE-WP-0005-T06 -status: todo +status: done priority: medium state_hub_task_id: "e1dd5973-bf2b-4aa9-842e-9f530afa1ab6" ``` @@ -246,11 +269,17 @@ Acceptance: - Agent/service path has a documented subject id shape compatible with IAM profile claims and existing actor naming. - Headless automation uses Kubernetes auth or an explicitly approved non-interactive identity; it does not reuse a human token. +**2026-06-27:** Documented the identity contract in `docs/credential-broker.md`: +KeyCape/OIDC with MFA for human operators, stable IAM-compatible subjects for +agents and CI, and Kubernetes service-account subjects for headless workloads. +The helper now exposes `--actor`, `--actor-type`, and `--subject`, and validates +actor type against the grant catalog. T06 is done source-side. + ## T07 - Add flex-auth preflight authorization and State Hub request metadata ```task id: RAILIANCE-WP-0005-T07 -status: todo +status: wait priority: medium state_hub_task_id: "1269bb58-0699-43ef-aa4f-43bc49c61a49" ``` @@ -265,11 +294,18 @@ Acceptance: - State Hub records request lifecycle without token values. - The helper works in offline/degraded mode only for pre-authorized local flows; it never caches new secret material in State Hub. +**2026-06-27:** Added optional flex-auth preflight via `--flex-auth-url` / +`FLEX_AUTH_URL`, strict `--require-flex-auth`, provided decision ids via +`--decision-id`, and opt-in State Hub lifecycle notes via `--record-state-hub`. +The helper records only non-secret metadata. T07 is `wait` until a live flex-auth +credential authorization endpoint is available and the OpenBao live gate is +cleared. + ## T08 - Integrate ops-warden smoke and routing catalog ```task id: RAILIANCE-WP-0005-T08 -status: todo +status: wait priority: high state_hub_task_id: "4571d4c9-d4de-4ee9-97e0-ff03e49e65ec" ``` @@ -284,11 +320,18 @@ Acceptance: - ops-warden docs still make clear it owns SSH cert signing, not OpenBao token vending. - warden route find VAULT_TOKEN points to this railiance-platform flow. +**2026-06-27:** Added `make credential-exec-ops-warden-smoke` for the intended +one-command smoke and confirmed credential routing locally with +`uv run warden route show openbao-api-key --json`: OpenBao/API/dynamic lease +needs belong to `railiance-platform`; ops-warden executes SSH cert issuance +only. T08 is `wait` because this workspace cannot update the external +ops-warden routing catalog and the live OpenBao grant apply is still denied. + ## T09 - Verification, audit, and red-team checks ```task id: RAILIANCE-WP-0005-T09 -status: todo +status: wait priority: high state_hub_task_id: "78d1db83-12fb-4ac2-95eb-54c91ac125b5" ``` @@ -303,11 +346,18 @@ Acceptance: - Negative tests prove denied grants do not mint tokens. - Documentation includes emergency revocation and cleanup commands. +**2026-06-27:** Added `tests/test_credential_helper.py` and `make credential-tests` +covering TTL bounds, actor-type restrictions, token redaction, unsafe env +rejection, local lease mode/cleanup, Kubernetes-auth delegation, and gitignore +coverage for local lease files. Offline validation is passing. T09 is `wait` +until live OpenBao audit evidence, response-wrap unwrap-once evidence, and +negative live mint checks can be collected. + ## T10 - Rollout and migration ```task id: RAILIANCE-WP-0005-T10 -status: todo +status: wait priority: medium state_hub_task_id: "44ce4082-fa8f-44d0-8f86-172d14ecfb0e" ``` @@ -327,6 +377,11 @@ Acceptance: - Operators have a documented fast path and a break-glass path. - State Hub, ops-warden, key-cape, and flex-auth docs link to the same routing truth. +**2026-06-27:** Documented rollout phases, emergency revocation, delivery modes, +identity binding, flex-auth preflight, State Hub metadata, and routing ownership +in `docs/credential-broker.md`. T10 is `wait` on the live warden-sign pilot and +external routing-doc/catalog updates. + ## Exit Criteria - A policy-approved actor can request or exec with a short-lived OpenBao token without seeing or pasting the raw token.