From 752cfd6f0030a3eecae44a3cb43a1ee48fdcf58b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 27 Jun 2026 00:06:03 +0200 Subject: [PATCH] feat: add credential broker token helper --- Makefile | 42 +- credential-grants/catalog.yaml | 3 +- docs/credential-broker.md | 68 +- .../credential-broker-warden-sign-issuer.hcl | 23 + scripts/credential-grants-validate.py | 3 + scripts/credential.py | 626 ++++++++++++++++++ scripts/openbao-apply-token-grants.py | 219 ++++++ scripts/openbao-verify-token-grants.py | 293 ++++++++ ...005-credential-request-and-lease-broker.md | 25 +- 9 files changed, 1292 insertions(+), 10 deletions(-) create mode 100644 openbao/policies/credential-broker-warden-sign-issuer.hcl create mode 100755 scripts/credential.py create mode 100755 scripts/openbao-apply-token-grants.py create mode 100755 scripts/openbao-verify-token-grants.py diff --git a/Makefile b/Makefile index 02abe8a..eb1ec82 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,9 @@ ARGOCD_NAMESPACE ?= argocd ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap ARGOCD_REPOSITORY_SECRET ?= CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml +OPENBAO_TOKEN_GRANT_ARGS ?= +CREDENTIAL_HELPER_ARGS ?= +CREDENTIAL_HELPER_PURPOSE ?= flex-auth-openbao-smoke ##@ CloudNative PG (cnpg) — primary database operator @@ -177,6 +180,43 @@ openbao-validate-emergency-evidence: ## Validate non-secret OpenBao emergency se credential-grants-validate: ## Validate non-secret credential grant catalog scripts/credential-grants-validate.py $(CREDENTIAL_GRANTS) +openbao-token-grants-dry-run: ## Dry-run OpenBao token roles and issuer policies for credential grants + scripts/openbao-apply-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS) + +openbao-configure-token-grants: ## Apply OpenBao token roles and issuer policies for credential grants + KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \ + OPENBAO_RELEASE=$(OPENBAO_RELEASE) \ + scripts/openbao-apply-token-grants.py $(OPENBAO_TOKEN_GRANT_ARGS) + +openbao-verify-token-grants-dry-run: ## Dry-run OpenBao token grant verification + scripts/openbao-verify-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS) + +openbao-verify-token-grants: ## Verify OpenBao token roles and issuer policies for credential grants + KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \ + OPENBAO_RELEASE=$(OPENBAO_RELEASE) \ + scripts/openbao-verify-token-grants.py $(OPENBAO_TOKEN_GRANT_ARGS) + +openbao-verify-token-grants-smoke: ## Mint/revoke a child token and prove bounded warden-sign capabilities + KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \ + OPENBAO_RELEASE=$(OPENBAO_RELEASE) \ + 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 + +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) -- \ + SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh + ##@ ArgoCD GitOps bootstrap argocd-bootstrap-dry-run: ## Server-side dry-run ArgoCD AppProjects and root Application @@ -210,4 +250,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 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-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 00b4bf0..eb18e80 100644 --- a/credential-grants/catalog.yaml +++ b/credential-grants/catalog.yaml @@ -1,5 +1,5 @@ version: 1 -updated: "2026-06-25" +updated: "2026-06-26" owner_repo: railiance-platform owner_domain: financials workplan_id: RAILIANCE-WP-0005 @@ -38,6 +38,7 @@ grants: openbao: namespace: openbao token_role: warden-sign + issuer_policy: credential-broker-warden-sign-issuer policies: - warden-sign disallowed_policies: diff --git a/docs/credential-broker.md b/docs/credential-broker.md index c4a1e56..8ed541a 100644 --- a/docs/credential-broker.md +++ b/docs/credential-broker.md @@ -76,6 +76,41 @@ Every grant entry defines: The first pilot grant is `ops-warden/warden-sign`, which creates a short-lived OpenBao token with only the `warden-sign` policy. +## OpenBao Token Roles + +OpenBao-token grants are configured from source with: + +- an issuer policy under `openbao/policies/`; +- an `auth/token/roles/` token role with allowed policies, disallowed + admin policies, non-renewable TTL bounds, no default policy, and orphan token + issuance; +- verification that reads the issuer policy, token role, and target workload + policy before any smoke token is minted. + +Dry-run the current grant configuration with: + +```bash +make openbao-token-grants-dry-run +make openbao-verify-token-grants-dry-run +``` + +Live application uses an operator-approved OpenBao token from +`OPENBAO_TOKEN_FILE` or an interactive hidden prompt. The token is passed to the +OpenBao pod through stdin, never through argv: + +```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 +``` + +The smoke verifier can mint a short-lived child token, confirm that it can list +`ssh/roles`, confirm that it cannot list unrelated secret engines, and revoke +the token by accessor: + +```bash +OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-verify-token-grants-smoke +``` + ## Delivery Modes `exec-env` is the preferred local path. The helper obtains a lease, injects @@ -111,6 +146,34 @@ credential exec --grant ops-warden/warden-sign --ttl 15m -- \ SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh ``` +The source helper MVP lives at `scripts/credential.py` until this flow graduates +into a packaged command. It supports the same core shape: + +```bash +scripts/credential.py request --grant ops-warden/warden-sign --purpose flex-auth-openbao-smoke +scripts/credential.py exec --grant ops-warden/warden-sign --purpose flex-auth-openbao-smoke -- \ + SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh +scripts/credential.py status +scripts/credential.py revoke +``` + +`request` defaults to `local-token-file`: the raw child token is written only to +`.local/credential-leases/` with mode `0600`, and stdout contains the lease +handle/accessor plus metadata. `--delivery response-wrap` returns an OpenBao +wrapping token for attended handoff, not the raw child token. + +`exec` mints a bounded child token, injects it as `VAULT_TOKEN` only into the +child process environment, redacts token-looking output, and revokes the token +by accessor when the child exits. The helper rejects caller-supplied +`VAULT_TOKEN`/`BAO_TOKEN` env assignments and unsafe OpenBao debug/trace log +settings. + +Dry-run all helper paths with: + +```bash +make credential-helper-dry-run +``` + 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. @@ -122,11 +185,6 @@ prompt. 3. Build a small helper that supports `request`, `exec`, `status`, and `revoke`. 4. Add optional flex-auth preflight and State Hub request lifecycle metadata. 5. Update ops-warden routing so OpenBao token needs point here, while SSH certificate issuance remains in ops-warden. -token role configuration for each OpenBao-token grant. 3. Build a small helper -that supports `request`, `exec`, `status`, and `revoke`. 4. Add optional -flex-auth preflight and State Hub request lifecycle metadata. 5. Update -ops-warden routing so OpenBao token needs point here, while SSH certificate -issuance remains in ops-warden. Live token issuance requires an approved operator path to create or use the non-root issuer capability. Source-only validation and dry-run helper behavior diff --git a/openbao/policies/credential-broker-warden-sign-issuer.hcl b/openbao/policies/credential-broker-warden-sign-issuer.hcl new file mode 100644 index 0000000..d7e5f82 --- /dev/null +++ b/openbao/policies/credential-broker-warden-sign-issuer.hcl @@ -0,0 +1,23 @@ +# Narrow issuer policy for the credential broker warden-sign pilot. +# This policy can create child tokens only through the warden-sign token role. +# Bind it to a broker/operator issuer identity, not to tenant workloads. + +path "auth/token/create/warden-sign" { + 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"] +} \ No newline at end of file diff --git a/scripts/credential-grants-validate.py b/scripts/credential-grants-validate.py index 44f734c..8e79ccc 100755 --- a/scripts/credential-grants-validate.py +++ b/scripts/credential-grants-validate.py @@ -146,6 +146,9 @@ def validate_grant( require_nonempty_string( openbao.get("token_role"), f"{prefix}.openbao.token_role", errors ) + require_nonempty_string( + openbao.get("issuer_policy"), f"{prefix}.openbao.issuer_policy", errors + ) require_list(openbao.get("mount_paths"), f"{prefix}.openbao.mount_paths", errors) ttl = require_dict(grant_obj.get("ttl"), f"{prefix}.ttl", errors) diff --git a/scripts/credential.py b/scripts/credential.py new file mode 100755 index 0000000..4300181 --- /dev/null +++ b/scripts/credential.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import getpass +import hashlib +import json +import os +import re +import shlex +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import yaml + +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]+" +) +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"} + + +def emit_json(payload: dict[str, Any]) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def fail(message: str) -> None: + raise SystemExit(f"ERROR: {message}") + + +def redact(text: str, extra_secrets: list[str] | None = None) -> str: + redacted = TOKEN_MARKERS.sub("[REDACTED]", text) + for secret in extra_secrets or []: + if secret: + redacted = redacted.replace(secret, "[REDACTED]") + return redacted + + +def guard_verbose_env() -> None: + for name in ("BAO_LOG_LEVEL", "VAULT_LOG_LEVEL"): + value = os.environ.get(name, "").lower() + if value in UNSAFE_VERBOSE_VALUES: + fail( + f"refusing to run with {name}={value}; it may print secret-bearing traces" + ) + + +def read_issuer_token( + token_file: str | None, dry_run: bool, use_token_helper: bool +) -> str | None: + if dry_run or use_token_helper: + return None + if token_file: + path = Path(token_file).expanduser() + if not path.exists(): + fail(f"OPENBAO_TOKEN_FILE does not exist: {path}") + lines = path.read_text(encoding="utf-8").splitlines() + token = lines[0].strip() if lines else "" + if not token: + fail(f"OPENBAO_TOKEN_FILE is empty: {path}") + return token + token = getpass.getpass("OpenBao issuer token: ") + if not token: + fail("empty OpenBao issuer token") + return token + + +def ttl_seconds(value: str) -> int: + match = re.match(r"^([1-9][0-9]*)([smhd])$", value) + if not match: + fail(f"TTL must match : {value!r}") + amount = int(match.group(1)) + multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86400}[match.group(2)] + return amount * multiplier + + +def expires_at(ttl: str) -> str: + return ( + datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds(ttl)) + ).isoformat() + + +def resolve_repo_path(path: Path) -> Path: + path = path.expanduser() + if path.is_absolute(): + return path + return (REPO_DIR / path).resolve() + + +def load_catalog(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + fail(f"catalog root must be an object: {path}") + return data + + +def get_grant(catalog: dict[str, Any], grant_id: str) -> dict[str, Any]: + grants = catalog.get("grants") or [] + if not isinstance(grants, list): + fail("catalog grants must be a list") + for grant in grants: + if isinstance(grant, dict) and grant.get("id") == grant_id: + if grant.get("credential_type") != "openbao-token": + fail( + f"unsupported credential_type for {grant_id}: {grant.get('credential_type')}" + ) + return grant + fail(f"grant not found in catalog: {grant_id}") + + +def validate_issue_request( + grant: dict[str, Any], ttl: str, purpose: str, delivery: str | None = None +) -> None: + if not purpose.strip(): + fail("--purpose is required and must not be empty") + ttl_cfg = grant.get("ttl") or {} + max_ttl = ttl_cfg.get("max") + if not isinstance(max_ttl, str): + fail(f"grant {grant.get('id')} has no ttl.max") + if ttl_seconds(ttl) > ttl_seconds(max_ttl): + fail(f"requested TTL {ttl} exceeds grant max TTL {max_ttl}") + if delivery is not None: + allowed = set((grant.get("delivery") or {}).get("allowed") or []) + if delivery not in allowed: + fail( + f"delivery mode {delivery!r} is not allowed for grant {grant.get('id')}" + ) + + +def safe_handle(handle: str) -> str: + digest = hashlib.sha256(handle.encode("utf-8")).hexdigest()[:16] + cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", handle)[:64] + return f"{cleaned}-{digest}" + + +class BaoRunner: + def __init__( + self, + *, + kubectl: str, + namespace: str, + release: str, + dry_run: bool, + use_token_helper: bool, + issuer_token: str | None, + ) -> None: + self.kubectl_parts = shlex.split(kubectl) + self.namespace = namespace + self.pod = f"{release}-0" + self.dry_run = dry_run + self.use_token_helper = use_token_helper + self.issuer_token = issuer_token + + def run( + 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)) + return subprocess.CompletedProcess(args, 0, "", "") + + if self.use_token_helper: + cmd = ( + self.kubectl_parts + + ["exec", "-i", "-n", self.namespace, self.pod, "--", "bao"] + + args + ) + proc_input = input_text + else: + if not self.issuer_token: + raise RuntimeError( + "issuer token is required unless --use-token-helper is set" + ) + cmd = ( + self.kubectl_parts + + [ + "exec", + "-i", + "-n", + self.namespace, + self.pod, + "--", + "sh", + "-c", + 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"', + "sh", + ] + + args + ) + proc_input = self.issuer_token + "\n" + (input_text or "") + + result = subprocess.run( + cmd, input=proc_input, capture_output=True, text=True, check=False + ) + if result.returncode != 0: + if result.stdout and not quiet: + print(redact(result.stdout), end="") + if result.stderr: + print(redact(result.stderr), file=sys.stderr, end="") + raise SystemExit(result.returncode) + if result.stdout and not quiet: + print(redact(result.stdout), end="") + if result.stderr and not quiet: + print(redact(result.stderr), file=sys.stderr, end="") + return result + + +def token_create_args( + grant: dict[str, Any], ttl: str, wrap_ttl: str | None = None +) -> list[str]: + openbao = grant["openbao"] + 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", + ] + ) + for policy in openbao["policies"]: + args.append(f"-policy={policy}") + return args + + +def parse_token_create(stdout: str) -> tuple[str, str]: + try: + payload = json.loads(stdout) + auth = payload.get("auth") or payload.get("data") or {} + token = auth["client_token"] + accessor = auth["accessor"] + except Exception as exc: # noqa: BLE001 + raise SystemExit( + f"ERROR: could not parse token create response: {exc}" + ) from exc + return token, accessor + + +def parse_wrap_create(stdout: str) -> dict[str, Any]: + try: + payload = json.loads(stdout) + wrap_info = payload["wrap_info"] + except Exception as exc: # noqa: BLE001 + raise SystemExit( + f"ERROR: could not parse wrapped token response: {exc}" + ) from exc + return wrap_info + + +def write_mode_0600(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.parent.chmod(0o700) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + path.chmod(0o600) + + +def write_local_lease( + *, + lease_dir: Path, + grant: dict[str, Any], + purpose: str, + ttl: str, + token: str, + accessor: str, +) -> dict[str, Any]: + stem = safe_handle(accessor) + token_path = lease_dir / f"{stem}.openbao-token" + meta_path = lease_dir / f"{stem}.json" + write_mode_0600(token_path, token + "\n") + metadata = { + "grant_id": grant["id"], + "lease_handle": accessor, + "accessor": accessor, + "purpose": purpose, + "issued_at": datetime.now(timezone.utc).isoformat(), + "expires_at": expires_at(ttl), + "ttl": ttl, + "delivery_mode": "local-token-file", + "token_file": str(token_path), + "status": "issued", + } + write_mode_0600(meta_path, json.dumps(metadata, indent=2, sort_keys=True) + "\n") + return {key: value for key, value in metadata.items() if key != "token_file"} | { + "token_file": str(token_path), + "metadata_file": str(meta_path), + } + + +def find_local_metadata( + lease_dir: Path, handle: str +) -> tuple[Path, dict[str, Any]] | None: + if not lease_dir.exists(): + return None + for path in lease_dir.glob("*.json"): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + if data.get("lease_handle") == handle or data.get("accessor") == handle: + return path, data + return None + + +def path_is_within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except ValueError: + return False + + +def remove_local_lease_files(lease_dir: Path, handle: str) -> list[str]: + found = find_local_metadata(lease_dir, handle) + if not found: + return [] + meta_path, metadata = found + lease_root = lease_dir.resolve() + removed: list[str] = [] + for key in ("token_file",): + value = metadata.get(key) + if isinstance(value, str): + path = Path(value) + if not path.is_absolute(): + path = (REPO_DIR / path).resolve() + if path.exists() and path.is_file(): + if not path_is_within(path, lease_root): + fail( + f"refusing to remove local lease file outside {lease_root}: {path}" + ) + path.unlink() + removed.append(str(path)) + if meta_path.exists(): + if not path_is_within(meta_path, lease_root): + fail(f"refusing to remove metadata file outside {lease_root}: {meta_path}") + meta_path.unlink() + removed.append(str(meta_path)) + return removed + + +def split_env_prefix(command: list[str]) -> tuple[dict[str, str], list[str]]: + if command and command[0] == "--": + command = command[1:] + extra_env: dict[str, str] = {} + index = 0 + while index < len(command) and ENV_ASSIGNMENT.match(command[index]): + name, value = command[index].split("=", 1) + if name in UNSAFE_TOKEN_ENV: + fail( + f"refusing caller-supplied {name}; the helper owns credential injection" + ) + if TOKEN_MARKERS.search(value): + fail(f"refusing secret-looking value in env assignment {name}") + extra_env[name] = value + index += 1 + program = command[index:] + if not program: + fail("missing child command after --") + return extra_env, program + + +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) + if args.dry_run: + emit_json( + { + "status": "dry-run", + "command": "request", + "grant_id": grant["id"], + "purpose": args.purpose, + "ttl": ttl, + "delivery_mode": args.delivery, + } + ) + if args.delivery == "response-wrap": + print( + "DRY-RUN: bao " + + shlex.join(token_create_args(grant, ttl, args.wrap_ttl)) + ) + else: + print("DRY-RUN: bao " + shlex.join(token_create_args(grant, ttl))) + return 0 + + 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", + } + ) + return 0 + + result = runner.run(token_create_args(grant, ttl), quiet=True) + token, accessor = parse_token_create(result.stdout) + payload = write_local_lease( + lease_dir=args.lease_dir, + grant=grant, + purpose=args.purpose, + ttl=ttl, + token=token, + accessor=accessor, + ) + emit_json(payload) + return 0 + + +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") + extra_env, program = split_env_prefix(args.command) + if args.dry_run: + emit_json( + { + "status": "dry-run", + "command": "exec", + "grant_id": grant["id"], + "purpose": args.purpose, + "ttl": ttl, + "delivery_mode": "exec-env", + "child_env": sorted([*extra_env.keys(), "VAULT_TOKEN"]), + "child_command": program, + } + ) + print("DRY-RUN: bao " + shlex.join(token_create_args(grant, ttl))) + return 0 + + result = runner.run(token_create_args(grant, ttl), quiet=True) + token, accessor = parse_token_create(result.stdout) + env = os.environ.copy() + env.update(extra_env) + env["VAULT_TOKEN"] = token + try: + child = subprocess.run( + program, env=env, capture_output=True, text=True, check=False + ) + if child.stdout: + print(redact(child.stdout, [token]), end="") + if child.stderr: + print(redact(child.stderr, [token]), file=sys.stderr, end="") + return child.returncode + finally: + runner.run( + ["write", "auth/token/revoke-accessor", f"accessor={accessor}"], quiet=True + ) + + +def command_status(args: argparse.Namespace, runner: BaoRunner) -> int: + local = find_local_metadata(args.lease_dir, args.lease_handle) + if args.dry_run: + print( + "DRY-RUN: bao write -format=json auth/token/lookup-accessor accessor=" + ) + emit_json( + { + "status": "dry-run", + "lease_handle": args.lease_handle, + "local_metadata_found": local is not None, + } + ) + return 0 + result = runner.run( + [ + "write", + "-format=json", + "auth/token/lookup-accessor", + f"accessor={args.lease_handle}", + ], + quiet=True, + ) + payload = json.loads(result.stdout) + if local: + _, metadata = local + payload.setdefault("local", metadata | {"token_file": "[LOCAL-SECRET-FILE]"}) + print(redact(json.dumps(payload, indent=2, sort_keys=True))) + return 0 + + +def command_revoke(args: argparse.Namespace, runner: BaoRunner) -> int: + if args.dry_run: + print("DRY-RUN: bao write auth/token/revoke-accessor accessor=") + emit_json({"status": "dry-run", "lease_handle": args.lease_handle}) + return 0 + runner.run( + ["write", "auth/token/revoke-accessor", f"accessor={args.lease_handle}"], + quiet=True, + ) + removed = remove_local_lease_files(args.lease_dir, args.lease_handle) + emit_json( + { + "status": "revoked", + "lease_handle": args.lease_handle, + "removed_local_files": removed, + } + ) + return 0 + + +def add_common_issue_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--grant", default="ops-warden/warden-sign") + parser.add_argument("--purpose", required=True) + parser.add_argument("--ttl") + parser.add_argument("--dry-run", action="store_true") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Request, use, inspect, and revoke bounded OpenBao credential leases." + ) + parser.add_argument("--catalog", default=str(DEFAULT_CATALOG)) + parser.add_argument( + "--namespace", default=os.environ.get("OPENBAO_NAMESPACE", "openbao") + ) + parser.add_argument( + "--release", default=os.environ.get("OPENBAO_RELEASE", "openbao") + ) + parser.add_argument("--kubectl", default=os.environ.get("KUBECTL", "kubectl")) + parser.add_argument( + "--issuer-token-file", default=os.environ.get("OPENBAO_TOKEN_FILE") + ) + parser.add_argument( + "--use-token-helper", + action="store_true", + help="Use the OpenBao CLI token helper inside the pod", + ) + parser.add_argument("--lease-dir", type=Path, default=DEFAULT_LEASE_DIR) + + subparsers = parser.add_subparsers(dest="command_name", required=True) + + request = subparsers.add_parser( + "request", help="Issue a wrapped token or local-token-file lease" + ) + add_common_issue_args(request) + request.add_argument( + "--delivery", + choices=["local-token-file", "response-wrap"], + default="local-token-file", + ) + request.add_argument("--wrap-ttl", default="5m") + + exec_parser = subparsers.add_parser( + "exec", help="Run a child process with VAULT_TOKEN injected" + ) + add_common_issue_args(exec_parser) + exec_parser.add_argument("command", nargs=argparse.REMAINDER) + + status = subparsers.add_parser( + "status", help="Look up a lease by non-secret accessor handle" + ) + status.add_argument("--dry-run", action="store_true") + status.add_argument("lease_handle") + + revoke = subparsers.add_parser( + "revoke", help="Revoke a lease by non-secret accessor handle" + ) + revoke.add_argument("--dry-run", action="store_true") + revoke.add_argument("lease_handle") + + return parser + + +def main() -> int: + guard_verbose_env() + parser = build_parser() + args = parser.parse_args() + args.lease_dir = resolve_repo_path(args.lease_dir) + catalog = load_catalog(resolve_repo_path(Path(args.catalog))) + grant = None + if args.command_name in {"request", "exec"}: + grant = get_grant(catalog, args.grant) + issuer_token = read_issuer_token( + args.issuer_token_file, args.dry_run, args.use_token_helper + ) + runner = BaoRunner( + kubectl=args.kubectl, + namespace=args.namespace, + release=args.release, + dry_run=args.dry_run, + use_token_helper=args.use_token_helper, + issuer_token=issuer_token, + ) + + if args.command_name == "request": + assert grant is not None + return command_request(args, runner, grant) + if args.command_name == "exec": + assert grant is not None + return command_exec(args, runner, grant) + if args.command_name == "status": + return command_status(args, runner) + if args.command_name == "revoke": + return command_revoke(args, runner) + parser.error(f"unknown command: {args.command_name}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/openbao-apply-token-grants.py b/scripts/openbao-apply-token-grants.py new file mode 100755 index 0000000..d30d315 --- /dev/null +++ b/scripts/openbao-apply-token-grants.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import getpass +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any + +import yaml + +REPO_DIR = Path(__file__).resolve().parents[1] + + +class BaoRunner: + def __init__( + self, + *, + kubectl: str, + namespace: str, + release: str, + dry_run: bool, + use_token_helper: bool, + token: str | None, + ) -> None: + self.kubectl_parts = shlex.split(kubectl) + self.namespace = namespace + self.pod = f"{release}-0" + self.dry_run = dry_run + self.use_token_helper = use_token_helper + self.token = token + + def run( + self, args: list[str], input_text: str | None = None + ) -> subprocess.CompletedProcess[str]: + rendered = "bao " + shlex.join(args) + if self.dry_run: + print(f"DRY-RUN: {rendered}") + return subprocess.CompletedProcess(args, 0, "", "") + + if self.use_token_helper: + cmd = ( + self.kubectl_parts + + ["exec", "-i", "-n", self.namespace, self.pod, "--", "bao"] + + args + ) + proc_input = input_text + else: + if not self.token: + raise RuntimeError( + "OpenBao token is required unless --use-token-helper is set" + ) + cmd = ( + self.kubectl_parts + + [ + "exec", + "-i", + "-n", + self.namespace, + self.pod, + "--", + "sh", + "-c", + 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"', + "sh", + ] + + args + ) + proc_input = self.token + "\n" + (input_text or "") + + result = subprocess.run(cmd, input=proc_input, capture_output=True, text=True) + if result.stdout: + print(result.stdout, end="") + if result.returncode != 0: + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + raise SystemExit(result.returncode) + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + return result + + +def read_token( + token_file: str | None, dry_run: bool, use_token_helper: bool +) -> str | None: + if dry_run or use_token_helper: + return None + if token_file: + path = Path(token_file) + if not path.exists(): + raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE does not exist: {path}") + lines = path.read_text(encoding="utf-8").splitlines() + token = lines[0].strip() if lines else "" + if not token: + raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE is empty: {path}") + return token + token = getpass.getpass("OpenBao token: ") + if not token: + raise SystemExit("ERROR: empty OpenBao token") + return token + + +def load_catalog(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if not isinstance(data, dict): + raise SystemExit(f"ERROR: catalog root must be an object: {path}") + return data + + +def selected_grants( + catalog: dict[str, Any], grant_id: str | None +) -> list[dict[str, Any]]: + grants = catalog.get("grants") or [] + if not isinstance(grants, list): + raise SystemExit("ERROR: catalog grants must be a list") + selected = [ + grant for grant in grants if not grant_id or grant.get("id") == grant_id + ] + if grant_id and not selected: + raise SystemExit(f"ERROR: grant not found in catalog: {grant_id}") + return selected + + +def role_args(grant: dict[str, Any]) -> list[str]: + openbao = grant["openbao"] + ttl = grant["ttl"] + policies = ",".join(openbao["policies"]) + disallowed = ",".join(openbao.get("disallowed_policies") or []) + args = [ + "write", + f"auth/token/roles/{openbao['token_role']}", + f"allowed_policies={policies}", + f"disallowed_policies={disallowed}", + "orphan=true", + "renewable=false", + f"token_explicit_max_ttl={ttl['max']}", + "token_no_default_policy=true", + "token_type=service", + ] + return args + + +def write_policy(runner: BaoRunner, name: str, policy_file: Path) -> None: + if not policy_file.exists(): + raise SystemExit(f"ERROR: missing policy file: {policy_file}") + if runner.dry_run: + print(f"DRY-RUN: bao policy write {name} {policy_file}") + return + runner.run( + ["policy", "write", name, "-"], + input_text=policy_file.read_text(encoding="utf-8"), + ) + print(f"OK: policy {name} applied") + + +def apply_grant(runner: BaoRunner, grant: dict[str, Any], policy_dir: Path) -> None: + if grant.get("credential_type") != "openbao-token": + print(f"SKIP: {grant.get('id')} is not an openbao-token grant") + return + openbao = grant["openbao"] + issuer_policy = openbao["issuer_policy"] + policy_file = policy_dir / f"{issuer_policy}.hcl" + write_policy(runner, issuer_policy, policy_file) + runner.run(role_args(grant)) + runner.run(["read", f"auth/token/roles/{openbao['token_role']}"]) + print(f"OK: token role {openbao['token_role']} configured for grant {grant['id']}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Apply OpenBao token roles for credential grants." + ) + parser.add_argument("--catalog", default="credential-grants/catalog.yaml") + parser.add_argument("--policy-dir", default="openbao/policies") + parser.add_argument("--grant", help="Limit to one grant id") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument( + "--use-token-helper", + action="store_true", + help="Use the OpenBao CLI token helper inside the pod", + ) + parser.add_argument("--namespace", default=None) + parser.add_argument("--release", default=None) + parser.add_argument("--kubectl", default=None) + args = parser.parse_args() + + import os + + namespace = args.namespace or os.environ.get("OPENBAO_NAMESPACE", "openbao") + release = args.release or os.environ.get("OPENBAO_RELEASE", "openbao") + kubectl = args.kubectl or os.environ.get("KUBECTL", "kubectl") + token_file = os.environ.get("OPENBAO_TOKEN_FILE") + token = read_token(token_file, args.dry_run, args.use_token_helper) + + catalog = load_catalog(REPO_DIR / args.catalog) + runner = BaoRunner( + kubectl=kubectl, + namespace=namespace, + release=release, + dry_run=args.dry_run, + use_token_helper=args.use_token_helper, + token=token, + ) + + if not args.dry_run: + runner.run(["status"]) + + policy_dir = REPO_DIR / args.policy_dir + for grant in selected_grants(catalog, args.grant): + apply_grant(runner, grant, policy_dir) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/openbao-verify-token-grants.py b/scripts/openbao-verify-token-grants.py new file mode 100755 index 0000000..b0626fb --- /dev/null +++ b/scripts/openbao-verify-token-grants.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import getpass +import json +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any + +import yaml + +REPO_DIR = Path(__file__).resolve().parents[1] + + +class BaoRunner: + def __init__( + self, + *, + kubectl: str, + namespace: str, + release: str, + dry_run: bool, + use_token_helper: bool, + token: str | None, + ) -> None: + self.kubectl_parts = shlex.split(kubectl) + self.namespace = namespace + self.pod = f"{release}-0" + self.dry_run = dry_run + self.use_token_helper = use_token_helper + self.token = token + + def run( + self, + args: list[str], + *, + input_text: str | None = None, + check: bool = True, + quiet: bool = False, + ) -> subprocess.CompletedProcess[str]: + if self.dry_run: + print("DRY-RUN: bao " + shlex.join(args)) + return subprocess.CompletedProcess(args, 0, "", "") + + if self.use_token_helper: + cmd = ( + self.kubectl_parts + + ["exec", "-i", "-n", self.namespace, self.pod, "--", "bao"] + + args + ) + proc_input = input_text + else: + if not self.token: + raise RuntimeError( + "OpenBao token is required unless --use-token-helper is set" + ) + cmd = ( + self.kubectl_parts + + [ + "exec", + "-i", + "-n", + self.namespace, + self.pod, + "--", + "sh", + "-c", + 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"', + "sh", + ] + + args + ) + proc_input = self.token + "\n" + (input_text or "") + + result = subprocess.run(cmd, input=proc_input, capture_output=True, text=True) + if check and result.returncode != 0: + if result.stdout and not quiet: + print(result.stdout, end="") + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + raise SystemExit(result.returncode) + if not quiet and result.stdout: + print(result.stdout, end="") + if not quiet and result.stderr: + print(result.stderr, file=sys.stderr, end="") + return result + + +def run_with_token( + *, + kubectl: str, + namespace: str, + release: str, + token: str, + args: list[str], + check: bool, +) -> subprocess.CompletedProcess[str]: + kubectl_parts = shlex.split(kubectl) + cmd = ( + kubectl_parts + + [ + "exec", + "-i", + "-n", + namespace, + f"{release}-0", + "--", + "sh", + "-c", + 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"', + "sh", + ] + + args + ) + return subprocess.run( + cmd, input=token + "\n", capture_output=True, text=True, check=False + ) + + +def read_token( + token_file: str | None, dry_run: bool, use_token_helper: bool +) -> str | None: + if dry_run or use_token_helper: + return None + if token_file: + path = Path(token_file) + if not path.exists(): + raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE does not exist: {path}") + lines = path.read_text(encoding="utf-8").splitlines() + token = lines[0].strip() if lines else "" + if not token: + raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE is empty: {path}") + return token + token = getpass.getpass("OpenBao token: ") + if not token: + raise SystemExit("ERROR: empty OpenBao token") + return token + + +def load_catalog(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if not isinstance(data, dict): + raise SystemExit(f"ERROR: catalog root must be an object: {path}") + return data + + +def selected_grants( + catalog: dict[str, Any], grant_id: str | None +) -> list[dict[str, Any]]: + grants = catalog.get("grants") or [] + if not isinstance(grants, list): + raise SystemExit("ERROR: catalog grants must be a list") + selected = [ + grant for grant in grants if not grant_id or grant.get("id") == grant_id + ] + if grant_id and not selected: + raise SystemExit(f"ERROR: grant not found in catalog: {grant_id}") + return selected + + +def verify_static(runner: BaoRunner, grant: dict[str, Any]) -> None: + openbao = grant["openbao"] + runner.run(["read", f"auth/token/roles/{openbao['token_role']}"]) + runner.run(["policy", "read", openbao["issuer_policy"]]) + runner.run(["policy", "read", openbao["policies"][0]]) + print(f"OK: static token-grant config readable for {grant['id']}") + + +def issue_smoke_token( + runner: BaoRunner, + *, + kubectl: str, + namespace: str, + release: str, + grant: dict[str, Any], +) -> None: + openbao = grant["openbao"] + ttl = grant["ttl"]["default"] + policies = openbao["policies"] + result = runner.run( + [ + "token", + "create", + f"-role={openbao['token_role']}", + f"-policy={policies[0]}", + f"-ttl={ttl}", + "-format=json", + ], + quiet=True, + ) + try: + payload = json.loads(result.stdout) + auth = payload.get("auth") or payload.get("data") or {} + child_token = auth["client_token"] + accessor = auth["accessor"] + except Exception as exc: # noqa: BLE001 + raise SystemExit( + f"ERROR: could not parse token create response: {exc}" + ) from exc + + try: + positive = run_with_token( + kubectl=kubectl, + namespace=namespace, + release=release, + token=child_token, + args=["list", "ssh/roles"], + check=False, + ) + if positive.returncode != 0: + raise SystemExit( + "ERROR: child token could not list ssh/roles with warden-sign policy" + ) + print("OK: child token can list ssh/roles") + + negative = run_with_token( + kubectl=kubectl, + namespace=namespace, + release=release, + token=child_token, + args=["secrets", "list"], + check=False, + ) + if negative.returncode == 0: + raise SystemExit("ERROR: child token unexpectedly listed secret engines") + print("OK: child token cannot list secret engines") + finally: + runner.run(["token", "revoke-accessor", accessor], quiet=True) + print("OK: smoke child token revoked by accessor") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify OpenBao token grants.") + parser.add_argument("--catalog", default="credential-grants/catalog.yaml") + parser.add_argument("--grant", help="Limit to one grant id") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument( + "--use-token-helper", + action="store_true", + help="Use the OpenBao CLI token helper inside the pod", + ) + parser.add_argument( + "--issue-smoke-token", + action="store_true", + help="Mint and revoke a short-lived child token for live verification", + ) + parser.add_argument("--namespace", default=None) + parser.add_argument("--release", default=None) + parser.add_argument("--kubectl", default=None) + args = parser.parse_args() + + import os + + namespace = args.namespace or os.environ.get("OPENBAO_NAMESPACE", "openbao") + release = args.release or os.environ.get("OPENBAO_RELEASE", "openbao") + kubectl = args.kubectl or os.environ.get("KUBECTL", "kubectl") + token_file = os.environ.get("OPENBAO_TOKEN_FILE") + token = read_token(token_file, args.dry_run, args.use_token_helper) + catalog = load_catalog(REPO_DIR / args.catalog) + runner = BaoRunner( + kubectl=kubectl, + namespace=namespace, + release=release, + dry_run=args.dry_run, + use_token_helper=args.use_token_helper, + token=token, + ) + + for grant in selected_grants(catalog, args.grant): + verify_static(runner, grant) + if args.issue_smoke_token: + if args.dry_run: + print( + f"DRY-RUN: would mint and revoke smoke child token for {grant['id']}" + ) + else: + issue_smoke_token( + runner, + kubectl=kubectl, + namespace=namespace, + release=release, + grant=grant, + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(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 35a38f4..0b38a58 100644 --- a/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md +++ b/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md @@ -10,7 +10,7 @@ topic_slug: railiance planning_priority: high planning_order: 5 created: "2026-06-24" -updated: "2026-06-25" +updated: "2026-06-26" 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: todo +status: progress priority: high state_hub_task_id: "d8498e3b-b2fb-47b7-ab88-cd6592c1807e" ``` @@ -167,11 +167,20 @@ Acceptance: - The resulting token cannot administer OpenBao and can only call the SSH sign paths allowed by openbao/policies/warden-sign.hcl. - Verification proves the token can run ops-warden vault signing and cannot list unrelated secrets. +**2026-06-26:** Added the source-side OpenBao token-grant implementation for +the `ops-warden/warden-sign` pilot: issuer policy +`openbao/policies/credential-broker-warden-sign-issuer.hcl`, idempotent apply +and verify scripts, Make targets for dry-run/live apply/live verification, and +catalog validation for `openbao.issuer_policy`. Dry-run validation is expected +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`. + ## T04 - Build credential helper MVP ```task id: RAILIANCE-WP-0005-T04 -status: todo +status: progress priority: high state_hub_task_id: "0c543cb3-36cb-4b25-9a58-de8efc1216c9" ``` @@ -186,6 +195,16 @@ Acceptance: - status and revoke work by non-secret lease handle/accessor. - The helper redacts token-looking values from logs and refuses to run in verbose modes that would print secrets. +**2026-06-26:** Added `scripts/credential.py` as the source helper MVP with +`request`, `exec`, `status`, and `revoke` subcommands. The helper validates the +grant catalog, enforces purpose and TTL bounds, defaults `request` to a local +mode-0600 token file plus non-secret accessor metadata, supports response-wrap +handoff, injects `VAULT_TOKEN` only into the child process for `exec`, redacts +token-looking child output, rejects caller-supplied token env assignments, and +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. + ## T05 - Implement secure delivery modes ```task