feat: add credential broker token helper

This commit is contained in:
2026-06-27 00:06:03 +02:00
parent 6e663dfd20
commit 752cfd6f00
9 changed files with 1292 additions and 10 deletions

View File

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

View File

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

View File

@@ -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/<role>` 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 <lease-accessor>
scripts/credential.py revoke <lease-accessor>
```
`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

View File

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

View File

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

626
scripts/credential.py Executable file
View File

@@ -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 <positive integer><s|m|h|d>: {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=<lease_handle>"
)
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=<lease_handle>")
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())

View File

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

View File

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

View File

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