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

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