feat: add credential grant catalog foundation

This commit is contained in:
2026-06-26 17:49:40 +02:00
parent 693dc71833
commit c7393d94ab
6 changed files with 617 additions and 5 deletions

4
.gitignore vendored
View File

@@ -16,3 +16,7 @@ argocd/repositories/*.repository.sops.yaml
# Kubeconfig
*.kubeconfig
# Credential broker local lease/token material
.local/credential-leases/
*.openbao-token

View File

@@ -23,6 +23,7 @@ EXTERNAL_SECRETS_NAMESPACE ?= external-secrets
ARGOCD_NAMESPACE ?= argocd
ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
ARGOCD_REPOSITORY_SECRET ?=
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
##@ CloudNative PG (cnpg) — primary database operator
@@ -171,6 +172,11 @@ openbao-validate-emergency-evidence: ## Validate non-secret OpenBao emergency se
OPENBAO_EMERGENCY_EVIDENCE='$(OPENBAO_EMERGENCY_EVIDENCE)' \
scripts/openbao-validate-emergency-drill-evidence.sh
##@ Credential broker
credential-grants-validate: ## Validate non-secret credential grant catalog
scripts/credential-grants-validate.py $(CREDENTIAL_GRANTS)
##@ ArgoCD GitOps bootstrap
argocd-bootstrap-dry-run: ## Server-side dry-run ArgoCD AppProjects and root Application
@@ -204,4 +210,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 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 argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help

View File

@@ -0,0 +1,111 @@
version: 1
updated: "2026-06-25"
owner_repo: railiance-platform
owner_domain: financials
workplan_id: RAILIANCE-WP-0005
state_hub_workstream_id: 2731fece-6c49-45b8-ab8a-4ea6c04ac603
delivery_modes:
allowed_known:
- exec-env
- response-wrap
- local-token-file
- kubernetes-auth
denied_known:
- chat
- state-hub-body
- git
- command-line-token-argument
- llm-prompt
grant_classes:
- self-service
- approval-required
- break-glass
grants:
- id: ops-warden/warden-sign
title: Ops Warden OpenBao SSH signing smoke token
status: pilot
grant_class: self-service
credential_type: openbao-token
issuer: openbao
audience: ops-warden
description: >
Short-lived OpenBao child token for ops-warden SSH signing smoke tests.
The token may only use the warden-sign policy and must not be treated as
an ops-warden-owned secret.
openbao:
namespace: openbao
token_role: warden-sign
policies:
- warden-sign
disallowed_policies:
- root
- platform-admin
mount_paths:
- ssh/sign/adm-role
- ssh/sign/agt-role
- ssh/sign/atm-role
- ssh/roles
ttl:
default: 15m
max: 1h
renewable: false
requires_human_above: 1h
actors:
allowed_types:
- human-operator
- approved-agent
- ci-runner
required_subject_binding: keycape-or-kubernetes-service-account
authorization:
flex_auth_required: false
flex_auth_mode: optional-preflight
approval_required: false
purpose_required: true
allowed_purpose_examples:
- flex-auth-openbao-smoke
- ops-warden-production-sign-smoke
delivery:
allowed:
- exec-env
- response-wrap
- local-token-file
preferred: exec-env
denied:
- chat
- state-hub-body
- git
- command-line-token-argument
- llm-prompt
exec_env:
variable: VAULT_TOKEN
child_only: true
redact_logs: true
response_wrap:
ttl: 5m
unwrap_once: true
local_token_file:
directory: .local/credential-leases
mode: "0600"
audit:
openbao_audit_required: true
state_hub_metadata_allowed: true
record_secret_values: false
metadata_fields:
- grant_id
- actor
- subject
- purpose
- requested_ttl
- issued_ttl
- delivery_mode
- lease_accessor
- decision_id
- status
revocation:
required: true
by_accessor: true
on_exec_exit: true
on_denied_request: false

133
docs/credential-broker.md Normal file
View File

@@ -0,0 +1,133 @@
# Credential Request And Lease Broker
**Workplan:** `RAILIANCE-WP-0005`
**Owner:** `railiance-platform`
**Status:** source implementation started
This document records the Railiance credential broker ownership decision and
the first implementation contract for short-lived OpenBao credential leases.
## Decision
`railiance-platform` owns OpenBao credential request, generation, delivery,
audit, and revocation because this repo owns the platform secrets service and
the OpenBao policy surface. The broker may later split into a dedicated
service repo if the implementation grows, but the grant catalog and OpenBao
policy contracts remain platform-owned.
The broker is not a new secret store. It is a controlled request path for
bounded credentials that already belong to OpenBao or adjacent platform
authorities.
## Boundaries
| Concern | Owner | Boundary |
| --- | --- | --- |
| OpenBao mounts, policies, token roles, response wrapping, audit | `railiance-platform` | Generates and revokes bounded credentials. |
| Human login, OIDC, MFA, IAM profile claims | `key-cape` | Authenticates human and service identities. |
| Authorization decision | `flex-auth` | Decides whether an actor may request a grant for a purpose, TTL, audience, and delivery mode. |
| SSH certificate signing | `ops-warden` | Issues SSH certificates only. It does not vend OpenBao tokens, API keys, or provider secrets. |
| Request tracking | State Hub | Stores non-secret metadata only: request ids, actor, grant, purpose, TTL, decision id, lease accessor, status, timestamps, and audit pointers. |
| Agent/runtime consumption | `llm-connect` and callers | Never place secrets in prompts. Consume credentials through local exec injection, response wrapping, service-account auth, or approved local files. |
## Non-Secret Metadata Only
State Hub, workplans, docs, Git, chat, and prompts may contain:
- grant ids such as `ops-warden/warden-sign`;
- requested TTL and bounded max TTL;
- actor and subject ids;
- purpose strings;
- lease handles or accessors when they are not sufficient to use the secret;
- OpenBao audit request ids or timestamps;
- status values such as requested, issued, denied, revoked, or expired.
They must not contain:
- OpenBao root tokens, platform-admin tokens, or wrapped token values;
- unseal shares, recovery codes, private keys, OTP seeds, passwords, or API keys;
- raw bearer tokens in command lines, prompt text, State Hub bodies, or logs;
- screenshots or pasted command output containing secret values.
## Grant Catalog
The catalog lives at:
```text
credential-grants/catalog.yaml
```
Validate it with:
```bash
make credential-grants-validate
```
Every grant entry defines:
- a stable grant id;
- credential type and OpenBao policy set;
- grant class: `self-service`, `approval-required`, or `break-glass`;
- default and max TTL;
- allowed actor types and purpose examples;
- allowed and denied delivery modes;
- audit and revocation expectations.
The first pilot grant is `ops-warden/warden-sign`, which creates a short-lived
OpenBao token with only the `warden-sign` policy.
## Delivery Modes
`exec-env` is the preferred local path. The helper obtains a lease, injects
the credential only into a child process environment, redacts output, and then
revokes or lets the credential expire.
`response-wrap` is for attended handoff. The broker returns a single-use
OpenBao wrapping token instead of the raw credential. The recipient unwraps it
once; a second unwrap must fail.
`local-token-file` is for tools that cannot consume environment variables
cleanly. Files must be mode `0600`, stored under `.local/credential-leases/`,
and removed when the lease is revoked or expires. That directory is ignored by
Git.
`kubernetes-auth` is for in-cluster workloads. Workloads should authenticate
with service-account-bound auth instead of receiving manually handed tokens.
The denied modes are absolute unless a later ADR updates the catalog:
- `chat`
- `state-hub-body`
- `git`
- `command-line-token-argument`
- `llm-prompt`
## Pilot Flow
The target ops-warden smoke path is:
```bash
credential exec --grant ops-warden/warden-sign --ttl 15m -- \
SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh
```
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.
## Implementation Sequence
1. Validate and maintain the non-secret grant catalog.
2. Add bounded OpenBao 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.
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
must remain useful without a live token.

View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Any
import yaml
SECRET_MARKERS = [
"AGE-SECRET-KEY-1",
"-----BEGIN PRIVATE KEY-----",
"-----BEGIN OPENSSH PRIVATE KEY-----",
"OPENBAO_ROOT_TOKEN=",
"VAULT_TOKEN=",
"BAO_TOKEN=",
"hvs.",
"hvb.",
"hvc.",
]
REQUIRED_DENIED_MODES = {
"chat",
"state-hub-body",
"git",
"command-line-token-argument",
"llm-prompt",
}
ALLOWED_CREDENTIAL_TYPES = {"openbao-token"}
ALLOWED_GRANT_CLASSES = {"self-service", "approval-required", "break-glass"}
ALLOWED_GRANT_STATUSES = {"pilot", "active", "deprecated", "disabled"}
DISALLOWED_POLICIES = {"root", "platform-admin"}
TTL_RE = re.compile(r"^([1-9][0-9]*)([smhd])$")
def fail(message: str) -> None:
print(f"[FAIL] {message}", file=sys.stderr)
def ttl_seconds(value: Any, field: str, errors: list[str]) -> int | None:
if not isinstance(value, str):
errors.append(f"{field} must be a string TTL such as 15m")
return None
match = TTL_RE.match(value)
if not match:
errors.append(f"{field} must match <positive integer><s|m|h|d>: {value!r}")
return None
amount = int(match.group(1))
unit = match.group(2)
multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
return amount * multiplier
def require_dict(value: Any, field: str, errors: list[str]) -> dict[str, Any]:
if not isinstance(value, dict):
errors.append(f"{field} must be an object")
return {}
return value
def require_list(value: Any, field: str, errors: list[str]) -> list[Any]:
if not isinstance(value, list):
errors.append(f"{field} must be a list")
return []
return value
def require_nonempty_string(value: Any, field: str, errors: list[str]) -> str:
if not isinstance(value, str) or not value.strip():
errors.append(f"{field} must be a non-empty string")
return ""
return value.strip()
def validate_grant(
grant: Any, index: int, catalog: dict[str, Any], errors: list[str]
) -> str:
prefix = f"grants[{index}]"
grant_obj = require_dict(grant, prefix, errors)
if not grant_obj:
return ""
grant_id = require_nonempty_string(grant_obj.get("id"), f"{prefix}.id", errors)
require_nonempty_string(grant_obj.get("title"), f"{prefix}.title", errors)
require_nonempty_string(
grant_obj.get("description"), f"{prefix}.description", errors
)
status = require_nonempty_string(
grant_obj.get("status"), f"{prefix}.status", errors
)
if status and status not in ALLOWED_GRANT_STATUSES:
errors.append(
f"{prefix}.status must be one of {sorted(ALLOWED_GRANT_STATUSES)}"
)
grant_class = require_nonempty_string(
grant_obj.get("grant_class"), f"{prefix}.grant_class", errors
)
if grant_class and grant_class not in ALLOWED_GRANT_CLASSES:
errors.append(
f"{prefix}.grant_class must be one of {sorted(ALLOWED_GRANT_CLASSES)}"
)
credential_type = require_nonempty_string(
grant_obj.get("credential_type"), f"{prefix}.credential_type", errors
)
if credential_type and credential_type not in ALLOWED_CREDENTIAL_TYPES:
errors.append(
f"{prefix}.credential_type must be one of {sorted(ALLOWED_CREDENTIAL_TYPES)}"
)
require_nonempty_string(grant_obj.get("issuer"), f"{prefix}.issuer", errors)
require_nonempty_string(grant_obj.get("audience"), f"{prefix}.audience", errors)
openbao = require_dict(grant_obj.get("openbao"), f"{prefix}.openbao", errors)
policies = [
str(policy)
for policy in require_list(
openbao.get("policies"), f"{prefix}.openbao.policies", errors
)
]
if not policies:
errors.append(f"{prefix}.openbao.policies must contain at least one policy")
for policy in policies:
if not policy or policy in DISALLOWED_POLICIES:
errors.append(
f"{prefix}.openbao.policies contains disallowed policy: {policy!r}"
)
configured_disallowed = set(
str(policy)
for policy in require_list(
openbao.get("disallowed_policies"),
f"{prefix}.openbao.disallowed_policies",
errors,
)
)
missing_disallowed = DISALLOWED_POLICIES - configured_disallowed
if missing_disallowed:
errors.append(
f"{prefix}.openbao.disallowed_policies missing {sorted(missing_disallowed)}"
)
require_nonempty_string(
openbao.get("token_role"), f"{prefix}.openbao.token_role", errors
)
require_list(openbao.get("mount_paths"), f"{prefix}.openbao.mount_paths", errors)
ttl = require_dict(grant_obj.get("ttl"), f"{prefix}.ttl", errors)
default_ttl = ttl_seconds(ttl.get("default"), f"{prefix}.ttl.default", errors)
max_ttl = ttl_seconds(ttl.get("max"), f"{prefix}.ttl.max", errors)
if default_ttl is not None and max_ttl is not None and default_ttl > max_ttl:
errors.append(f"{prefix}.ttl.default must not exceed ttl.max")
if ttl.get("renewable") is not False:
errors.append(f"{prefix}.ttl.renewable must be false for the pilot")
actors = require_dict(grant_obj.get("actors"), f"{prefix}.actors", errors)
allowed_actor_types = require_list(
actors.get("allowed_types"), f"{prefix}.actors.allowed_types", errors
)
if not allowed_actor_types:
errors.append(f"{prefix}.actors.allowed_types must not be empty")
require_nonempty_string(
actors.get("required_subject_binding"),
f"{prefix}.actors.required_subject_binding",
errors,
)
authorization = require_dict(
grant_obj.get("authorization"), f"{prefix}.authorization", errors
)
if authorization.get("purpose_required") is not True:
errors.append(f"{prefix}.authorization.purpose_required must be true")
require_list(
authorization.get("allowed_purpose_examples"),
f"{prefix}.authorization.allowed_purpose_examples",
errors,
)
delivery = require_dict(grant_obj.get("delivery"), f"{prefix}.delivery", errors)
modes = catalog.get("delivery_modes", {})
allowed_known = set(
str(mode)
for mode in require_list(
modes.get("allowed_known"), "delivery_modes.allowed_known", errors
)
)
denied_known = set(
str(mode)
for mode in require_list(
modes.get("denied_known"), "delivery_modes.denied_known", errors
)
)
allowed = set(
str(mode)
for mode in require_list(
delivery.get("allowed"), f"{prefix}.delivery.allowed", errors
)
)
denied = set(
str(mode)
for mode in require_list(
delivery.get("denied"), f"{prefix}.delivery.denied", errors
)
)
if allowed - allowed_known:
errors.append(
f"{prefix}.delivery.allowed has unknown modes: {sorted(allowed - allowed_known)}"
)
if denied - denied_known:
errors.append(
f"{prefix}.delivery.denied has unknown modes: {sorted(denied - denied_known)}"
)
if allowed & denied:
errors.append(
f"{prefix}.delivery modes both allowed and denied: {sorted(allowed & denied)}"
)
missing_denied = REQUIRED_DENIED_MODES - denied
if missing_denied:
errors.append(f"{prefix}.delivery.denied missing {sorted(missing_denied)}")
preferred = require_nonempty_string(
delivery.get("preferred"), f"{prefix}.delivery.preferred", errors
)
if preferred and preferred not in allowed:
errors.append(f"{prefix}.delivery.preferred must be in delivery.allowed")
if "local-token-file" in allowed:
local_file = require_dict(
delivery.get("local_token_file"),
f"{prefix}.delivery.local_token_file",
errors,
)
if local_file.get("directory") != ".local/credential-leases":
errors.append(
f"{prefix}.delivery.local_token_file.directory must be .local/credential-leases"
)
if str(local_file.get("mode")) != "0600":
errors.append(f"{prefix}.delivery.local_token_file.mode must be 0600")
audit = require_dict(grant_obj.get("audit"), f"{prefix}.audit", errors)
if audit.get("openbao_audit_required") is not True:
errors.append(f"{prefix}.audit.openbao_audit_required must be true")
if audit.get("record_secret_values") is not False:
errors.append(f"{prefix}.audit.record_secret_values must be false")
revocation = require_dict(
grant_obj.get("revocation"), f"{prefix}.revocation", errors
)
if revocation.get("required") is not True:
errors.append(f"{prefix}.revocation.required must be true")
if revocation.get("by_accessor") is not True:
errors.append(f"{prefix}.revocation.by_accessor must be true")
return grant_id
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate non-secret credential grant catalog."
)
parser.add_argument(
"catalog",
nargs="?",
default="credential-grants/catalog.yaml",
help="Path to catalog YAML",
)
args = parser.parse_args()
path = Path(args.catalog)
if not path.exists():
fail(f"catalog file is missing: {path}")
return 1
raw = path.read_text(encoding="utf-8")
errors: list[str] = []
for marker in SECRET_MARKERS:
if marker in raw:
errors.append(f"secret-looking marker present: {marker}")
try:
catalog = yaml.safe_load(raw)
except yaml.YAMLError as exc:
fail(f"catalog YAML is invalid: {exc}")
return 1
catalog_obj = require_dict(catalog, "catalog", errors)
if catalog_obj.get("version") != 1:
errors.append("version must be 1")
require_nonempty_string(catalog_obj.get("updated"), "updated", errors)
require_nonempty_string(catalog_obj.get("owner_repo"), "owner_repo", errors)
require_nonempty_string(catalog_obj.get("workplan_id"), "workplan_id", errors)
delivery_modes = require_dict(
catalog_obj.get("delivery_modes"), "delivery_modes", errors
)
allowed_known = set(
str(mode)
for mode in require_list(
delivery_modes.get("allowed_known"), "delivery_modes.allowed_known", errors
)
)
denied_known = set(
str(mode)
for mode in require_list(
delivery_modes.get("denied_known"), "delivery_modes.denied_known", errors
)
)
if not REQUIRED_DENIED_MODES.issubset(denied_known):
errors.append(
f"delivery_modes.denied_known missing {sorted(REQUIRED_DENIED_MODES - denied_known)}"
)
if allowed_known & denied_known:
errors.append(
f"delivery_modes overlap between allowed and denied: {sorted(allowed_known & denied_known)}"
)
grant_classes = set(
str(item)
for item in require_list(
catalog_obj.get("grant_classes"), "grant_classes", errors
)
)
if grant_classes != ALLOWED_GRANT_CLASSES:
errors.append(f"grant_classes must be exactly {sorted(ALLOWED_GRANT_CLASSES)}")
grants = require_list(catalog_obj.get("grants"), "grants", errors)
if not grants:
errors.append("grants must not be empty")
seen: set[str] = set()
for index, grant in enumerate(grants):
grant_id = validate_grant(grant, index, catalog_obj, errors)
if grant_id:
if grant_id in seen:
errors.append(f"duplicate grant id: {grant_id}")
seen.add(grant_id)
if "ops-warden/warden-sign" not in seen:
errors.append("initial grant ops-warden/warden-sign is required")
if errors:
for error in errors:
fail(error)
return 1
print(f"[OK] credential grant catalog is valid: {path}")
print(f"[OK] grants: {len(grants)}")
for grant in grants:
print(f"[OK] {grant['id']}: {grant['grant_class']} {grant['credential_type']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -4,13 +4,13 @@ type: workplan
title: "Credential Request and Lease Broker"
domain: financials
repo: railiance-platform
status: ready
status: active
owner: codex
topic_slug: railiance
planning_priority: high
planning_order: 5
created: "2026-06-24"
updated: "2026-06-24"
updated: "2026-06-25"
depends_on_workplans:
- RAIL-PL-WP-0002
state_hub_workstream_id: "2731fece-6c49-45b8-ab8a-4ea6c04ac603"
@@ -101,7 +101,7 @@ Mitigations required by this workplan:
```task
id: RAILIANCE-WP-0005-T01
status: todo
status: done
priority: high
state_hub_task_id: "cd680de8-a483-40d6-84fa-369bad60e7c7"
```
@@ -116,11 +116,13 @@ Acceptance:
- Docs state that State Hub stores request metadata only, never secret values.
- Ops-warden credential routing can point OpenBao token requests here.
**2026-06-25:** Added `docs/credential-broker.md` as the ownership and architecture decision. It records that railiance-platform owns OpenBao credential request/generation/delivery, ops-warden owns SSH certificate signing only, State Hub stores non-secret request metadata only, and llm-connect/callers must not place secrets in prompts.
## T02 - Define credential grant catalog
```task
id: RAILIANCE-WP-0005-T02
status: todo
status: done
priority: high
state_hub_task_id: "6b64ad4b-90cd-475b-aaa9-73997c6b011b"
```
@@ -144,6 +146,8 @@ Acceptance:
- The catalog distinguishes self-service, approval-required, and break-glass grants.
- No grant entry contains a secret.
**2026-06-25:** Added the non-secret grant catalog at `credential-grants/catalog.yaml` with the initial `ops-warden/warden-sign` pilot grant, plus `scripts/credential-grants-validate.py` and `make credential-grants-validate`. The validator enforces required fields, TTL bounds, denied delivery modes, disallowed OpenBao policies, audit/revocation expectations, and secret-looking marker rejection.
## T03 - Configure bounded OpenBao token roles and policies
```task