feat: add credential grant catalog foundation
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,3 +16,7 @@ argocd/repositories/*.repository.sops.yaml
|
||||
|
||||
# Kubeconfig
|
||||
*.kubeconfig
|
||||
|
||||
# Credential broker local lease/token material
|
||||
.local/credential-leases/
|
||||
*.openbao-token
|
||||
|
||||
8
Makefile
8
Makefile
@@ -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
|
||||
|
||||
111
credential-grants/catalog.yaml
Normal file
111
credential-grants/catalog.yaml
Normal 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
133
docs/credential-broker.md
Normal 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.
|
||||
354
scripts/credential-grants-validate.py
Executable file
354
scripts/credential-grants-validate.py
Executable 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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user