feat: complete credential broker source flow
This commit is contained in:
29
Makefile
29
Makefile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
130
tests/test_credential_helper.py
Normal file
130
tests/test_credential_helper.py
Normal 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()
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user