feat: add credential grant catalog foundation
This commit is contained in:
parent
693dc71833
commit
c7393d94ab
6 changed files with 617 additions and 5 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -16,3 +16,7 @@ argocd/repositories/*.repository.sops.yaml
|
||||||
|
|
||||||
# Kubeconfig
|
# Kubeconfig
|
||||||
*.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_NAMESPACE ?= argocd
|
||||||
ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
|
ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
|
||||||
ARGOCD_REPOSITORY_SECRET ?=
|
ARGOCD_REPOSITORY_SECRET ?=
|
||||||
|
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
|
||||||
|
|
||||||
##@ CloudNative PG (cnpg) — primary database operator
|
##@ 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)' \
|
OPENBAO_EMERGENCY_EVIDENCE='$(OPENBAO_EMERGENCY_EVIDENCE)' \
|
||||||
scripts/openbao-validate-emergency-drill-evidence.sh
|
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 GitOps bootstrap
|
||||||
|
|
||||||
argocd-bootstrap-dry-run: ## Server-side dry-run ArgoCD AppProjects and root Application
|
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 } \
|
/^[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)
|
/^##@/ { 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"
|
title: "Credential Request and Lease Broker"
|
||||||
domain: financials
|
domain: financials
|
||||||
repo: railiance-platform
|
repo: railiance-platform
|
||||||
status: ready
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: railiance
|
topic_slug: railiance
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
planning_order: 5
|
planning_order: 5
|
||||||
created: "2026-06-24"
|
created: "2026-06-24"
|
||||||
updated: "2026-06-24"
|
updated: "2026-06-25"
|
||||||
depends_on_workplans:
|
depends_on_workplans:
|
||||||
- RAIL-PL-WP-0002
|
- RAIL-PL-WP-0002
|
||||||
state_hub_workstream_id: "2731fece-6c49-45b8-ab8a-4ea6c04ac603"
|
state_hub_workstream_id: "2731fece-6c49-45b8-ab8a-4ea6c04ac603"
|
||||||
|
|
@ -101,7 +101,7 @@ Mitigations required by this workplan:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAILIANCE-WP-0005-T01
|
id: RAILIANCE-WP-0005-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "cd680de8-a483-40d6-84fa-369bad60e7c7"
|
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.
|
- Docs state that State Hub stores request metadata only, never secret values.
|
||||||
- Ops-warden credential routing can point OpenBao token requests here.
|
- 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
|
## T02 - Define credential grant catalog
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAILIANCE-WP-0005-T02
|
id: RAILIANCE-WP-0005-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6b64ad4b-90cd-475b-aaa9-73997c6b011b"
|
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.
|
- The catalog distinguishes self-service, approval-required, and break-glass grants.
|
||||||
- No grant entry contains a secret.
|
- 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
|
## T03 - Configure bounded OpenBao token roles and policies
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue