feat: add credential broker token helper
This commit is contained in:
42
Makefile
42
Makefile
@@ -24,6 +24,9 @@ ARGOCD_NAMESPACE ?= argocd
|
||||
ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
|
||||
ARGOCD_REPOSITORY_SECRET ?=
|
||||
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
|
||||
OPENBAO_TOKEN_GRANT_ARGS ?=
|
||||
CREDENTIAL_HELPER_ARGS ?=
|
||||
CREDENTIAL_HELPER_PURPOSE ?= flex-auth-openbao-smoke
|
||||
|
||||
##@ CloudNative PG (cnpg) — primary database operator
|
||||
|
||||
@@ -177,6 +180,43 @@ openbao-validate-emergency-evidence: ## Validate non-secret OpenBao emergency se
|
||||
credential-grants-validate: ## Validate non-secret credential grant catalog
|
||||
scripts/credential-grants-validate.py $(CREDENTIAL_GRANTS)
|
||||
|
||||
openbao-token-grants-dry-run: ## Dry-run OpenBao token roles and issuer policies for credential grants
|
||||
scripts/openbao-apply-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-configure-token-grants: ## Apply OpenBao token roles and issuer policies for credential grants
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-apply-token-grants.py $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-verify-token-grants-dry-run: ## Dry-run OpenBao token grant verification
|
||||
scripts/openbao-verify-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-verify-token-grants: ## Verify OpenBao token roles and issuer policies for credential grants
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-verify-token-grants.py $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-verify-token-grants-smoke: ## Mint/revoke a child token and prove bounded warden-sign capabilities
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-verify-token-grants.py --issue-smoke-token $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
credential-helper-dry-run: ## Dry-run credential request, exec, status, and revoke helper flows
|
||||
scripts/credential.py request --dry-run --grant ops-warden/warden-sign \
|
||||
--purpose $(CREDENTIAL_HELPER_PURPOSE) $(CREDENTIAL_HELPER_ARGS)
|
||||
scripts/credential.py exec --dry-run --grant ops-warden/warden-sign \
|
||||
--purpose $(CREDENTIAL_HELPER_PURPOSE) $(CREDENTIAL_HELPER_ARGS) -- \
|
||||
SMOKE_VAULT=1 /bin/true
|
||||
scripts/credential.py status --dry-run example-accessor
|
||||
scripts/credential.py revoke --dry-run example-accessor
|
||||
|
||||
credential-exec-ops-warden-smoke: ## Run ops-warden smoke with an exec-injected warden-sign token
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/credential.py exec --grant ops-warden/warden-sign \
|
||||
--purpose ops-warden-production-sign-smoke $(CREDENTIAL_HELPER_ARGS) -- \
|
||||
SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh
|
||||
|
||||
##@ ArgoCD GitOps bootstrap
|
||||
|
||||
argocd-bootstrap-dry-run: ## Server-side dry-run ArgoCD AppProjects and root Application
|
||||
@@ -210,4 +250,4 @@ help: ## Show this help
|
||||
/^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \
|
||||
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)
|
||||
|
||||
.PHONY: db-deploy db-status db-shell db-logs apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help
|
||||
.PHONY: db-deploy db-status db-shell db-logs apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
23
openbao/policies/credential-broker-warden-sign-issuer.hcl
Normal file
23
openbao/policies/credential-broker-warden-sign-issuer.hcl
Normal 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"]
|
||||
}
|
||||
@@ -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
626
scripts/credential.py
Executable 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())
|
||||
219
scripts/openbao-apply-token-grants.py
Executable file
219
scripts/openbao-apply-token-grants.py
Executable 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())
|
||||
293
scripts/openbao-verify-token-grants.py
Executable file
293
scripts/openbao-verify-token-grants.py
Executable 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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user