From c7393d94ab80b250e58a7f7b2f12783489b15594 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 26 Jun 2026 17:49:40 +0200 Subject: [PATCH] feat: add credential grant catalog foundation --- .gitignore | 4 + Makefile | 8 +- credential-grants/catalog.yaml | 111 ++++++ docs/credential-broker.md | 133 +++++++ scripts/credential-grants-validate.py | 354 ++++++++++++++++++ ...005-credential-request-and-lease-broker.md | 12 +- 6 files changed, 617 insertions(+), 5 deletions(-) create mode 100644 credential-grants/catalog.yaml create mode 100644 docs/credential-broker.md create mode 100755 scripts/credential-grants-validate.py diff --git a/.gitignore b/.gitignore index 35a016d..9f8439a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ argocd/repositories/*.repository.sops.yaml # Kubeconfig *.kubeconfig + +# Credential broker local lease/token material +.local/credential-leases/ +*.openbao-token diff --git a/Makefile b/Makefile index 1ee997f..02abe8a 100644 --- a/Makefile +++ b/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 diff --git a/credential-grants/catalog.yaml b/credential-grants/catalog.yaml new file mode 100644 index 0000000..00b4bf0 --- /dev/null +++ b/credential-grants/catalog.yaml @@ -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 diff --git a/docs/credential-broker.md b/docs/credential-broker.md new file mode 100644 index 0000000..c4a1e56 --- /dev/null +++ b/docs/credential-broker.md @@ -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. diff --git a/scripts/credential-grants-validate.py b/scripts/credential-grants-validate.py new file mode 100755 index 0000000..44f734c --- /dev/null +++ b/scripts/credential-grants-validate.py @@ -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 : {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()) diff --git a/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md b/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md index b3640f9..35a38f4 100644 --- a/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md +++ b/workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md @@ -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