feat: add credential broker token helper
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user