feat: complete credential broker source flow

This commit is contained in:
2026-06-27 00:29:53 +02:00
parent 2268a9375e
commit 673ec46e25
7 changed files with 853 additions and 52 deletions

View File

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

View File

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

View File

@@ -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:<local-user>`;
- `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:<namespace>:<service-account>`. 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 <lease-accessor>
```
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.

View File

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

View File

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

View File

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

View File

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