Compare commits
73 Commits
108944cd3e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2db4d1afe1 | |||
| 268437a36d | |||
| 0a24ab8475 | |||
| 6ed18ca709 | |||
| 8321e14b46 | |||
| a95236d2e5 | |||
| c626bfcf15 | |||
| 3e28e9ae79 | |||
| aa4c9ac492 | |||
| b59718fc83 | |||
| 51b85f802d | |||
| efb5432eb8 | |||
| 5c6a3ce95e | |||
| a07f2d3d7f | |||
| 481e64c3f4 | |||
| 8f617fcbf4 | |||
| e88c7829f3 | |||
| 1e769c75a0 | |||
| 2c1e76efca | |||
| 3527bc1cae | |||
| adf865611c | |||
| 271aa94642 | |||
| 3ef25cb787 | |||
| 53f3f4ca10 | |||
| f630d5135e | |||
| e3147b7fd5 | |||
| 06f2f4e315 | |||
| 00fb93544c | |||
| 5e0ed95127 | |||
| 6effdb80ca | |||
| eb24e04b71 | |||
| ad47a136f7 | |||
| 82d15cfea2 | |||
| 0e3ea30c75 | |||
| f92d07d5a1 | |||
| 248bc58b6a | |||
| a27a114491 | |||
| 3706ff703e | |||
| 52687d8b3e | |||
| aee0dcefad | |||
| 815b124ab1 | |||
| 8c1e64d5e0 | |||
| 85a4278a55 | |||
| 9d42c73833 | |||
| 704ee99218 | |||
| 76c9661db3 | |||
| 673ec46e25 | |||
| 2268a9375e | |||
| 752cfd6f00 | |||
| 6e663dfd20 | |||
| c7393d94ab | |||
| 693dc71833 | |||
| 0f0b14001e | |||
| c022cb2f83 | |||
| 86eb6ea269 | |||
| d59704deef | |||
| f39180583a | |||
| 0b384f8485 | |||
| 8e6892f4bf | |||
| 6712eed995 | |||
| a1dbb26842 | |||
| 50799938db | |||
| 520c7ea2c0 | |||
| ae4d967481 | |||
| 80648a78b7 | |||
| 64d7c18c3f | |||
| cb45f29fb2 | |||
| a6a87ae282 | |||
| 6ddf4e56b4 | |||
| 665d43386f | |||
| 423eccc8e9 | |||
| 7838df6069 | |||
| c24956fb5a |
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Credential and access routing
|
||||
|
||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=railiance-platform` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
### Quick routing table
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||
| Authorization decision | flex-auth | No — route only |
|
||||
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
@@ -1,11 +1,11 @@
|
||||
## First Session Protocol
|
||||
|
||||
Triggered when `get_domain_summary("railiance")` shows **no workstreams**.
|
||||
Triggered when `get_domain_summary("financials")` shows **no workstreams**.
|
||||
The project is registered but work has not yet been structured.
|
||||
|
||||
**Step 1 — Read, don't write**
|
||||
- `~/the-custodian/canon/projects/railiance/project_charter_v0.1.md` — purpose, scope
|
||||
- `~/the-custodian/canon/projects/railiance/roadmap_v0.1.md` — planned phases
|
||||
- `~/the-custodian/canon/projects/financials/project_charter_v0.1.md` — purpose, scope
|
||||
- `~/the-custodian/canon/projects/financials/roadmap_v0.1.md` — planned phases
|
||||
- Scan repo root: README, directory structure, existing code or docs
|
||||
|
||||
**Step 2 — Survey in-progress work**
|
||||
@@ -17,7 +17,7 @@ roadmap phase. **Wait for approval before creating.**
|
||||
|
||||
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||
```
|
||||
workplans/railiance-platform-WP-NNNN-<slug>.md ← write this first
|
||||
workplans/RAILIANCE-WP-NNNN-<slug>.md ← write this first
|
||||
```
|
||||
Then register in the hub:
|
||||
```
|
||||
@@ -28,7 +28,7 @@ create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||
**Step 5 — Record the setup**
|
||||
```
|
||||
add_progress_event(
|
||||
summary="First session: structured railiance into N workstreams, M tasks",
|
||||
summary="First session: structured financials into N workstreams, M tasks",
|
||||
event_type="milestone",
|
||||
topic_id="ca369340-a64e-442e-98f1-a4fa7dc74a38",
|
||||
detail={"workstreams": [...], "tasks_created": M}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
**Purpose:** OAS S3 Platform Services — PostgreSQL HA, object storage, secret management, identity
|
||||
|
||||
**Domain:** railiance
|
||||
**Domain:** financials
|
||||
**Repo slug:** railiance-platform
|
||||
**Topic ID:** ca369340-a64e-442e-98f1-a4fa7dc74a38
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
## Session Protocol
|
||||
|
||||
State Hub: http://127.0.0.1:8000
|
||||
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||
MCP server name in `~/.claude.json`: `dev-hub`
|
||||
|
||||
**Step 1 — Orient**
|
||||
|
||||
@@ -10,7 +11,7 @@ cat .custodian-brief.md
|
||||
```
|
||||
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||
```
|
||||
get_domain_summary("railiance")
|
||||
get_domain_summary("financials")
|
||||
```
|
||||
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||
```bash
|
||||
@@ -39,11 +40,11 @@ curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
ls workplans/
|
||||
```
|
||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`todo`/`in_progress` tasks.
|
||||
`wait`/`todo`/`progress` tasks.
|
||||
|
||||
**Step 4 — Present brief**
|
||||
|
||||
1. **Active workstreams** for `railiance` — title, task counts, blocking decisions
|
||||
1. **Active workstreams** for `financials` — title, task counts, blocking decisions
|
||||
2. **Pending tasks** from `workplans/` + any `[repo:railiance-platform]` hub tasks
|
||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
File location: `workplans/railiance-platform-WP-NNNN-<slug>.md`
|
||||
ID prefix: `RAILIANCE-WP`
|
||||
File location: `workplans/RAILIANCE-WP-NNNN-<slug>.md`
|
||||
ID prefix: `RAILIANCE-WP-`
|
||||
|
||||
Work items originate as files in this repo **before** being registered in the hub.
|
||||
|
||||
@@ -12,7 +12,7 @@ repo state, and `finished` when implementation is complete. `stalled` and
|
||||
`needs_review` are derived health labels, not stored statuses.
|
||||
|
||||
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||
prefix: `YYMMDD-railiance-platform-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||
prefix: `YYMMDD-RAILIANCE-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||
unchanged; the prefix is only for quick visual reference.
|
||||
|
||||
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||
@@ -25,4 +25,16 @@ Ecosystem todos from other agents arrive as `[repo:railiance-platform]` hub task
|
||||
visible at session start. Pick one up by creating the workplan file, then registering
|
||||
the workstream.
|
||||
|
||||
Task blocks use this shape:
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-NNNN-T01
|
||||
status: wait | todo | progress | done | cancel
|
||||
priority: high | medium | low
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
```
|
||||
|
||||
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||
blocked work and `cancel` for stopped work.
|
||||
|
||||
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — railiance-platform
|
||||
|
||||
**Domain:** railiance
|
||||
**Last synced:** 2026-05-29 00:09 UTC
|
||||
**Domain:** financials
|
||||
**Last synced:** 2026-07-01 21:12 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
*(none — repo may need first-session setup)*
|
||||
### Credential Request and Lease Broker
|
||||
Progress: 6/10 done | workstream_id: `2731fece-6c49-45b8-ab8a-4ea6c04ac603`
|
||||
|
||||
**Open tasks:**
|
||||
- ! T05 - Implement secure delivery modes `66f3cd6d`
|
||||
*(wait: OpenBao live delivery verification pending)*
|
||||
- ! T07 - Add flex-auth preflight authorization and State Hub request metadata `1269bb58`
|
||||
*(wait: Live flex-auth/OpenBao lifecycle evidence pending)*
|
||||
- ! T09 - Verification, audit, and red-team checks `78d1db83`
|
||||
*(wait: Live OpenBao audit evidence pending)*
|
||||
- ! T10 - Rollout and migration `44ce4082`
|
||||
*(wait: Live pilot and external routing rollout pending)*
|
||||
|
||||
### OpenBao Approved Automation Delegation
|
||||
Progress: 2/5 done | workstream_id: `671898ef-2378-4814-b8f6-066148cdad46`
|
||||
|
||||
**Open tasks:**
|
||||
- ! T05 - Close the whynot-design pilot `18f34c95`
|
||||
- ► T03 - Add non-production applier role first `ff927a19`
|
||||
- ► T04 - Add production metadata applier with human approval gate `414abd65`
|
||||
|
||||
### Issue-Core Runtime Ingestion Credential Lane
|
||||
Progress: 2/7 done | workstream_id: `b059c81d-96f1-451f-896f-a05cd73744a1`
|
||||
|
||||
**Open tasks:**
|
||||
- ! T03 - Apply or confirm least-privilege OpenBao metadata `e8566cf4`
|
||||
- ! T04 - Provision values through approved custody `4990fe6a`
|
||||
- ! T05 - Verify positive and negative access `65e83572`
|
||||
- ! T06 - Activate ops-warden catalog front door `0d9a02da`
|
||||
- ! T07 - Record lifecycle operations `c85d1139`
|
||||
|
||||
### llm-connect OpenRouter Provider Key Lane
|
||||
Progress: 1/7 done | workstream_id: `f364d405-a85d-4b89-b600-1964ab436cad`
|
||||
|
||||
**Open tasks:**
|
||||
- ! T03 - Apply or confirm least-privilege OpenBao metadata `42796ef5`
|
||||
- ! T04 - Provision the provider key through approved custody `651f6ec8`
|
||||
- ! T05 - Verify positive and negative access `d538cfc0`
|
||||
- ! T06 - Activate ops-warden catalog front door `376de3fe`
|
||||
- ! T07 - Record lifecycle operations `130155a5`
|
||||
- ► T01 - Review CCR scope and selector naming `307b75a6`
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("railiance")`
|
||||
`get_domain_summary("financials")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -3,10 +3,20 @@ helm/*.yaml
|
||||
!helm/*.sops.yaml
|
||||
!helm/*.yaml.template
|
||||
!helm/openbao-values.yaml
|
||||
!helm/openbao-middleware.yaml
|
||||
!helm/openbao-ui-overlay-k8s.yaml
|
||||
# Kubernetes manifests (no secrets) are safe to commit
|
||||
!helm/*-cluster.yaml
|
||||
!helm/*-networkpolicies.yaml
|
||||
!helm/*-databases.yaml
|
||||
|
||||
# ArgoCD repository credentials — encrypt locally, never commit
|
||||
argocd/repositories/*.repository.sops.yaml
|
||||
!argocd/repositories/*.repository.sops.yaml.template
|
||||
|
||||
# Kubeconfig
|
||||
*.kubeconfig
|
||||
|
||||
# Credential broker local lease/token material
|
||||
.local/credential-leases/
|
||||
*.openbao-token
|
||||
|
||||
23
.repo-classification.yaml
Normal file
23
.repo-classification.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
repo_classification:
|
||||
standard: Repo Classification Standard
|
||||
version: '1.0'
|
||||
classified_at: '2026-06-22'
|
||||
classified_by: human
|
||||
category: tooling
|
||||
domain: financials
|
||||
secondary_domains:
|
||||
- infotech
|
||||
capability_tags:
|
||||
- platform
|
||||
- operations
|
||||
- configuration
|
||||
- governance
|
||||
business_stake:
|
||||
- finance
|
||||
- technology
|
||||
- operations
|
||||
business_mechanics:
|
||||
- control
|
||||
- operation
|
||||
- coordination
|
||||
notes: Railiance platform substrate; human corrected category project→tooling.
|
||||
71
AGENTS.md
71
AGENTS.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**Purpose:** OAS S3 Platform Services — PostgreSQL HA, object storage, secret management, identity
|
||||
|
||||
**Domain:** railiance
|
||||
**Domain:** financials
|
||||
**Repo slug:** railiance-platform
|
||||
**Topic ID:** `ca369340-a64e-442e-98f1-a4fa7dc74a38`
|
||||
**Workplan prefix:** `RAILIANCE-WP-`
|
||||
@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "in_progress"}'
|
||||
# values: todo | in_progress | done | blocked
|
||||
-d '{"status": "progress"}'
|
||||
# values: wait | todo | progress | done | cancel
|
||||
```
|
||||
|
||||
### Flag a task for human review
|
||||
@@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||
2. Check inbox: `GET /messages/?to_agent=railiance-platform&unread_only=true`; mark read
|
||||
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||
4. Check blocked tasks: `GET /tasks/?needs_human=true`
|
||||
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||
|
||||
**During work:**
|
||||
- Update task statuses in workplan files as tasks progress
|
||||
@@ -101,6 +101,63 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
|
||||
---
|
||||
|
||||
## Credential and access routing
|
||||
|
||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=railiance-platform` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
### Quick routing table
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||
| Authorization decision | flex-auth | No — route only |
|
||||
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
|
||||
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||
<!-- Append repo-specific agent instructions below this marker.
|
||||
The state-hub template sync preserves content after this line. -->
|
||||
|
||||
---
|
||||
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
Work items originate as files in this repo — not in the hub. The hub is a
|
||||
@@ -124,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||
id: RAILIANCE-WP-NNNN
|
||||
type: workplan
|
||||
title: "..."
|
||||
domain: railiance
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
owner: codex
|
||||
@@ -146,7 +203,7 @@ derived health labels, not frontmatter statuses.
|
||||
|
||||
` ` `task
|
||||
id: RAILIANCE-WP-NNNN-T01
|
||||
status: todo | in_progress | done | blocked
|
||||
status: wait | todo | progress | done | cancel
|
||||
priority: high | medium | low
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
` ` `
|
||||
@@ -154,7 +211,7 @@ state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
Task description text.
|
||||
```
|
||||
|
||||
Status progression: `todo` → `in_progress` → `done` (or `blocked`)
|
||||
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||
|
||||
To create a new workplan:
|
||||
1. Write the file following the format above
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
@.claude/rules/stack-and-commands.md
|
||||
@.claude/rules/architecture.md
|
||||
@.claude/rules/repo-boundary.md
|
||||
@.claude/rules/credential-routing.md
|
||||
@.claude/rules/agents.md
|
||||
|
||||
195
Makefile
195
Makefile
@@ -1,7 +1,7 @@
|
||||
SHELL := /usr/bin/env bash
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
KUBECONFIG ?= $(firstword $(wildcard $(HOME)/.kube/config-hosteurope) $(HOME)/.kube/config)
|
||||
KUBECONFIG ?= $(firstword $(wildcard $(HOME)/.kube/config) $(HOME)/.kube/config-hosteurope)
|
||||
KUBECTL_BIN ?= $(firstword $(shell command -v kubectl 2>/dev/null) $(wildcard $(HOME)/.local/bin/kubectl) kubectl)
|
||||
KUBECTL := $(KUBECTL_BIN) --kubeconfig=$(KUBECONFIG)
|
||||
HELM := helm --kubeconfig=$(KUBECONFIG)
|
||||
@@ -13,9 +13,30 @@ OPENBAO_CHART_VERSION ?= 0.28.2
|
||||
OPENBAO_NAMESPACE ?= openbao
|
||||
OPENBAO_RELEASE ?= openbao
|
||||
OPENBAO_VALUES ?= helm/openbao-values.yaml
|
||||
OPENBAO_MIDDLEWARE ?= helm/openbao-middleware.yaml
|
||||
OPENBAO_UI_OVERLAY_DIR ?= helm/openbao-ui-overlay
|
||||
OPENBAO_UI_OVERLAY_K8S ?= helm/openbao-ui-overlay-k8s.yaml
|
||||
OPENBAO_VERIFY_AUTH_ARGS ?=
|
||||
OPENBAO_RESTORE_EVIDENCE ?= /tmp/netkingdom-openbao-restore-drill/evidence.json
|
||||
OPENBAO_EMERGENCY_EVIDENCE ?= /tmp/netkingdom-openbao-emergency-drill/evidence.json
|
||||
EXTERNAL_SECRETS_NAMESPACE ?= external-secrets
|
||||
ARGOCD_NAMESPACE ?= argocd
|
||||
ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
|
||||
ARGOCD_REPOSITORY_SECRET ?=
|
||||
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
|
||||
CREDENTIAL_CHANGE ?= CCR-2026-0001
|
||||
CREDENTIAL_CHANGE_EVIDENCE_ARGS ?=
|
||||
CREDENTIAL_CHANGE_LIFECYCLE_ACTION ?= deactivate
|
||||
CREDENTIAL_CHANGE_LIFECYCLE_ARGS ?=
|
||||
CREDENTIAL_CHANGE_IMPORT_ARGS ?=
|
||||
STATE_HUB_URL ?= http://127.0.0.1:8000
|
||||
OPENBAO_TOKEN_GRANT_ARGS ?=
|
||||
OPENBAO_CREDENTIAL_CHANGE_APPLIER_ARGS ?=
|
||||
OPENBAO_WORKLOAD_KV_ARGS ?=
|
||||
CREDENTIAL_HELPER_GLOBAL_ARGS ?=
|
||||
CREDENTIAL_HELPER_ARGS ?=
|
||||
CREDENTIAL_HELPER_CHILD_ENV ?=
|
||||
CREDENTIAL_HELPER_PURPOSE ?= flex-auth-openbao-smoke
|
||||
|
||||
##@ CloudNative PG (cnpg) — primary database operator
|
||||
|
||||
@@ -102,13 +123,25 @@ openbao-dry-run: openbao-repo ## Render the OpenBao Helm release without applyin
|
||||
-f $(OPENBAO_VALUES) \
|
||||
--dry-run
|
||||
|
||||
openbao-overlay-apply: ## Apply KeyCape login overlay gateway and assets
|
||||
OPENBAO_UI_OVERLAY_DIR=$(OPENBAO_UI_OVERLAY_DIR) \
|
||||
OPENBAO_UI_OVERLAY_K8S=$(OPENBAO_UI_OVERLAY_K8S) \
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
scripts/openbao-ui-overlay-apply.sh
|
||||
|
||||
openbao-verify-login-overlay: ## Verify public KeyCape login overlay is active
|
||||
OPENBAO_UI_OVERLAY_DIR=$(OPENBAO_UI_OVERLAY_DIR) \
|
||||
scripts/openbao-verify-login-overlay.sh $(OPENBAO_VERIFY_LOGIN_OVERLAY_ARGS)
|
||||
|
||||
openbao-deploy: openbao-repo ## Deploy / upgrade OpenBao to the openbao namespace
|
||||
$(KUBECTL) create namespace $(OPENBAO_NAMESPACE) --dry-run=client -o yaml | $(KUBECTL) apply -f -
|
||||
$(KUBECTL) apply -f $(OPENBAO_MIDDLEWARE)
|
||||
$(HELM) upgrade --install $(OPENBAO_RELEASE) openbao/openbao \
|
||||
--version $(OPENBAO_CHART_VERSION) \
|
||||
--namespace $(OPENBAO_NAMESPACE) \
|
||||
-f $(OPENBAO_VALUES) \
|
||||
--wait --timeout 5m
|
||||
$(MAKE) openbao-overlay-apply
|
||||
|
||||
openbao-status: ## Show OpenBao pods, services, PVCs, and seal/init status
|
||||
$(KUBECTL) get pods,svc,pvc -n $(OPENBAO_NAMESPACE) \
|
||||
@@ -127,10 +160,34 @@ openbao-configure-initial: ## Apply first post-unseal audit, auth, mounts, and p
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) scripts/openbao-apply-initial-config.sh
|
||||
|
||||
openbao-configure-ssh: ## Enable SSH secrets engine, roles, and warden-sign policy
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) scripts/openbao-apply-ssh-engine.sh
|
||||
|
||||
openbao-verify-ssh: ## Verify SSH engine mount, roles, and warden-sign policy
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) scripts/openbao-verify-ssh-engine.sh
|
||||
|
||||
openbao-verify-authenticated: ## Run authenticated non-mutating OpenBao audit/auth/mount checks
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) scripts/openbao-verify-authenticated.sh $(OPENBAO_VERIFY_AUTH_ARGS)
|
||||
|
||||
openbao-configure-external-secrets-issue-core: ## Configure OpenBao policy/role for issue-core ESO pilot
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) ESO_NAMESPACE=$(EXTERNAL_SECRETS_NAMESPACE) \
|
||||
scripts/openbao-apply-external-secrets-issue-core.sh
|
||||
|
||||
openbao-configure-external-secrets-activity-core: ## Configure OpenBao policy/role for activity-core ESO lane
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) OPENBAO_RELEASE=$(OPENBAO_RELEASE) ESO_NAMESPACE=$(EXTERNAL_SECRETS_NAMESPACE) OPENBAO_ESO_ROLE=external-secrets-activity-core OPENBAO_ESO_POLICY=workload-kv-read-llm-connect-provider-secrets POLICY_FILE='$(CURDIR)/openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl' scripts/openbao-apply-external-secrets-issue-core.sh
|
||||
|
||||
openbao-workload-kv-lanes-dry-run: ## Dry-run OpenBao workload KV read-lane policy apply
|
||||
scripts/openbao-apply-workload-kv-lanes.sh --dry-run $(OPENBAO_WORKLOAD_KV_ARGS)
|
||||
|
||||
openbao-configure-workload-kv-lanes: ## Configure OpenBao workload KV read-lane policies
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-apply-workload-kv-lanes.sh $(OPENBAO_WORKLOAD_KV_ARGS)
|
||||
|
||||
openbao-validate-restore-evidence: ## Validate non-secret OpenBao restore-drill evidence JSON
|
||||
OPENBAO_RESTORE_EVIDENCE='$(OPENBAO_RESTORE_EVIDENCE)' \
|
||||
scripts/openbao-validate-restore-evidence.sh
|
||||
@@ -139,6 +196,140 @@ 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)
|
||||
|
||||
credential-change-validate: ## Validate non-secret credential change requests
|
||||
scripts/credential-change.py validate
|
||||
|
||||
credential-change-render: ## Render a credential change request review summary
|
||||
scripts/credential-change.py render $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-plan: ## Render a credential change request apply plan for review
|
||||
scripts/credential-change.py plan $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-decision-templates: ## Render CCR approve/deny/needs-changes templates
|
||||
scripts/credential-change.py decision-templates $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-status: ## Render credential change request readiness status
|
||||
scripts/credential-change.py status $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-status-json: ## Render credential change request readiness status as JSON
|
||||
scripts/credential-change.py status --json $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-sync-decision: ## Sync resolved State Hub decision back into a CCR
|
||||
scripts/credential-change.py sync-decision $(CREDENTIAL_CHANGE) --state-hub-url $(STATE_HUB_URL)
|
||||
|
||||
credential-change-apply-plan: ## Render approved-only operator apply plan
|
||||
scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-operator-commands: ## Render approved-only non-secret OpenBao operator commands
|
||||
scripts/credential-change.py operator-commands $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-applier-dry-run: ## Validate delegated OpenBao metadata mutations for a CCR
|
||||
scripts/credential-change.py applier-dry-run $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-applier-apply-plan: ## Render delegated OpenBao metadata apply plan
|
||||
scripts/credential-change.py applier-apply $(CREDENTIAL_CHANGE) --plan-only
|
||||
|
||||
credential-change-applier-apply: ## Apply delegated metadata; pass confirmation/actor args via CREDENTIAL_CHANGE_EVIDENCE_ARGS
|
||||
scripts/credential-change.py applier-apply $(CREDENTIAL_CHANGE) $(CREDENTIAL_CHANGE_EVIDENCE_ARGS)
|
||||
|
||||
credential-change-runbook: ## Render the attended CCR apply/verify runbook
|
||||
scripts/credential-change.py runbook $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-record-evidence: ## Record non-secret CCR evidence; pass CREDENTIAL_CHANGE_EVIDENCE_ARGS
|
||||
scripts/credential-change.py record-evidence $(CREDENTIAL_CHANGE) $(CREDENTIAL_CHANGE_EVIDENCE_ARGS)
|
||||
|
||||
credential-change-lifecycle-plan: ## Render deactivation/rotation/compromise lifecycle guidance
|
||||
scripts/credential-change.py lifecycle-plan $(CREDENTIAL_CHANGE) --action $(CREDENTIAL_CHANGE_LIFECYCLE_ACTION)
|
||||
|
||||
credential-change-lifecycle-event: ## Record lifecycle event; pass CREDENTIAL_CHANGE_LIFECYCLE_ARGS
|
||||
scripts/credential-change.py lifecycle-event $(CREDENTIAL_CHANGE) --action $(CREDENTIAL_CHANGE_LIFECYCLE_ACTION) $(CREDENTIAL_CHANGE_LIFECYCLE_ARGS)
|
||||
|
||||
credential-change-import-inventory: ## Import existing lane as non-secret CCR; pass CREDENTIAL_CHANGE_IMPORT_ARGS
|
||||
scripts/credential-change.py import-inventory $(CREDENTIAL_CHANGE_IMPORT_ARGS)
|
||||
|
||||
openbao-credential-change-appliers-dry-run: ## Dry-run credential-change applier policies/token roles
|
||||
scripts/openbao-apply-credential-change-appliers.py --dry-run $(OPENBAO_CREDENTIAL_CHANGE_APPLIER_ARGS)
|
||||
|
||||
openbao-configure-credential-change-appliers: ## Apply credential-change applier policies/token roles
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-apply-credential-change-appliers.py $(OPENBAO_CREDENTIAL_CHANGE_APPLIER_ARGS)
|
||||
|
||||
openbao-token-grants-dry-run: ## Dry-run OpenBao token roles and issuer policies for credential grants
|
||||
scripts/openbao-apply-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-configure-token-grants: ## Apply OpenBao token roles and issuer policies for credential grants
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-apply-token-grants.py $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-verify-token-grants-dry-run: ## Dry-run OpenBao token grant verification
|
||||
scripts/openbao-verify-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-verify-token-grants: ## Verify OpenBao token roles and issuer policies for credential grants
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-verify-token-grants.py $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
openbao-verify-token-grants-smoke: ## Mint/revoke a child token and prove bounded warden-sign capabilities
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/openbao-verify-token-grants.py --issue-smoke-token $(OPENBAO_TOKEN_GRANT_ARGS)
|
||||
|
||||
credential-helper-dry-run: ## Dry-run credential request, exec, status, and revoke helper flows
|
||||
scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) request --dry-run \
|
||||
--grant ops-warden/warden-sign --purpose $(CREDENTIAL_HELPER_PURPOSE) \
|
||||
$(CREDENTIAL_HELPER_ARGS)
|
||||
scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) request --dry-run \
|
||||
--grant ops-warden/warden-sign --purpose $(CREDENTIAL_HELPER_PURPOSE) \
|
||||
--delivery kubernetes-auth $(CREDENTIAL_HELPER_ARGS)
|
||||
scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) exec --dry-run \
|
||||
--grant ops-warden/warden-sign --purpose $(CREDENTIAL_HELPER_PURPOSE) \
|
||||
$(CREDENTIAL_HELPER_ARGS) -- SMOKE_VAULT=1 /bin/true
|
||||
scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) status --dry-run example-accessor
|
||||
scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) revoke --dry-run example-accessor
|
||||
|
||||
credential-tests: ## Run offline credential broker unit tests
|
||||
python3 -m unittest discover -s tests -p 'test_credential*.py'
|
||||
|
||||
credential-change-tests: ## Run credential change request unit tests
|
||||
python3 -m unittest discover -s tests -p 'test_credential_change.py'
|
||||
|
||||
credential-exec-ops-warden-smoke: ## Run ops-warden smoke with an exec-injected warden-sign token
|
||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||
scripts/credential.py $(CREDENTIAL_HELPER_GLOBAL_ARGS) exec \
|
||||
--grant ops-warden/warden-sign --purpose ops-warden-production-sign-smoke \
|
||||
$(CREDENTIAL_HELPER_ARGS) -- \
|
||||
$(CREDENTIAL_HELPER_CHILD_ENV) \
|
||||
SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh
|
||||
|
||||
##@ ArgoCD GitOps bootstrap
|
||||
|
||||
argocd-bootstrap-dry-run: ## Server-side dry-run ArgoCD AppProjects and root Application
|
||||
$(KUBECTL) apply --dry-run=server -k $(ARGOCD_BOOTSTRAP_DIR)
|
||||
|
||||
argocd-bootstrap-deploy: ## Apply ArgoCD AppProjects and root Application
|
||||
$(KUBECTL) apply -k $(ARGOCD_BOOTSTRAP_DIR)
|
||||
|
||||
argocd-repo-apply: ## Apply a SOPS-encrypted ArgoCD repository Secret (set ARGOCD_REPOSITORY_SECRET)
|
||||
@test -n "$(ARGOCD_REPOSITORY_SECRET)" || \
|
||||
(echo "ERROR: set ARGOCD_REPOSITORY_SECRET=argocd/repositories/<repo>.repository.sops.yaml"; exit 1)
|
||||
sops -d $(ARGOCD_REPOSITORY_SECRET) | $(KUBECTL) apply -f -
|
||||
|
||||
argocd-status: ## Show Railiance ArgoCD projects, root app, and registered repos
|
||||
$(KUBECTL) get appprojects.argoproj.io -n $(ARGOCD_NAMESPACE) \
|
||||
railiance-bootstrap railiance-tenants railiance-platform-addons
|
||||
$(KUBECTL) get applications.argoproj.io -n $(ARGOCD_NAMESPACE) \
|
||||
railiance-apps-root external-secrets openbao-secretstore issue-core
|
||||
$(KUBECTL) get secrets -n $(ARGOCD_NAMESPACE) \
|
||||
-l argocd.argoproj.io/secret-type=repository
|
||||
|
||||
##@ Backup
|
||||
|
||||
backup: ## Backup platform services (PostgreSQL logical dump) — age-encrypted to Nextcloud
|
||||
@@ -151,4 +342,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-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-verify-authenticated openbao-validate-restore-evidence openbao-validate-emergency-evidence 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-configure-external-secrets-activity-core openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup help
|
||||
|
||||
18
argocd/applications/README.md
Normal file
18
argocd/applications/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Railiance ArgoCD Tenant Applications
|
||||
|
||||
This directory is synced by the `railiance-apps-root` ArgoCD Application.
|
||||
|
||||
Tenant teams author a thin ArgoCD `Application` manifest against the contract
|
||||
in `docs/argocd-gitops.md`. Platform review merges that manifest here after
|
||||
checking namespace, repository, sync policy, and secret-delivery shape.
|
||||
|
||||
Workload manifests stay in the owning tenant repo. The default source path for
|
||||
tenant workloads is:
|
||||
|
||||
```text
|
||||
k8s/railiance/
|
||||
```
|
||||
|
||||
Do not commit Kubernetes Secret values, ArgoCD repository credentials, OpenBao
|
||||
tokens, deploy keys, or API keys here.
|
||||
|
||||
35
argocd/applications/external-secrets.application.yaml
Normal file
35
argocd/applications/external-secrets.application.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: external-secrets
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: external-secrets
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "0"
|
||||
spec:
|
||||
project: railiance-platform-addons
|
||||
source:
|
||||
repoURL: https://charts.external-secrets.io
|
||||
chart: external-secrets
|
||||
targetRevision: 0.16.1
|
||||
helm:
|
||||
releaseName: external-secrets
|
||||
values: |
|
||||
installCRDs: true
|
||||
serviceAccount:
|
||||
create: true
|
||||
name: external-secrets
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: external-secrets
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ServerSideApply=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- PruneLast=true
|
||||
27
argocd/applications/issue-core.application.yaml
Normal file
27
argocd/applications/issue-core.application.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: issue-core
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance.io/domain: issue-core
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "10"
|
||||
spec:
|
||||
project: railiance-tenants
|
||||
source:
|
||||
repoURL: https://gitea.coulomb.social/coulomb/issue-core.git
|
||||
targetRevision: main
|
||||
path: k8s/railiance
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: issue-core
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- PruneLast=true
|
||||
27
argocd/applications/openbao-secretstore.application.yaml
Normal file
27
argocd/applications/openbao-secretstore.application.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: openbao-secretstore
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: external-secrets
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "1"
|
||||
spec:
|
||||
project: railiance-platform-addons
|
||||
source:
|
||||
repoURL: https://gitea.coulomb.social/coulomb/railiance-platform.git
|
||||
targetRevision: main
|
||||
path: argocd/platform-addons/openbao-secretstore
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: external-secrets
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- PruneLast=true
|
||||
22
argocd/bootstrap/00-railiance-bootstrap-project.yaml
Normal file
22
argocd/bootstrap/00-railiance-bootstrap-project.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: AppProject
|
||||
metadata:
|
||||
name: railiance-bootstrap
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: gitops
|
||||
spec:
|
||||
description: Platform-owned ArgoCD bootstrap project for Railiance app-of-apps.
|
||||
sourceRepos:
|
||||
- https://gitea.coulomb.social/coulomb/railiance-platform.git
|
||||
destinations:
|
||||
- server: https://kubernetes.default.svc
|
||||
namespace: argocd
|
||||
clusterResourceWhitelist: []
|
||||
namespaceResourceWhitelist:
|
||||
- group: argoproj.io
|
||||
kind: Application
|
||||
orphanedResources:
|
||||
warn: true
|
||||
|
||||
52
argocd/bootstrap/01-railiance-tenants-project.yaml
Normal file
52
argocd/bootstrap/01-railiance-tenants-project.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: AppProject
|
||||
metadata:
|
||||
name: railiance-tenants
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: gitops
|
||||
spec:
|
||||
description: Guardrails for Railiance tenant applications deployed by ArgoCD.
|
||||
sourceRepos:
|
||||
- https://gitea.coulomb.social/coulomb/*.git
|
||||
destinations:
|
||||
- server: https://kubernetes.default.svc
|
||||
namespace: "*"
|
||||
clusterResourceWhitelist:
|
||||
- group: ""
|
||||
kind: Namespace
|
||||
namespaceResourceWhitelist:
|
||||
- group: ""
|
||||
kind: ConfigMap
|
||||
- group: ""
|
||||
kind: PersistentVolumeClaim
|
||||
- group: ""
|
||||
kind: Secret
|
||||
- group: ""
|
||||
kind: Service
|
||||
- group: ""
|
||||
kind: ServiceAccount
|
||||
- group: apps
|
||||
kind: Deployment
|
||||
- group: apps
|
||||
kind: StatefulSet
|
||||
- group: autoscaling
|
||||
kind: HorizontalPodAutoscaler
|
||||
- group: batch
|
||||
kind: CronJob
|
||||
- group: batch
|
||||
kind: Job
|
||||
- group: external-secrets.io
|
||||
kind: ExternalSecret
|
||||
- group: networking.k8s.io
|
||||
kind: Ingress
|
||||
- group: networking.k8s.io
|
||||
kind: NetworkPolicy
|
||||
- group: traefik.io
|
||||
kind: IngressRoute
|
||||
- group: traefik.io
|
||||
kind: Middleware
|
||||
orphanedResources:
|
||||
warn: true
|
||||
|
||||
48
argocd/bootstrap/02-railiance-platform-addons-project.yaml
Normal file
48
argocd/bootstrap/02-railiance-platform-addons-project.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: AppProject
|
||||
metadata:
|
||||
name: railiance-platform-addons
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: gitops
|
||||
spec:
|
||||
description: Platform-owned cluster add-ons required by tenant workloads.
|
||||
sourceRepos:
|
||||
- https://gitea.coulomb.social/coulomb/railiance-platform.git
|
||||
- https://charts.external-secrets.io
|
||||
destinations:
|
||||
- server: https://kubernetes.default.svc
|
||||
namespace: "*"
|
||||
clusterResourceWhitelist:
|
||||
- group: ""
|
||||
kind: Namespace
|
||||
- group: apiextensions.k8s.io
|
||||
kind: CustomResourceDefinition
|
||||
- group: admissionregistration.k8s.io
|
||||
kind: MutatingWebhookConfiguration
|
||||
- group: admissionregistration.k8s.io
|
||||
kind: ValidatingWebhookConfiguration
|
||||
- group: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
- group: rbac.authorization.k8s.io
|
||||
kind: ClusterRoleBinding
|
||||
- group: external-secrets.io
|
||||
kind: ClusterSecretStore
|
||||
namespaceResourceWhitelist:
|
||||
- group: ""
|
||||
kind: ConfigMap
|
||||
- group: ""
|
||||
kind: Secret
|
||||
- group: ""
|
||||
kind: Service
|
||||
- group: ""
|
||||
kind: ServiceAccount
|
||||
- group: apps
|
||||
kind: Deployment
|
||||
- group: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
- group: rbac.authorization.k8s.io
|
||||
kind: RoleBinding
|
||||
orphanedResources:
|
||||
warn: true
|
||||
26
argocd/bootstrap/10-railiance-apps-root.application.yaml
Normal file
26
argocd/bootstrap/10-railiance-apps-root.application.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: railiance-apps-root
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: gitops
|
||||
spec:
|
||||
project: railiance-bootstrap
|
||||
source:
|
||||
repoURL: https://gitea.coulomb.social/coulomb/railiance-platform.git
|
||||
targetRevision: main
|
||||
path: argocd/applications
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: argocd
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=false
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- PruneLast=true
|
||||
|
||||
8
argocd/bootstrap/kustomization.yaml
Normal file
8
argocd/bootstrap/kustomization.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- 00-railiance-bootstrap-project.yaml
|
||||
- 01-railiance-tenants-project.yaml
|
||||
- 02-railiance-platform-addons-project.yaml
|
||||
- 10-railiance-apps-root.application.yaml
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- openbao.clustersecretstore.yaml
|
||||
- openbao-activity-core.clustersecretstore.yaml
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: openbao-activity-core
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: external-secrets
|
||||
spec:
|
||||
provider:
|
||||
vault:
|
||||
server: http://openbao.openbao.svc.cluster.local:8200
|
||||
path: platform
|
||||
version: v2
|
||||
auth:
|
||||
kubernetes:
|
||||
mountPath: kubernetes
|
||||
role: external-secrets-activity-core
|
||||
serviceAccountRef:
|
||||
name: external-secrets
|
||||
namespace: external-secrets
|
||||
conditions:
|
||||
- namespaces:
|
||||
- activity-core
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: openbao
|
||||
labels:
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: external-secrets
|
||||
spec:
|
||||
provider:
|
||||
vault:
|
||||
server: http://openbao.openbao.svc.cluster.local:8200
|
||||
path: platform
|
||||
version: v2
|
||||
auth:
|
||||
kubernetes:
|
||||
mountPath: kubernetes
|
||||
role: external-secrets-issue-core
|
||||
serviceAccountRef:
|
||||
name: external-secrets
|
||||
namespace: external-secrets
|
||||
conditions:
|
||||
- namespaces:
|
||||
- issue-core
|
||||
23
argocd/repositories/README.md
Normal file
23
argocd/repositories/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# ArgoCD Repository Registration
|
||||
|
||||
ArgoCD discovers Git repositories from Kubernetes Secrets in the `argocd`
|
||||
namespace with `argocd.argoproj.io/secret-type: repository`.
|
||||
|
||||
Use the templates in this directory to create SOPS-encrypted, non-plaintext
|
||||
repository Secret files. Credentials must be sourced from the approved
|
||||
operator/OpenBao path and must never be committed in plaintext.
|
||||
|
||||
Recommended OpenBao path:
|
||||
|
||||
```text
|
||||
platform/operators/argocd/repositories/<repo-name>
|
||||
```
|
||||
|
||||
After creating an encrypted file such as
|
||||
`argocd/repositories/railiance-platform.repository.sops.yaml`, apply it with:
|
||||
|
||||
```bash
|
||||
ARGOCD_REPOSITORY_SECRET=argocd/repositories/railiance-platform.repository.sops.yaml \
|
||||
make argocd-repo-apply
|
||||
```
|
||||
|
||||
21
argocd/repositories/issue-core.repository.sops.yaml.template
Normal file
21
argocd/repositories/issue-core.repository.sops.yaml.template
Normal file
@@ -0,0 +1,21 @@
|
||||
# Copy to issue-core.repository.sops.yaml, fill from the approved
|
||||
# operator/OpenBao path, then encrypt with:
|
||||
# sops -e -i argocd/repositories/issue-core.repository.sops.yaml
|
||||
#
|
||||
# Do not commit plaintext credentials.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: issue-core-repository
|
||||
namespace: argocd
|
||||
labels:
|
||||
argocd.argoproj.io/secret-type: repository
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: gitops
|
||||
stringData:
|
||||
type: git
|
||||
project: railiance-tenants
|
||||
url: https://gitea.coulomb.social/coulomb/issue-core.git
|
||||
username: CHANGE_ME
|
||||
password: CHANGE_ME
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copy to railiance-platform.repository.sops.yaml, fill from the approved
|
||||
# operator/OpenBao path, then encrypt with:
|
||||
# sops -e -i argocd/repositories/railiance-platform.repository.sops.yaml
|
||||
#
|
||||
# Do not commit plaintext credentials.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: railiance-platform-repository
|
||||
namespace: argocd
|
||||
labels:
|
||||
argocd.argoproj.io/secret-type: repository
|
||||
app.kubernetes.io/part-of: railiance-gitops
|
||||
railiance-platform/component: gitops
|
||||
stringData:
|
||||
type: git
|
||||
project: railiance-bootstrap
|
||||
url: https://gitea.coulomb.social/coulomb/railiance-platform.git
|
||||
username: CHANGE_ME
|
||||
password: CHANGE_ME
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
id: CCR-2026-0001
|
||||
kind: credential-change-request
|
||||
schema_version: 1
|
||||
request_type: workload-kv-read
|
||||
title: whynot-design npm publish token lane
|
||||
status: active
|
||||
created: '2026-06-27'
|
||||
updated: '2026-06-29'
|
||||
requester:
|
||||
agent: ops-warden
|
||||
message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
|
||||
reason: Allow ops-warden to proxy caller-scoped access to whynot-design's npm publish
|
||||
token.
|
||||
review:
|
||||
required: true
|
||||
required_approvers:
|
||||
- platform-operator
|
||||
comments:
|
||||
- at: '2026-06-27T21:40:22+00:00'
|
||||
reviewer: bernd.worsch
|
||||
decision: binding_confirmed
|
||||
comment: 'Confirmed in chat: groups=whynot-design is the intended KeyCape/NetKingdom
|
||||
binding for the whynot-design npm publish lane.'
|
||||
- at: '2026-06-27T22:06:18+00:00'
|
||||
reviewer: human
|
||||
decision: approved
|
||||
comment: 'State Hub decision 250669d0-8475-4527-9624-cd072249f9a9: APPROVE: scoped
|
||||
path and confirmed binding are acceptable'
|
||||
- at: '2026-06-27T22:54:20+00:00'
|
||||
reviewer: bernd.worsch
|
||||
decision: scope_corrected_requires_review
|
||||
comment: Corrected tenant from whynot-design to coulomb per operator clarification.
|
||||
The previous approval covered platform/workloads/whynot-design/whynot-design/npm-publish
|
||||
and must not be reused for the corrected platform/workloads/coulomb/whynot-design/npm-publish
|
||||
scope.
|
||||
- at: '2026-06-27T23:23:19+00:00'
|
||||
reviewer: human
|
||||
decision: approved
|
||||
comment: 'State Hub decision e6381a56-6b04-4fd5-b2de-f3ef59cde888: APPROVE: We
|
||||
fixed the path using coulomb as the org/tenant.'
|
||||
target:
|
||||
domain: financials
|
||||
tenant: coulomb
|
||||
workload: whynot-design
|
||||
environment: production
|
||||
purpose: npm package publishing through ops-warden caller-scoped fetch/exec
|
||||
openbao:
|
||||
mount: platform
|
||||
kv_path: platform/workloads/coulomb/whynot-design/npm-publish
|
||||
fields:
|
||||
- NPM_AUTH_TOKEN
|
||||
policy_name: workload-kv-read-whynot-design-npm-publish
|
||||
policy_file: openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl
|
||||
auth:
|
||||
method: oidc
|
||||
mount: netkingdom
|
||||
role: whynot-design-workload-kv-read
|
||||
allowed_redirect_uris:
|
||||
- https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback
|
||||
- http://localhost:8250/oidc/callback
|
||||
- http://127.0.0.1:8250/oidc/callback
|
||||
oidc_scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
- groups
|
||||
user_claim: sub
|
||||
groups_claim: groups
|
||||
bound_claims:
|
||||
groups:
|
||||
- whynot-design
|
||||
bound_claims_confirmed: true
|
||||
policies:
|
||||
- workload-kv-read-whynot-design-npm-publish
|
||||
ttl: 15m
|
||||
access_frontdoor:
|
||||
type: ops-warden
|
||||
catalog_id: whynot-design-npm-publish
|
||||
selector: npm publish token
|
||||
command: warden access whynot-design-npm-publish --exec -- npm publish
|
||||
resolvable: true
|
||||
readiness: ready
|
||||
activation: verified-positive-and-negative-caller-verification
|
||||
risk:
|
||||
classification: high
|
||||
notes:
|
||||
- Grants read access to the credential used to publish npm packages.
|
||||
- Uses a publish-specific catalog id; a future read-only npm token must use a separate
|
||||
catalog id.
|
||||
- The OIDC bound claim was confirmed in review; re-confirm if the claim changes.
|
||||
- ops-warden must proxy the read as the caller and must not retain the token value.
|
||||
verification:
|
||||
positive:
|
||||
- Approved whynot-design identity can fetch field NPM_AUTH_TOKEN through OpenBao
|
||||
or ops-warden.
|
||||
negative:
|
||||
- Non-whynot identity cannot read the path or field.
|
||||
activation_conditions:
|
||||
- Policy applied with platform-admin/operator authority.
|
||||
- OIDC role bound to confirmed whynot-design claim or approved service account.
|
||||
- Secret value provisioned directly in OpenBao through approved operator custody.
|
||||
- Positive and negative verification recorded with non-secret audit ids or timestamps.
|
||||
evidence:
|
||||
- at: '2026-06-28T10:37:42+00:00'
|
||||
actor: codex
|
||||
kind: non_secret_openbao_apply_check
|
||||
result: passed
|
||||
details:
|
||||
- Policy read succeeded for workload-kv-read-whynot-design-npm-publish.
|
||||
- OIDC role read showed the whynot-design bound claim, read policy, and callback URIs.
|
||||
- Metadata read showed catalog-id whynot-design-npm-publish.
|
||||
- Secret field presence check found NPM_AUTH_TOKEN without printing or recording the value.
|
||||
- at: '2026-06-28T11:20:06+00:00'
|
||||
actor: codex
|
||||
kind: non_secret_oidc_role_correction
|
||||
result: applied
|
||||
details:
|
||||
- Positive login reported missing groups claim because the role did not request the groups scope.
|
||||
- Updated auth/netkingdom/role/whynot-design-workload-kv-read with oidc_scopes openid/profile/email/groups.
|
||||
- Added the 127.0.0.1 local CLI callback URI alongside localhost and browser callbacks.
|
||||
- at: '2026-06-28T14:01:47+00:00'
|
||||
actor: codex
|
||||
kind: non_secret_identity_group_check
|
||||
result: applied
|
||||
details:
|
||||
- Positive login advanced from missing groups claim to bound claim mismatch; this confirms the groups scope is now requested.
|
||||
- Live LLDAP group inventory did not contain whynot-design before this check.
|
||||
- Created and verified the whynot-design LLDAP group for the approved OpenBao bound claim.
|
||||
- No user membership was changed; positive verification still requires the authenticating account to be explicitly added to whynot-design.
|
||||
- at: '2026-06-28T15:22:29+00:00'
|
||||
actor: bernd.worsch
|
||||
kind: positive_fetch_verification
|
||||
result: passed
|
||||
details:
|
||||
- Attended OIDC login for auth/netkingdom/role/whynot-design-workload-kv-read succeeded with workload-kv-read-whynot-design-npm-publish policy.
|
||||
- NPM_AUTH_TOKEN field fetch from platform/workloads/coulomb/whynot-design/npm-publish exited successfully with output redirected to /dev/null.
|
||||
- The secret value was not printed or recorded.
|
||||
- A short-lived OpenBao client token was printed by the CLI login output and was revoked by accessor immediately after the report.
|
||||
- Negative denial verification is still pending; keep the front door non-resolvable until it passes.
|
||||
- at: '2026-06-28T22:06:43+00:00'
|
||||
actor: bernd.worsch
|
||||
kind: negative_denial_verification
|
||||
result: passed
|
||||
details:
|
||||
- platform-root was temporarily removed from the whynot-design LLDAP group for the attended negative check.
|
||||
- OIDC login for auth/netkingdom/role/whynot-design-workload-kv-read failed with a groups bound-claim mismatch.
|
||||
- No OpenBao client token was issued for the negative identity, and no NPM_AUTH_TOKEN value was printed or recorded.
|
||||
- at: '2026-06-28T22:08:50+00:00'
|
||||
actor: codex
|
||||
kind: identity_group_restore
|
||||
result: passed
|
||||
details:
|
||||
- Restored platform-root membership in the whynot-design LLDAP group after negative verification.
|
||||
- Verified whynot-design membership contains platform-root and no unexpected additional users.
|
||||
- Positive and negative verification gates are now complete; access_frontdoor is ready/resolvable.
|
||||
lifecycle:
|
||||
deactivate: Disable ops-warden catalog entry and remove or detach auth role policy.
|
||||
rotate: Replace NPM_AUTH_TOKEN value directly in OpenBao and record non-secret rotation
|
||||
evidence.
|
||||
compromised: Immediately deactivate access front door, rotate npm token, record
|
||||
blast-radius notes, and open incident follow-up tasks.
|
||||
state_hub:
|
||||
workplan_id: RAILIANCE-WP-0007
|
||||
related_workplan_id: RAILIANCE-WP-0006
|
||||
ops_warden_reply_message_id: b175c561-7858-43f5-a309-949b0dede1b4
|
||||
ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
|
||||
superseded_decision_id: 250669d0-8475-4527-9624-cd072249f9a9
|
||||
superseded_decision_resolved_at: '2026-06-27T22:04:32.956077Z'
|
||||
superseded_decision_reason: tenant/workload scope corrected before secret provisioning
|
||||
decision_id: e6381a56-6b04-4fd5-b2de-f3ef59cde888
|
||||
decision_api_url: http://127.0.0.1:8000/decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888
|
||||
decision_dashboard_url: http://127.0.0.1:3000/decisions
|
||||
decision_resolved_at: '2026-06-27T23:16:21.905924Z'
|
||||
@@ -0,0 +1,105 @@
|
||||
id: CCR-2026-0002
|
||||
kind: credential-change-request
|
||||
schema_version: 1
|
||||
request_type: workload-kv-read
|
||||
title: issue-core runtime ingestion key lane
|
||||
status: proposed
|
||||
created: '2026-06-27'
|
||||
updated: '2026-06-30'
|
||||
requester:
|
||||
agent: ops-warden
|
||||
message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
|
||||
reason: Confirm and provision the issue-core workload KV lane requested in the ops-warden
|
||||
batch.
|
||||
review:
|
||||
required: true
|
||||
required_approvers:
|
||||
- platform-operator
|
||||
- issue-core-owner
|
||||
comments:
|
||||
- at: '2026-06-29T22:53:03+00:00'
|
||||
reviewer: codex
|
||||
decision: metadata_review_binding_confirmed
|
||||
comment: Live cluster metadata on 2026-06-30 confirms ExternalSecret issue-core/issue-core-runtime
|
||||
is Ready=True (SecretSynced) and maps ISSUE_CORE_API_KEY plus GITEA_BACKEND_TOKEN
|
||||
from platform/workloads/issue-core/issue-core/issue-core-runtime. The workload
|
||||
Deployment uses the default service account; OpenBao auth for this delivery
|
||||
path is the platform ClusterSecretStore/openbao role external-secrets-issue-core
|
||||
bound to service account external-secrets/external-secrets. Keep CCR status
|
||||
proposed until platform/operator and issue-core-owner approval.
|
||||
target:
|
||||
domain: financials
|
||||
tenant: issue-core
|
||||
workload: issue-core
|
||||
environment: production
|
||||
purpose: issue-core runtime ingestion through OpenBao workload KV and External Secrets
|
||||
openbao:
|
||||
mount: platform
|
||||
kv_path: platform/workloads/issue-core/issue-core/issue-core-runtime
|
||||
fields:
|
||||
- ISSUE_CORE_API_KEY
|
||||
- GITEA_BACKEND_TOKEN
|
||||
policy_name: workload-kv-read-issue-core-runtime
|
||||
policy_file: openbao/policies/workload-kv-read-issue-core-runtime.hcl
|
||||
auth:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
role: external-secrets-issue-core
|
||||
bound_claims:
|
||||
service_account_names:
|
||||
- external-secrets
|
||||
service_account_namespaces:
|
||||
- external-secrets
|
||||
bound_claims_confirmed: true
|
||||
policies:
|
||||
- workload-kv-read-issue-core-runtime
|
||||
ttl: 15m
|
||||
access_frontdoor:
|
||||
type: ops-warden
|
||||
catalog_id: issue-core-ingestion-api-key
|
||||
selector: issue-core ingestion API key
|
||||
command: warden access issue-core-ingestion-api-key --fetch ISSUE_CORE_API_KEY
|
||||
resolvable: false
|
||||
readiness: template
|
||||
activation: draft-until-ccr-verified
|
||||
delivery:
|
||||
surface: external-secrets
|
||||
target: ExternalSecret issue-core/issue-core-runtime -> Secret issue-core-runtime
|
||||
in the issue-core namespace
|
||||
risk:
|
||||
classification: high
|
||||
notes:
|
||||
- Grants read access to issue-core runtime ingestion credentials through the platform
|
||||
External Secrets path.
|
||||
- GITEA_BACKEND_TOKEN remains included because the live issue-core ExternalSecret
|
||||
maps it alongside ISSUE_CORE_API_KEY; remove it before approval only if the issue-core
|
||||
owner confirms it is no longer required.
|
||||
- The Kubernetes auth subject is the External Secrets operator service account external-secrets/external-secrets,
|
||||
with ClusterSecretStore usage limited to the issue-core namespace.
|
||||
- ops-warden must proxy reads as the caller and must not retain token values.
|
||||
verification:
|
||||
positive:
|
||||
- ExternalSecret issue-core/issue-core-runtime is Ready=True and syncs the configured
|
||||
fields without printing values.
|
||||
- Approved issue-core runtime can consume the resulting Kubernetes Secret without
|
||||
exposing values.
|
||||
negative:
|
||||
- A namespace outside the approved ClusterSecretStore condition cannot use this
|
||||
store to read the path.
|
||||
- A service account outside external-secrets/external-secrets cannot authenticate
|
||||
through the External Secrets OpenBao role.
|
||||
activation_conditions:
|
||||
- Policy applied with platform-admin/operator authority.
|
||||
- Kubernetes auth role bound to external-secrets/external-secrets for the issue-core
|
||||
External Secrets delivery path.
|
||||
- Secret values provisioned directly in OpenBao through approved operator custody.
|
||||
- Positive and negative verification recorded with non-secret audit ids or timestamps.
|
||||
lifecycle:
|
||||
deactivate: Disable ops-warden catalog entry and remove or detach auth role policy.
|
||||
rotate: Replace issue-core runtime secret values directly in OpenBao and record
|
||||
non-secret rotation evidence.
|
||||
compromised: Immediately deactivate access front door, rotate affected values, record
|
||||
blast-radius notes, and open incident follow-up tasks.
|
||||
state_hub:
|
||||
workplan_id: RAILIANCE-WP-0007
|
||||
ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
|
||||
@@ -0,0 +1,102 @@
|
||||
id: CCR-2026-0003
|
||||
kind: credential-change-request
|
||||
schema_version: 1
|
||||
request_type: workload-kv-read
|
||||
title: llm-connect OpenRouter provider key lane
|
||||
status: proposed
|
||||
created: '2026-06-27'
|
||||
updated: '2026-06-30'
|
||||
requester:
|
||||
agent: ops-warden
|
||||
message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
|
||||
reason: Confirm and provision the llm-connect OpenRouter workload KV lane requested
|
||||
in the ops-warden batch.
|
||||
review:
|
||||
required: true
|
||||
required_approvers:
|
||||
- platform-operator
|
||||
- activity-core-owner
|
||||
comments:
|
||||
- at: '2026-06-29T22:53:03+00:00'
|
||||
reviewer: codex
|
||||
decision: metadata_review_binding_confirmed_pending_owner_approval
|
||||
comment: Live cluster metadata on 2026-06-30 confirms namespace activity-core
|
||||
exists and the External Secrets operator service account external-secrets/external-secrets
|
||||
exists. The proposed llm-connect service account does not exist; the llm-connect
|
||||
Deployment currently uses the default service account. Updated the proposed
|
||||
OpenBao auth binding to the platform ESO pattern with role external-secrets-activity-core.
|
||||
No activity-core ExternalSecret exists yet; a namespace-limited ClusterSecretStore
|
||||
source manifest was added for future rollout. Keep CCR status proposed until
|
||||
platform/operator and activity-core-owner approval.
|
||||
target:
|
||||
domain: financials
|
||||
tenant: activity-core
|
||||
workload: llm-connect
|
||||
environment: production
|
||||
purpose: llm-connect provider access through OpenBao workload KV and External Secrets
|
||||
openbao:
|
||||
mount: platform
|
||||
kv_path: platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets
|
||||
fields:
|
||||
- OPENROUTER_API_KEY
|
||||
policy_name: workload-kv-read-llm-connect-provider-secrets
|
||||
policy_file: openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl
|
||||
auth:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
role: external-secrets-activity-core
|
||||
bound_claims:
|
||||
service_account_names:
|
||||
- external-secrets
|
||||
service_account_namespaces:
|
||||
- external-secrets
|
||||
bound_claims_confirmed: true
|
||||
policies:
|
||||
- workload-kv-read-llm-connect-provider-secrets
|
||||
ttl: 15m
|
||||
access_frontdoor:
|
||||
type: ops-warden
|
||||
catalog_id: llm-connect-openrouter-api-key
|
||||
selector: llm-connect OpenRouter API key
|
||||
command: warden access llm-connect-openrouter-api-key --fetch OPENROUTER_API_KEY
|
||||
resolvable: false
|
||||
readiness: template
|
||||
activation: draft-until-ccr-verified
|
||||
delivery:
|
||||
surface: external-secrets
|
||||
target: ExternalSecret to Secret llm-connect-provider-secrets in the activity-core
|
||||
namespace
|
||||
risk:
|
||||
classification: high
|
||||
notes:
|
||||
- Grants read access to the provider key used by llm-connect for OpenRouter requests
|
||||
through the platform External Secrets path.
|
||||
- The Kubernetes auth subject is the External Secrets operator service account external-secrets/external-secrets,
|
||||
with ClusterSecretStore usage limited to the activity-core namespace.
|
||||
- ops-warden must proxy reads as the caller and must not retain token values.
|
||||
verification:
|
||||
positive:
|
||||
- An approved activity-core ExternalSecret can sync field OPENROUTER_API_KEY to
|
||||
Secret llm-connect-provider-secrets without printing the value.
|
||||
- The llm-connect runtime can consume the resulting Kubernetes Secret without exposing
|
||||
values.
|
||||
negative:
|
||||
- A namespace outside the approved ClusterSecretStore condition cannot use this
|
||||
store to read the path.
|
||||
- A service account outside external-secrets/external-secrets cannot authenticate
|
||||
through the External Secrets OpenBao role.
|
||||
activation_conditions:
|
||||
- Policy applied with platform-admin/operator authority.
|
||||
- Kubernetes auth role bound to external-secrets/external-secrets for the activity-core
|
||||
External Secrets delivery path.
|
||||
- Secret value provisioned directly in OpenBao through approved operator custody.
|
||||
- Positive and negative verification recorded with non-secret audit ids or timestamps.
|
||||
lifecycle:
|
||||
deactivate: Disable ops-warden catalog entry and remove or detach auth role policy.
|
||||
rotate: Replace OPENROUTER_API_KEY directly in OpenBao and record non-secret rotation
|
||||
evidence.
|
||||
compromised: Immediately deactivate access front door, rotate the provider key,
|
||||
record blast-radius notes, and open incident follow-up tasks.
|
||||
state_hub:
|
||||
workplan_id: RAILIANCE-WP-0007
|
||||
ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
|
||||
123
credential-grants/catalog.yaml
Normal file
123
credential-grants/catalog.yaml
Normal file
@@ -0,0 +1,123 @@
|
||||
version: 1
|
||||
updated: "2026-06-27"
|
||||
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
|
||||
issuer_policy: credential-broker-warden-sign-issuer
|
||||
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
|
||||
- kubernetes-auth
|
||||
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"
|
||||
kubernetes_auth:
|
||||
mount: auth/kubernetes
|
||||
role: credential-broker-warden-sign
|
||||
audience: openbao
|
||||
service_account_names:
|
||||
- credential-broker
|
||||
- ops-warden-smoke
|
||||
namespaces:
|
||||
- openbao
|
||||
- ops-warden
|
||||
audit:
|
||||
openbao_audit_required: true
|
||||
state_hub_metadata_allowed: true
|
||||
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
|
||||
224
docs/argocd-gitops.md
Normal file
224
docs/argocd-gitops.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# ArgoCD GitOps Contract
|
||||
|
||||
Railiance ArgoCD is the cluster sync engine. This repo owns only the shared
|
||||
platform contract around GitOps trust, guardrails, and OpenBao-backed secret
|
||||
delivery. Application workload manifests remain in the owning application
|
||||
repos.
|
||||
|
||||
## Ownership Boundary
|
||||
|
||||
`railiance-platform` owns:
|
||||
|
||||
- ArgoCD AppProject guardrails for Railiance tenant workloads.
|
||||
- The root app-of-apps entrypoint.
|
||||
- Repository credential registration templates.
|
||||
- Secret delivery conventions through OpenBao and External Secrets Operator.
|
||||
|
||||
Tenant repos own:
|
||||
|
||||
- Container images.
|
||||
- Workload manifests under `k8s/railiance/`.
|
||||
- The proposed ArgoCD `Application` manifest for platform review.
|
||||
- Application config that contains no secret values.
|
||||
|
||||
Cluster/runtime ownership remains outside this repo: installing or upgrading
|
||||
ArgoCD itself belongs with the cluster layer.
|
||||
|
||||
## Bootstrap Layout
|
||||
|
||||
```text
|
||||
argocd/bootstrap/
|
||||
00-railiance-bootstrap-project.yaml
|
||||
01-railiance-tenants-project.yaml
|
||||
10-railiance-apps-root.application.yaml
|
||||
|
||||
argocd/applications/
|
||||
*.application.yaml
|
||||
|
||||
argocd/repositories/
|
||||
*.repository.sops.yaml
|
||||
```
|
||||
|
||||
The bootstrap is applied once by an operator. If the Git source is private,
|
||||
apply the encrypted `railiance-platform` repository Secret first so the root
|
||||
Application can sync this repo:
|
||||
|
||||
```bash
|
||||
ARGOCD_REPOSITORY_SECRET=argocd/repositories/railiance-platform.repository.sops.yaml \
|
||||
make argocd-repo-apply
|
||||
make argocd-bootstrap-dry-run
|
||||
make argocd-bootstrap-deploy
|
||||
make argocd-status
|
||||
```
|
||||
|
||||
After that, `railiance-apps-root` syncs tenant Application manifests from
|
||||
`argocd/applications/`.
|
||||
|
||||
## Repository Registration
|
||||
|
||||
Every Git source repo used by ArgoCD must be registered in the `argocd`
|
||||
namespace with an ArgoCD repository Secret. Use one read-only deploy token or
|
||||
deploy key per repo unless an operator approves a narrower shared credential
|
||||
model.
|
||||
|
||||
Repository credentials are operator credentials, not workload secrets. Store
|
||||
their source material in OpenBao under:
|
||||
|
||||
```text
|
||||
platform/operators/argocd/repositories/<repo-name>
|
||||
```
|
||||
|
||||
Create an encrypted repository Secret from the matching template:
|
||||
|
||||
```bash
|
||||
cp argocd/repositories/issue-core.repository.sops.yaml.template \
|
||||
argocd/repositories/issue-core.repository.sops.yaml
|
||||
sops -e -i argocd/repositories/issue-core.repository.sops.yaml
|
||||
```
|
||||
|
||||
Apply only encrypted files:
|
||||
|
||||
```bash
|
||||
ARGOCD_REPOSITORY_SECRET=argocd/repositories/issue-core.repository.sops.yaml \
|
||||
make argocd-repo-apply
|
||||
```
|
||||
|
||||
Do not commit plaintext deploy tokens, passwords, SSH private keys, OpenBao
|
||||
tokens, or ArgoCD API tokens.
|
||||
|
||||
## Tenant Application Contract
|
||||
|
||||
Tenant Applications are thin routing manifests reviewed into
|
||||
`argocd/applications/`. The workload source remains in the tenant repo,
|
||||
normally:
|
||||
|
||||
```text
|
||||
k8s/railiance/
|
||||
```
|
||||
|
||||
Default Application shape:
|
||||
|
||||
```yaml
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: example-service
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: railiance-tenants
|
||||
source:
|
||||
repoURL: https://gitea.coulomb.social/coulomb/example-service.git
|
||||
targetRevision: main
|
||||
path: k8s/railiance
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: example-service
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- PruneLast=true
|
||||
```
|
||||
|
||||
Use sync waves only for real dependency ordering. Secret delivery resources
|
||||
should sync before Deployments that consume them.
|
||||
|
||||
## Secret Delivery
|
||||
|
||||
OpenBao is the canonical runtime secret custody authority.
|
||||
|
||||
Default pattern:
|
||||
|
||||
1. Store workload secret material in OpenBao.
|
||||
2. Use External Secrets Operator to materialize a Kubernetes Secret in the
|
||||
workload namespace.
|
||||
3. Reference that Kubernetes Secret from the Deployment, Job, or CronJob.
|
||||
|
||||
Path convention for workload credential custody:
|
||||
|
||||
```text
|
||||
platform/workloads/<tenant-or-org>/<workload>/<secret-purpose>
|
||||
```
|
||||
|
||||
Kubernetes namespace and service-account bounds belong in the OpenBao auth role
|
||||
or External Secrets binding, not in the tenant segment unless the namespace is
|
||||
itself the approved workload identity.
|
||||
|
||||
Use CSI-mounted files only for workloads that need file references, sharper
|
||||
mount boundaries, or refresh behavior that should not rewrite application
|
||||
manifests. Do not use the OpenBao injector in the current deployment.
|
||||
|
||||
For `issue-core`, the expected custody shape is:
|
||||
|
||||
```text
|
||||
platform/workloads/issue-core/issue-core/issue-core-runtime
|
||||
```
|
||||
|
||||
with properties such as:
|
||||
|
||||
```text
|
||||
ISSUE_CORE_API_KEY
|
||||
GITEA_BACKEND_TOKEN
|
||||
```
|
||||
|
||||
The exact ExternalSecret manifest belongs with `issue-core` workload
|
||||
manifests, because it is part of that service's runtime deployment.
|
||||
|
||||
## AppProject Guardrails
|
||||
|
||||
`railiance-bootstrap` allows the root app to manage ArgoCD `Application`
|
||||
objects in the `argocd` namespace.
|
||||
|
||||
`railiance-tenants` allows ordinary namespaced workload resources and namespace
|
||||
creation. It does not allow tenant Applications to create CRDs, ClusterRoles,
|
||||
ClusterRoleBindings, or other cluster-admin resources.
|
||||
|
||||
If a tenant needs a cluster-scoped platform resource, create a new
|
||||
platform-owned workplan instead of broadening the tenant project by default.
|
||||
|
||||
## Platform Add-ons
|
||||
|
||||
External Secrets Operator is a platform-owned add-on because it installs CRDs,
|
||||
webhooks, and cluster RBAC. Tenant Applications must not install or upgrade it.
|
||||
|
||||
The GitOps contract uses:
|
||||
|
||||
- `railiance-platform-addons` AppProject for cluster add-ons.
|
||||
- `external-secrets` ArgoCD Application for the public Helm chart.
|
||||
- `openbao-secretstore` ArgoCD Application for the OpenBao
|
||||
`ClusterSecretStore`.
|
||||
- OpenBao Kubernetes auth role `external-secrets-issue-core` for the
|
||||
issue-core pilot.
|
||||
- OpenBao Kubernetes auth role `external-secrets-activity-core` for the
|
||||
activity-core/llm-connect provider-secret lane once approved.
|
||||
|
||||
`ClusterSecretStore/openbao` is limited to the `issue-core` namespace.
|
||||
`ClusterSecretStore/openbao-activity-core` is limited to the `activity-core`
|
||||
namespace and is intended for the llm-connect provider-secret lane. Broaden or
|
||||
add stores only with platform review.
|
||||
|
||||
Configure the OpenBao side without printing token values:
|
||||
|
||||
```bash
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token \
|
||||
make openbao-configure-external-secrets-issue-core
|
||||
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token \
|
||||
make openbao-configure-external-secrets-activity-core
|
||||
```
|
||||
|
||||
The helper keeps Kubernetes auth in local-reviewer mode: OpenBao rereads its
|
||||
own mounted service-account token and CA file instead of storing an expiring
|
||||
reviewer JWT.
|
||||
|
||||
Then sync ArgoCD and verify:
|
||||
|
||||
```bash
|
||||
make argocd-bootstrap-deploy
|
||||
make argocd-status
|
||||
kubectl -n external-secrets get deploy,pod
|
||||
kubectl get clustersecretstore.external-secrets.io openbao
|
||||
```
|
||||
308
docs/credential-broker.md
Normal file
308
docs/credential-broker.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Credential Request And Lease Broker
|
||||
|
||||
**Workplan:** `RAILIANCE-WP-0005`
|
||||
**Owner:** `railiance-platform`
|
||||
**Status:** source implementation complete; live verification pending approved token path
|
||||
|
||||
This document records the Railiance credential broker ownership decision and
|
||||
the first implementation contract for short-lived OpenBao credential leases.
|
||||
|
||||
## 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.
|
||||
|
||||
## OpenBao Token Roles
|
||||
|
||||
OpenBao-token grants are configured from source with:
|
||||
|
||||
- an issuer policy under `openbao/policies/`;
|
||||
- an `auth/token/roles/<role>` token role with allowed policies, disallowed
|
||||
admin policies, non-renewable TTL bounds, no default policy, and orphan token
|
||||
issuance;
|
||||
- verification that reads the issuer policy, token role, and target workload
|
||||
policy before any smoke token is minted.
|
||||
|
||||
Dry-run the current grant configuration with:
|
||||
|
||||
```bash
|
||||
make openbao-token-grants-dry-run
|
||||
make openbao-verify-token-grants-dry-run
|
||||
```
|
||||
|
||||
Live application uses an operator-approved OpenBao token from
|
||||
`OPENBAO_TOKEN_FILE` or an interactive hidden prompt. The token is passed to the
|
||||
OpenBao pod through stdin, never through argv:
|
||||
|
||||
```bash
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-configure-token-grants
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-verify-token-grants
|
||||
```
|
||||
|
||||
The smoke verifier can mint a short-lived child token, confirm that it can list
|
||||
`ssh/roles`, confirm that it cannot list unrelated secret engines, and revoke
|
||||
the token by accessor:
|
||||
|
||||
```bash
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-verify-token-grants-smoke
|
||||
```
|
||||
|
||||
## 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.
|
||||
For the pilot grant, `request --delivery kubernetes-auth` returns only
|
||||
non-secret OpenBao auth metadata such as the auth mount, role, service account
|
||||
names, and namespaces; it does not mint or print a bearer token.
|
||||
|
||||
The denied modes are absolute unless a later ADR updates the catalog:
|
||||
|
||||
- `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 source helper MVP lives at `scripts/credential.py` until this flow graduates
|
||||
into a packaged command. It supports the same core shape:
|
||||
|
||||
```bash
|
||||
scripts/credential.py request --grant ops-warden/warden-sign --purpose flex-auth-openbao-smoke
|
||||
scripts/credential.py exec --grant ops-warden/warden-sign --purpose flex-auth-openbao-smoke -- \
|
||||
SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh
|
||||
scripts/credential.py status <lease-accessor>
|
||||
scripts/credential.py revoke <lease-accessor>
|
||||
```
|
||||
|
||||
`request` defaults to `local-token-file`: the raw child token is written only to
|
||||
`.local/credential-leases/` with mode `0600`, and stdout contains the lease
|
||||
handle/accessor plus metadata. `--delivery response-wrap` returns an OpenBao
|
||||
wrapping token for attended handoff, not the raw child token.
|
||||
|
||||
`exec` mints a bounded child token, injects it as `VAULT_TOKEN` only into the
|
||||
child process environment, redacts token-looking output, and revokes the token
|
||||
by accessor when the child exits. The helper rejects caller-supplied
|
||||
`VAULT_TOKEN`/`BAO_TOKEN` env assignments and unsafe OpenBao debug/trace log
|
||||
settings.
|
||||
|
||||
Dry-run all helper paths with:
|
||||
|
||||
```bash
|
||||
make credential-helper-dry-run
|
||||
```
|
||||
|
||||
Pass helper global options before the subcommand. For example, if the OpenBao
|
||||
pod has an approved token helper session:
|
||||
|
||||
```bash
|
||||
make credential-exec-ops-warden-smoke CREDENTIAL_HELPER_GLOBAL_ARGS=--use-token-helper
|
||||
```
|
||||
|
||||
The child process receives `VAULT_TOKEN` in its environment. The token is not
|
||||
printed, written to shell history, sent to State Hub, or placed in an LLM
|
||||
prompt.
|
||||
|
||||
## Identity And Authorization
|
||||
|
||||
The helper records the following non-secret request identity fields:
|
||||
|
||||
- `actor`: the requester identity, defaulting to `codex:<local-user>`;
|
||||
- `actor_type`: one of the grant-approved actor classes such as
|
||||
`human-operator`, `approved-agent`, or `ci-runner`;
|
||||
- `subject`: the bound human, agent, CI, or Kubernetes service-account subject.
|
||||
|
||||
Human operators should use the KeyCape/OIDC path with MFA when the grant class
|
||||
or purpose requires it. Agents and CI runners should use stable subject strings
|
||||
that can be mapped to IAM profile claims, for example
|
||||
`agent:codex/railiance-platform` or
|
||||
`system:serviceaccount:<namespace>:<service-account>`. Headless automation must
|
||||
use Kubernetes auth or an explicitly approved non-interactive identity; it must
|
||||
not reuse a human OpenBao token.
|
||||
|
||||
The helper performs local catalog checks before any issuance:
|
||||
|
||||
- purpose is required;
|
||||
- requested TTL must not exceed the grant max TTL;
|
||||
- delivery mode must be allowed by the grant;
|
||||
- actor type must be allowed by the grant.
|
||||
|
||||
Optional flex-auth preflight is enabled with `--flex-auth-url` or `FLEX_AUTH_URL`.
|
||||
The helper posts non-secret request metadata to
|
||||
`/credential-grants/authorize` by default and accepts allow/deny responses using
|
||||
`allowed`, `decision`, or `status` fields plus optional `decision_id` and
|
||||
`reason`. Use `--require-flex-auth` when local preauthorization is not
|
||||
acceptable. Use `--decision-id` to carry an already-approved external decision
|
||||
without calling flex-auth again.
|
||||
|
||||
## State Hub Metadata
|
||||
|
||||
State Hub recording is opt-in through `--record-state-hub` or
|
||||
`CREDENTIAL_RECORD_STATE_HUB=1`. The helper writes request lifecycle notes to
|
||||
`/progress/` with non-secret metadata only:
|
||||
|
||||
- grant id, actor, actor type, subject, purpose, requested TTL, delivery mode;
|
||||
- authorization mode, decision id, and decision reason;
|
||||
- lease accessor, wrapping accessor, or wrapped accessor when available;
|
||||
- status values such as `requested`, `issued`, `wrapped`, `revoked`, or
|
||||
`delegated`.
|
||||
|
||||
It never records raw child tokens, wrapping tokens, token files, passwords,
|
||||
OpenBao root/platform-admin tokens, or command output.
|
||||
|
||||
## Verification And Revocation
|
||||
|
||||
Offline checks:
|
||||
|
||||
```bash
|
||||
make credential-grants-validate
|
||||
make credential-tests
|
||||
make openbao-token-grants-dry-run
|
||||
make openbao-verify-token-grants-dry-run
|
||||
make credential-helper-dry-run
|
||||
```
|
||||
|
||||
Live source-owned checks, once an approved OpenBao issuer token path exists:
|
||||
|
||||
```bash
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-configure-token-grants
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-verify-token-grants-smoke
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make credential-exec-ops-warden-smoke
|
||||
```
|
||||
|
||||
Use CREDENTIAL_HELPER_CHILD_ENV for child-only environment assignments needed by the smoke command, for example a Linux-only PATH that exposes ops-warden tooling. These assignments are passed after the credential helper separator and are not used for token handoff.
|
||||
|
||||
Emergency revocation by accessor:
|
||||
|
||||
```bash
|
||||
scripts/credential.py revoke <lease-accessor>
|
||||
```
|
||||
|
||||
When using `local-token-file`, remove stale local lease material after revoke or
|
||||
expiry:
|
||||
|
||||
```bash
|
||||
find .local/credential-leases -type f -maxdepth 1 -print
|
||||
```
|
||||
|
||||
Response wrapping live verification is manual until a richer integration test
|
||||
exists: unwrap the returned wrapping token once with OpenBao, confirm the second
|
||||
unwrap attempt fails, then revoke the wrapped child token by accessor.
|
||||
|
||||
## Routing And Rollout
|
||||
|
||||
Credential routing remains split by responsibility:
|
||||
|
||||
- `ops-warden` signs SSH certificates only;
|
||||
- OpenBao token or dynamic-lease needs route to `railiance-platform`;
|
||||
- login/MFA routes to KeyCape;
|
||||
- authorization decisions route to flex-auth.
|
||||
|
||||
The rollout sequence is:
|
||||
|
||||
1. `ops-warden/warden-sign` pilot for the flex-auth/ops-warden smoke.
|
||||
2. Platform-readonly token helper for diagnostics.
|
||||
3. Workload-specific grants for application repositories.
|
||||
4. Optional split to a dedicated credential-broker repo if the helper grows
|
||||
beyond platform ownership.
|
||||
|
||||
The workplan can close only after the live warden-sign pilot runs through the
|
||||
helper and the credential routing catalog returns this railiance-platform flow
|
||||
for VAULT_TOKEN/OpenBao-token requests.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Validate and maintain the non-secret grant catalog.
|
||||
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.
|
||||
|
||||
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.
|
||||
284
docs/credential-change-approval.md
Normal file
284
docs/credential-change-approval.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Credential Change Approval Workflow
|
||||
|
||||
This document sketches the operator workflow we want for it-sec and credential
|
||||
changes. The goal is to remove raw OpenBao command authoring from routine human
|
||||
operation while preserving explicit human approval, auditability, and safe
|
||||
handling of secret values.
|
||||
|
||||
## Problem
|
||||
|
||||
The current workflow still asks operators to translate a reviewed intent into
|
||||
OpenBao commands by hand:
|
||||
|
||||
- create or update policies;
|
||||
- create auth roles with the right bound claims;
|
||||
- create or rotate secret paths and fields;
|
||||
- verify positive and negative access;
|
||||
- tell ops-warden or another access front door when a lane may become active.
|
||||
|
||||
That is inefficient and easy to get wrong. It is also hard to review because
|
||||
the actual unit of work is spread across chat, workplans, OpenBao UI screens,
|
||||
State Hub notes, and shell commands.
|
||||
|
||||
## Direction
|
||||
|
||||
Treat OpenBao as the enforcement and audit engine, not the primary review UI.
|
||||
Add a small approval control plane in front of it:
|
||||
|
||||
1. an agent or CLI creates a structured, non-secret credential change request;
|
||||
2. humans review the rendered proposal, risk notes, generated OpenBao plan, and
|
||||
verification plan;
|
||||
3. a human approves or denies with a comment;
|
||||
4. only approved requests can be applied by an operator-controlled helper;
|
||||
5. the helper records non-secret evidence and marks the request active,
|
||||
rejected, deactivated, rotated, or compromised.
|
||||
|
||||
This can be implemented with repo files, State Hub, and CLI/chat integration
|
||||
first. An OpenBao UI extension can come later if the workflow proves itself.
|
||||
|
||||
## Core Object
|
||||
|
||||
The canonical unit is a credential change request, abbreviated `CCR`.
|
||||
|
||||
The CCR must be non-secret. It may contain:
|
||||
|
||||
- stable request id and title;
|
||||
- requester, reviewer, approver, and applier identities;
|
||||
- target domain, tenant, workload, environment, and purpose;
|
||||
- OpenBao mount, path, field names, policy names, and auth role names;
|
||||
- exact non-secret policy HCL or generated policy references;
|
||||
- proposed auth bindings and bound claims;
|
||||
- delivery surface such as ops-warden, External Secrets, CSI, or direct caller
|
||||
fetch;
|
||||
- machine-readable front-door readiness, including `readiness` and
|
||||
`resolvable`;
|
||||
- risk classification and approval requirements;
|
||||
- generated apply plan;
|
||||
- verification plan;
|
||||
- rollback, deactivate, rotate, and compromise response plan;
|
||||
- comments, approvals, denials, and timestamps;
|
||||
- non-secret OpenBao audit request ids or timestamps after execution.
|
||||
|
||||
It must not contain:
|
||||
|
||||
- secret values;
|
||||
- wrapped token values;
|
||||
- root, platform-admin, or issuer tokens;
|
||||
- passwords, API keys, private keys, OTP seeds, unseal shares, or recovery
|
||||
codes;
|
||||
- command output that includes secret values.
|
||||
|
||||
## State Machine
|
||||
|
||||
Suggested states:
|
||||
|
||||
```text
|
||||
draft
|
||||
proposed
|
||||
needs_changes
|
||||
approved
|
||||
denied
|
||||
apply_pending
|
||||
applied
|
||||
verified
|
||||
active
|
||||
deactivated
|
||||
rotated
|
||||
compromised
|
||||
superseded
|
||||
cancelled
|
||||
```
|
||||
|
||||
Only `approved` requests may be applied. Only `verified` requests may become
|
||||
`active`.
|
||||
|
||||
Emergency break-glass work may create a request after the fact, but it must be
|
||||
marked as break-glass, reviewed retrospectively, and linked to audit evidence.
|
||||
|
||||
## Review Surface
|
||||
|
||||
A reviewer should see a concise rendered proposal:
|
||||
|
||||
```text
|
||||
Request: whynot-design npm publish token lane
|
||||
Type: workload-kv-read
|
||||
Mount/path/field:
|
||||
platform/workloads/coulomb/whynot-design/npm-publish
|
||||
NPM_AUTH_TOKEN
|
||||
Policy:
|
||||
workload-kv-read-whynot-design-npm-publish
|
||||
Auth binding:
|
||||
netkingdom OIDC role whynot-design-workload-kv-read
|
||||
bound claim: groups includes whynot-design
|
||||
Access front door:
|
||||
ops-warden whynot-design-npm-publish
|
||||
readiness: template
|
||||
resolvable: false
|
||||
Risk:
|
||||
grants read access to npm publish credential
|
||||
Checks:
|
||||
positive whynot fetch, negative non-whynot denial, OpenBao audit evidence
|
||||
Decision:
|
||||
approve | deny | needs changes
|
||||
Comment:
|
||||
free text
|
||||
```
|
||||
|
||||
The reviewer should not need to know the exact `bao write` syntax. They should
|
||||
be able to discuss the proposal in chat, request changes, and then make a
|
||||
formal decision.
|
||||
|
||||
## Minimal Implementation
|
||||
|
||||
Version 1 should be boring:
|
||||
|
||||
- store CCR files under `credential-change-requests/`;
|
||||
- validate CCR schema offline;
|
||||
- render a human-readable review summary;
|
||||
- generate OpenBao apply plans from approved CCRs;
|
||||
- require an approval record before apply;
|
||||
- apply only non-secret policy/auth/path metadata;
|
||||
- prompt or delegate separately for secret value entry;
|
||||
- record non-secret evidence in State Hub.
|
||||
|
||||
The first implemented CLI slice is:
|
||||
|
||||
```bash
|
||||
make credential-change-validate
|
||||
make credential-change-render CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
make credential-change-plan CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
make credential-change-status CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
make credential-change-status-json CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
scripts/credential-change.py confirm-binding CCR-2026-0001 --reviewer <name> --comment "..."
|
||||
scripts/credential-change.py approve CCR-2026-0001 --reviewer <name> --comment "..."
|
||||
scripts/credential-change.py deny CCR-2026-0001 --reviewer <name> --comment "..."
|
||||
scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer <name> --comment "..."
|
||||
make credential-change-sync-decision CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
make credential-change-apply-plan CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
make credential-change-operator-commands CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
make credential-change-runbook CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
scripts/credential-change.py runbook CCR-2026-0001 --execute-metadata --actor <operator> --confirm "APPLY CCR-2026-0001"
|
||||
scripts/credential-change.py record-evidence CCR-2026-0001 --actor <operator> --kind positive_verification --result passed --detail "<non-secret audit reference>" --record-state-hub
|
||||
make credential-change-lifecycle-plan CREDENTIAL_CHANGE=CCR-2026-0001 CREDENTIAL_CHANGE_LIFECYCLE_ACTION=deactivate
|
||||
scripts/credential-change.py lifecycle-event CCR-2026-0001 --action compromise --actor <operator> --reason "<non-secret reason>" --detail "<non-secret evidence>" --blast-radius "<non-secret scope>" --follow-up "<task/ref>" --record-state-hub
|
||||
scripts/credential-change.py import-inventory CCR-YYYY-NNNN --title "existing lane" --tenant <tenant> --workload <workload> --environment production --purpose "<purpose>" --kv-path platform/workloads/<tenant>/<workload>/<purpose> --field <FIELD_NAME> --auth-method oidc --auth-mount netkingdom --auth-role <role> --bound-claim groups=<group> --bound-claims-confirmed --frontdoor-type ops-warden --catalog-id <catalog-id> --reason "Imported existing lane without secret values"
|
||||
```
|
||||
|
||||
`apply-plan` and `operator-commands` are intentionally guarded: they refuse
|
||||
anything not approved and refuse unconfirmed auth bindings. `operator-commands`
|
||||
renders the reviewed non-secret `bao policy write` and `bao write auth/.../role`
|
||||
commands for a platform operator; the actual secret value is still provisioned
|
||||
through approved OpenBao/operator custody only.
|
||||
|
||||
The same operations can be exposed through chat by having the agent create the
|
||||
proposal, show the rendered summary, then call the CLI only after the human
|
||||
gives an explicit approval phrase.
|
||||
|
||||
## State Hub Role
|
||||
|
||||
State Hub should hold:
|
||||
|
||||
- request lifecycle events;
|
||||
- review comments;
|
||||
- approval/denial decisions;
|
||||
- non-secret apply and verification evidence;
|
||||
- links to workplans and CCR files.
|
||||
|
||||
State Hub should not hold secret values. It can be the first review UI because
|
||||
it already supports messages, progress, task status, and cross-repo
|
||||
coordination.
|
||||
|
||||
For CCR review, create a pending State Hub decision that links to the CCR and
|
||||
contains only non-secret coordinates. Operators can inspect it in the dashboard
|
||||
at `http://127.0.0.1:3000/decisions` and resolve it with a rationale beginning
|
||||
with `APPROVE:`, `DENY:`, or `NEEDS_CHANGES:`. Then run
|
||||
`make credential-change-sync-decision CREDENTIAL_CHANGE=<CCR>` to copy the
|
||||
resolved decision back into the CCR file-backed state.
|
||||
|
||||
## OpenBao Role
|
||||
|
||||
OpenBao remains authoritative for:
|
||||
|
||||
- policy enforcement;
|
||||
- auth method configuration;
|
||||
- token issuance and revocation;
|
||||
- secret storage;
|
||||
- audit logs.
|
||||
|
||||
Where OpenBao supports non-secret metadata on secret paths or auth roles, we can
|
||||
mirror CCR ids and status labels. The workflow must not depend on OpenBao being
|
||||
the only index, because operators need to see proposed, rejected, deactivated,
|
||||
rotated, and compromised items across repos and access front doors.
|
||||
|
||||
## ops-warden Role
|
||||
|
||||
ops-warden should consume only approved and active access lanes.
|
||||
|
||||
For draft requests, ops-warden may create a draft catalog entry that points to
|
||||
the CCR, but it should not activate the entry until the CCR is verified.
|
||||
|
||||
For `warden access --fetch` / `--exec`, the catalog should include the CCR id
|
||||
and refuse active use when the CCR state is not `active`, `readiness` is not
|
||||
`ready`, or `resolvable` is not `true`.
|
||||
|
||||
## Interactive Runbook Role
|
||||
|
||||
The interactive runbook is the operator bridge:
|
||||
|
||||
1. load a CCR;
|
||||
2. show the rendered summary and exact generated plan;
|
||||
3. confirm the request is approved;
|
||||
4. acquire operator authority through an approved path;
|
||||
5. apply the plan;
|
||||
6. ask for attended secret entry when needed;
|
||||
7. run positive and negative verification;
|
||||
8. record non-secret evidence;
|
||||
9. notify downstream front doors such as ops-warden.
|
||||
|
||||
`credential-change.py runbook <CCR>` renders the checklist and exact final
|
||||
confirmation phrase. `--execute-metadata` is intentionally opt-in and requires
|
||||
that phrase; it uses the local `bao` CLI with ambient approved operator
|
||||
authority, writes only policy/auth metadata, and records a non-secret
|
||||
`metadata_apply` evidence entry. Secret value provisioning stays outside the
|
||||
script through approved OpenBao/operator custody. Verification, activation, and
|
||||
manual custody events are recorded with `record-evidence`, whose comments are
|
||||
scanned for known secret markers before the CCR file or State Hub is updated.
|
||||
|
||||
This lets operators safely drive privileged work without needing to remember
|
||||
every OpenBao command.
|
||||
|
||||
## Compromise And Deactivation
|
||||
|
||||
Every active CCR needs a deactivate and rotate path:
|
||||
|
||||
- `deactivated`: access intentionally disabled but not necessarily compromised;
|
||||
- `rotated`: secret value replaced and old value no longer valid;
|
||||
- `compromised`: emergency state requiring immediate disablement, rotation,
|
||||
blast-radius notes, and incident follow-up.
|
||||
|
||||
`lifecycle-plan` renders the attended checklist for each case, including the
|
||||
front-door state change and OpenBao metadata disable commands for deactivation
|
||||
or compromise. `lifecycle-event` records the non-secret lifecycle event in the
|
||||
CCR, sets the CCR status, and marks the access front door disabled, pending
|
||||
verification, or compromised as appropriate. For compromise events it accepts
|
||||
non-secret blast-radius notes and follow-up task references. Existing lanes that
|
||||
predate CCRs can be imported with `import-inventory`, which writes a CCR and
|
||||
matching read-policy artifact from metadata only; it never asks for or stores
|
||||
the secret value.
|
||||
|
||||
The workflow must support marking an existing credential or lane as compromised
|
||||
even when the original request predates this system.
|
||||
|
||||
## Near-Term Target
|
||||
|
||||
Use the whynot-design npm token lane as the pilot:
|
||||
|
||||
1. encode the existing non-secret lane as a CCR;
|
||||
2. render it for review;
|
||||
3. approve or request changes from chat;
|
||||
4. generate/apply the OpenBao policy and auth role only after approval;
|
||||
5. provision the secret value by attended operator custody;
|
||||
6. verify and activate the ops-warden catalog entry.
|
||||
|
||||
Once that path feels good, reuse it for the sibling workload-KV lanes and the
|
||||
credential broker's OpenBao token-role gates.
|
||||
127
docs/openbao-approved-automation-delegation.md
Normal file
127
docs/openbao-approved-automation-delegation.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# OpenBao Approved Automation Delegation
|
||||
|
||||
This document specifies the narrow OpenBao metadata surface that approved
|
||||
credential-change automation may mutate. It exists to avoid routine use of broad
|
||||
`platform-admin` while keeping secret values under operator custody.
|
||||
|
||||
## Scope
|
||||
|
||||
The delegated applier is for reviewed metadata only:
|
||||
|
||||
- ACL policies generated from approved CCRs;
|
||||
- auth roles bound to reviewed OIDC claims or Kubernetes service accounts;
|
||||
- credential-broker issuer policies and token roles generated from reviewed
|
||||
grant catalog entries;
|
||||
- readback and capability checks needed to prove the mutation landed.
|
||||
|
||||
It must not read, write, print, wrap, unwrap, or proxy managed secret values.
|
||||
Production secret provisioning remains an attended OpenBao/operator custody
|
||||
step unless a later workplan approves a stronger dual-control flow.
|
||||
|
||||
## Environment Boundaries
|
||||
|
||||
Build and development may use sandbox metadata once a non-production OpenBao
|
||||
mount or namespace is declared. Generated test secrets must stay in the sandbox
|
||||
and must never be copied into State Hub, prompts, Git, or chat.
|
||||
|
||||
The non-production applier policy candidate is
|
||||
`openbao/policies/credential-change-nonprod-applier.hcl`. It currently grants
|
||||
only metadata writes, matching the no-secret-value rule used in production.
|
||||
Any future generated test-secret path needs a separate CCR-backed approval so
|
||||
it cannot silently expand this delegation.
|
||||
|
||||
Test and staging may apply reviewed metadata after owner review. Verification
|
||||
must include positive and negative access checks, and evidence must be
|
||||
non-secret.
|
||||
|
||||
Production may apply only reviewed non-secret metadata. The production applier
|
||||
policy is `openbao/policies/credential-change-prod-applier.hcl`, and every live
|
||||
run must be preceded by `scripts/credential-change.py applier-dry-run <CCR>`.
|
||||
Unapproved CCRs fail closed before any OpenBao mutation is rendered. Live
|
||||
metadata mutation uses `scripts/credential-change.py applier-apply <CCR>` with
|
||||
an exact `DELEGATED APPLY <CCR-ID>` confirmation phrase and the local `bao` CLI
|
||||
under ambient delegated applier authority; the command does not accept OpenBao
|
||||
tokens in argv.
|
||||
|
||||
## Production Mutation Surface
|
||||
|
||||
| Change class | Allowed OpenBao path | Notes |
|
||||
| --- | --- | --- |
|
||||
| Workload KV read policies | `sys/policies/acl/workload-kv-read-*` | Generated from CCR mount/path/field metadata. |
|
||||
| Credential broker issuer policies | `sys/policies/acl/credential-broker-*-issuer` | Generated from grant catalog metadata. |
|
||||
| OIDC workload roles | `auth/netkingdom/role/*-workload-kv-read` | Bound claims must be confirmed before apply. |
|
||||
| Kubernetes workload roles | `auth/kubernetes/role/*` | Bound service accounts/namespaces must be confirmed before apply. |
|
||||
| Credential broker token roles | `auth/token/roles/credential-broker-*` | Child-token roles only; no root or platform-admin policies. |
|
||||
| Self checks | `auth/token/lookup-self`, `sys/capabilities-self` | Read/update only as required by OpenBao. |
|
||||
|
||||
Denied by omission:
|
||||
|
||||
- `platform/data/*`, `platform/metadata/*`, or any other secret value path;
|
||||
- `sys/*` outside the approved ACL policy prefixes;
|
||||
- `auth/*` outside the approved role prefixes;
|
||||
- `identity/*`, unseal/recovery material, audit devices, mounts, and root/admin
|
||||
policy assignment;
|
||||
- wildcard, parent-directory, or mismatched policy and role names.
|
||||
|
||||
## Local Dry-Run Guardrails
|
||||
|
||||
The CCR dry-run is deliberately stricter than the OpenBao ACL policy. It must:
|
||||
|
||||
1. validate the CCR schema and secret-marker scan;
|
||||
2. require CCR status `approved`, `applied`, `verified`, or `active`;
|
||||
3. require `openbao.auth.bound_claims_confirmed=true`;
|
||||
4. require mount `platform` and path `platform/workloads/...` for workload KV
|
||||
requests;
|
||||
5. require policy names to start with `workload-kv-read-` and remain under
|
||||
`openbao/policies/<policy-name>.hcl`;
|
||||
6. require OIDC roles to stay under `auth/netkingdom/role/*-workload-kv-read`;
|
||||
7. require Kubernetes roles to stay under `auth/kubernetes/role/*-workload-kv-read`
|
||||
or `auth/kubernetes/role/*-secrets-read`;
|
||||
8. render only exact policy and auth-role metadata mutations;
|
||||
9. leave secret value writes and front-door activation out of scope.
|
||||
|
||||
`applier-apply` reuses the same guardrails, renders the dry-run payload before
|
||||
mutation, requires exact confirmation, writes only policy/auth-role metadata,
|
||||
and appends non-secret `delegated_metadata_apply` evidence. For approved CCRs it
|
||||
can advance file-backed status to `applied`; for already applied/verified/active
|
||||
CCRs it records idempotent evidence without moving the lifecycle backward.
|
||||
|
||||
## Required Evidence
|
||||
|
||||
Record only non-secret evidence:
|
||||
|
||||
- CCR id and approval/decision reference;
|
||||
- applier identity and timestamp;
|
||||
- policy name and auth role path;
|
||||
- OpenBao request id or audit timestamp;
|
||||
- positive and negative verification references;
|
||||
- front-door activation confirmation after verification.
|
||||
|
||||
## Applier Identity Setup
|
||||
|
||||
`openbao-apply-credential-change-appliers.py` configures the source-owned
|
||||
metadata applier policies and matching OpenBao token roles:
|
||||
|
||||
- `credential-change-nonprod-applier` uses
|
||||
`openbao/policies/credential-change-nonprod-applier.hcl`;
|
||||
- `credential-change-prod-applier` uses
|
||||
`openbao/policies/credential-change-prod-applier.hcl`.
|
||||
|
||||
The token roles allow only their matching applier policy, explicitly disallow
|
||||
`root` and `platform-admin`, disable the default policy, use service tokens,
|
||||
and do not issue tokens by themselves. Token issuance remains an approved
|
||||
custody path outside this setup script. Use
|
||||
`make openbao-credential-change-appliers-dry-run` before any live apply.
|
||||
|
||||
## Current Production Policy Candidate
|
||||
|
||||
`openbao/policies/credential-change-prod-applier.hcl` is the source candidate
|
||||
for a future production applier identity. It is not a substitute for CCR review;
|
||||
it is the OpenBao-side capability envelope used after local dry-run validation.
|
||||
|
||||
## Current Non-Production Policy Candidate
|
||||
|
||||
`openbao/policies/credential-change-nonprod-applier.hcl` is the source
|
||||
candidate for a build/test/staging applier identity. It is intentionally
|
||||
metadata-only until this repo declares a non-production OpenBao mount or
|
||||
namespace and records live positive/negative evidence for that lane.
|
||||
172
docs/openbao.md
172
docs/openbao.md
@@ -18,15 +18,26 @@ S5 workloads / operators
|
||||
-> openbao-0
|
||||
-> integrated Raft storage on local-path PVC
|
||||
-> audit storage PVC mounted at /openbao/audit
|
||||
|
||||
Platform operators with approved admin identity
|
||||
-> https://bao.coulomb.social
|
||||
-> Traefik Ingress + TLS
|
||||
-> openbao-ui service
|
||||
-> OpenBao UI/API
|
||||
-> KeyCape OIDC at https://kc.coulomb.social for login
|
||||
```
|
||||
|
||||
- OpenBao is the canonical Railiance S3 secrets service.
|
||||
- SOPS/age remains the Git-at-rest bootstrap mechanism.
|
||||
- The first Railiance01 deployment is single-replica Raft, not true HA.
|
||||
- Public ingress is disabled. Operators use `kubectl exec` or port-forwarding.
|
||||
- Browser UI/API exposure is declared for `https://bao.coulomb.social`.
|
||||
Operators authenticate through KeyCape/OIDC with MFA and the
|
||||
`platform-admin` role. Do not use the root token through the browser UI.
|
||||
- `kubectl exec` and port-forwarding remain valid break-glass/operator paths
|
||||
for maintenance and non-browser verification.
|
||||
- TLS is disabled inside the pod listener for this internal-only bootstrap. Add
|
||||
cert-manager-backed internal TLS before exposing OpenBao beyond cluster-local
|
||||
traffic.
|
||||
cert-manager-backed internal TLS before relying on cluster-internal traffic
|
||||
from untrusted namespaces.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -41,6 +52,12 @@ make openbao-deploy
|
||||
make openbao-status
|
||||
```
|
||||
|
||||
`make openbao-deploy` applies `helm/openbao-middleware.yaml` (Traefik
|
||||
rate-limit and HSTS), upgrades the OpenBao Helm release, then applies the
|
||||
KeyCape login overlay gateway (`helm/openbao-ui-overlay-k8s.yaml`). Public
|
||||
ingress for `bao.coulomb.social` targets `openbao-ui-gateway`, not the chart
|
||||
ingress (which stays disabled in `helm/openbao-values.yaml`).
|
||||
|
||||
On Railiance01 directly:
|
||||
|
||||
```bash
|
||||
@@ -51,10 +68,13 @@ sudo env KUBECONFIG=/etc/rancher/k3s/k3s.yaml make openbao-status
|
||||
```
|
||||
|
||||
If the repo is not present on Railiance01 yet, copy only the non-secret values
|
||||
file and run Helm directly:
|
||||
and middleware files, then run Helm directly:
|
||||
|
||||
```bash
|
||||
scp helm/openbao-values.yaml tegwick@92.205.62.239:/tmp/openbao-values.yaml
|
||||
scp helm/openbao-middleware.yaml tegwick@92.205.62.239:/tmp/openbao-middleware.yaml
|
||||
ssh tegwick@92.205.62.239 \
|
||||
'sudo env KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply -f /tmp/openbao-middleware.yaml'
|
||||
ssh tegwick@92.205.62.239 \
|
||||
'sudo env KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm upgrade --install openbao openbao/openbao \
|
||||
--version 0.28.2 \
|
||||
@@ -78,6 +98,8 @@ Expected immediately after install:
|
||||
- `openbao-0` is Running.
|
||||
- `openbao`, `openbao-active`, `openbao-internal`, and `openbao-ui` services
|
||||
exist as cluster-internal services.
|
||||
- After DNS points at the cluster ingress, `https://bao.coulomb.social` serves
|
||||
the OpenBao UI over valid TLS.
|
||||
- data and audit PVCs are Bound.
|
||||
- `bao status` reports `Initialized: false` and `Sealed: true`.
|
||||
|
||||
@@ -213,6 +235,50 @@ Store that token through the approved operator secret path, then revoke or
|
||||
tightly escrow the initial root token. The root token should not become the
|
||||
normal operator credential.
|
||||
|
||||
## SSH Secrets Engine (ops-warden)
|
||||
|
||||
After `openbao-configure-initial`, enable the SSH user CA used by `ops-warden`
|
||||
(`warden sign` via `backend: vault`). This is **NET-WP-0020 T5** / **WP-0008 T2**
|
||||
prerequisite.
|
||||
|
||||
Declarative artifacts:
|
||||
|
||||
- `openbao/ssh/roles-spec.yaml` — `adm-role`, `agt-role`, `atm-role` TTLs
|
||||
- `openbao/policies/warden-sign.hcl` — least-privilege signing policy
|
||||
- `scripts/openbao-apply-ssh-engine.sh` — idempotent apply via `kubectl exec`
|
||||
- `scripts/openbao-verify-ssh-engine.sh` — non-mutating verification
|
||||
|
||||
Apply (requires `platform-admin` or equivalent token with `ssh/*` admin):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/openbao
|
||||
# Store platform-admin token locally (mode 600, never commit):
|
||||
# echo '<token>' > ~/.local/openbao/platform-admin.token && chmod 600 ~/.local/openbao/platform-admin.token
|
||||
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token OPENBAO_SSH_CA_PUBKEY_OUT=/tmp/openbao-ssh-ca.pub make openbao-configure-ssh
|
||||
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-verify-ssh
|
||||
```
|
||||
|
||||
The apply script exports the CA public key to `OPENBAO_SSH_CA_PUBKEY_OUT` and
|
||||
updates K8s secret `openbao/openbao-ssh-ca-pub` (non-secret pubkey only).
|
||||
|
||||
Create a dedicated warden signing token (do not use platform-admin daily):
|
||||
|
||||
```bash
|
||||
kubectl exec -n openbao openbao-0 -- bao token create -policy=warden-sign -period=8h -orphan
|
||||
```
|
||||
|
||||
Host trust and principals are **railiance-infra** scope:
|
||||
|
||||
```bash
|
||||
cd ~/railiance-infra
|
||||
make bootstrap-ssh-ca SSH_CA_PUBKEY=/tmp/openbao-ssh-ca.pub
|
||||
```
|
||||
|
||||
Then on the workstation: `bao login` (or export `VAULT_TOKEN` from the
|
||||
`warden-sign` token) and run `warden sign` per `ops-warden/wiki/OpenBaoSshEngineChecklist.md`.
|
||||
|
||||
## Auth And Workload Integration
|
||||
|
||||
Initial auth model:
|
||||
@@ -228,7 +294,93 @@ Initial auth model:
|
||||
| Human identity | NetKingdom IAM Profile/OIDC | target model; OpenBao is not the identity provider |
|
||||
| Automation | Kubernetes auth or short-lived operator token | no root tokens in automation |
|
||||
|
||||
Workload delivery choice:
|
||||
### Browser UI Login
|
||||
|
||||
The browser operator surface is:
|
||||
|
||||
```text
|
||||
https://bao.coulomb.social
|
||||
```
|
||||
|
||||
Operators see a streamlined **Sign in with KeyCape** mask. The raw OpenBao
|
||||
fields (namespace, method, mount path, role) are hidden presets applied by the
|
||||
UI overlay in `helm/openbao-ui-overlay/`. Public ingress targets the
|
||||
`openbao-ui-gateway` nginx proxy, which injects overlay assets and forwards to
|
||||
the OpenBao service.
|
||||
|
||||
Hidden defaults (also in `helm/openbao-ui-overlay/presets.json`):
|
||||
|
||||
```text
|
||||
method: OIDC
|
||||
namespace: leave blank
|
||||
mount path: netkingdom
|
||||
role: platform-admin
|
||||
```
|
||||
|
||||
Deploy or refresh the overlay:
|
||||
|
||||
```bash
|
||||
make openbao-overlay-apply
|
||||
make openbao-verify-login-overlay
|
||||
make openbao-verify-login-overlay OPENBAO_VERIFY_LOGIN_OVERLAY_ARGS=--check-upstream-drift
|
||||
```
|
||||
|
||||
After an OpenBao image or chart upgrade, follow
|
||||
`helm/openbao-ui-overlay/README.md` to refresh overlay selectors and
|
||||
`patches/<version>/manifest.sha256` fingerprints if upstream login markup
|
||||
changed.
|
||||
|
||||
OIDC mounts must be visible to the unauthenticated UI listing or Ember falls
|
||||
back to token auth (`?with=token`). Apply once per cluster:
|
||||
|
||||
```bash
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token \
|
||||
scripts/openbao-tune-auth-listing.sh
|
||||
```
|
||||
|
||||
The gateway serves a standalone KeyCape login page at `/ui/vault/auth` so Ember
|
||||
never handles the bare auth route (avoids `?with=token` / `?with=netkingdom/`
|
||||
bounce when OIDC mounts are hidden from the unauthenticated listing). Clicking
|
||||
**Sign in with KeyCape** calls `auth_url` and redirects to KeyCape directly.
|
||||
OIDC callbacks under `/ui/vault/auth/<mount>/oidc/callback` are handled by a
|
||||
standalone page that exchanges the authorization code, stores the UI session
|
||||
token, and redirects into the Ember app (no popup/`window.opener` flow).
|
||||
|
||||
The OpenBao UI redirects the browser to KeyCape at `kc.coulomb.social`, then
|
||||
returns to:
|
||||
|
||||
```text
|
||||
https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback
|
||||
```
|
||||
|
||||
The legacy `keycape` mount remains a compatibility alias for existing
|
||||
operator notes and CLI experiments. The preferred browser mount is
|
||||
`netkingdom`.
|
||||
|
||||
The browser callback URI must be present in both:
|
||||
|
||||
- KeyCape `openbao-admin` client redirect URIs; and
|
||||
- OpenBao `auth/netkingdom/role/platform-admin` `allowed_redirect_uris`.
|
||||
|
||||
If the compatibility alias is kept enabled, also keep
|
||||
`https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback` in the
|
||||
KeyCape client and `auth/keycape/role/platform-admin`.
|
||||
|
||||
Use the browser UI for metadata inspection and attended operator workflows.
|
||||
Do not use the OpenBao root token through the browser UI. Do not copy secret
|
||||
values, Inter-Hub keys, unseal shares, root tokens, OIDC client secrets, or
|
||||
screenshots of secret values into Git, State Hub, chat, or workplans.
|
||||
|
||||
For `HF-WP-0001`, prefer metadata-only inspection of candidate paths such as:
|
||||
|
||||
```text
|
||||
platform/
|
||||
platform/operators/
|
||||
platform/operators/inter-hub/
|
||||
```
|
||||
|
||||
Workload delivery choice (see also `docs/argocd-gitops.md` for the GitOps
|
||||
tenant contract):
|
||||
|
||||
- Prefer External Secrets Operator for values that should become Kubernetes
|
||||
Secrets consumed by ordinary Helm charts.
|
||||
@@ -243,7 +395,7 @@ Workload delivery choice:
|
||||
Path convention:
|
||||
|
||||
```text
|
||||
platform/workloads/<namespace>/<service-account>/<secret-name>
|
||||
platform/workloads/<tenant-or-org>/<workload>/<secret-purpose>
|
||||
platform/object-storage/<consumer>
|
||||
platform/databases/<consumer>
|
||||
platform/operators/<purpose>
|
||||
@@ -252,6 +404,10 @@ platform/operators/<purpose>
|
||||
The template policy for workload KV reads is
|
||||
`openbao/policies/workload-kv-read-template.hcl`.
|
||||
|
||||
Concrete workload access lanes used by ops-warden and similar front doors are
|
||||
tracked in `docs/workload-kv-access-lanes.md`. These docs carry non-secret
|
||||
path, field, policy, auth-role, and verification pointers only.
|
||||
|
||||
## Backup, Restore, Audit, And Monitoring
|
||||
|
||||
Before any live application secrets move into OpenBao:
|
||||
@@ -299,8 +455,8 @@ make openbao-verify-authenticated
|
||||
The target prompts for the token without echoing it, never puts the token on
|
||||
the command line, and only runs non-mutating checks. It verifies that
|
||||
`bao audit list` shows `file/`, `bao secrets list` shows `platform/`,
|
||||
`bao auth list` shows both `kubernetes/` and `keycape/`, and that the file
|
||||
audit log is non-empty.
|
||||
`bao auth list` shows `kubernetes/`, `netkingdom/`, and `keycape/`, and that
|
||||
the file audit log is non-empty.
|
||||
|
||||
If a previous attended OIDC login stored a still-valid token in the pod token
|
||||
helper, use:
|
||||
|
||||
253
docs/whynot-design-npm-publish-handoff.md
Normal file
253
docs/whynot-design-npm-publish-handoff.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# whynot-design npm Publish Token Handoff
|
||||
|
||||
This is the next-session handoff for `CCR-2026-0001` and the
|
||||
`whynot-design-npm-publish` access lane.
|
||||
|
||||
## Current State
|
||||
|
||||
- CCR: `CCR-2026-0001`
|
||||
- Decision: `e6381a56-6b04-4fd5-b2de-f3ef59cde888`
|
||||
- Status: active; non-secret OpenBao apply and verification checks passed
|
||||
- Front door: `ready`, `resolvable=true`
|
||||
- Positive verification: passed 2026-06-28
|
||||
- Negative verification: passed 2026-06-28
|
||||
- Catalog id: `whynot-design-npm-publish`
|
||||
- Tenant/org: `coulomb`
|
||||
- Workload/project: `whynot-design`
|
||||
- Bound IAM group: `whynot-design`
|
||||
- Secret path: `platform/workloads/coulomb/whynot-design/npm-publish`
|
||||
- Field: `NPM_AUTH_TOKEN`
|
||||
- Token source: Gitea package token for
|
||||
`https://gitea.coulomb.social/api/packages/coulomb/npm/`
|
||||
|
||||
The operator reported that the Gitea token was generated and stored in OpenBao.
|
||||
Using the temporary operator token only for non-secret infrastructure work, Codex
|
||||
confirmed that the policy exists, the OIDC role exists with the whynot-design
|
||||
binding and redirect URIs, the secret metadata has the expected catalog id, and
|
||||
the `NPM_AUTH_TOKEN` field is present. No secret value was printed, recorded,
|
||||
or copied into Git, State Hub, chat, or workplans.
|
||||
|
||||
On 2026-06-28, the attended positive OIDC login advanced from a missing
|
||||
`groups` claim to a bound-claim mismatch. That means the role now requests the
|
||||
`groups` scope correctly, but the authenticating identity is not a member of
|
||||
`whynot-design`. The `whynot-design` LLDAP group was created and verified.
|
||||
The intended publisher/verifier identity was later added, positive
|
||||
verification passed, then `platform-root` was temporarily removed for negative
|
||||
verification. The negative check passed with a groups bound-claim mismatch, and
|
||||
`platform-root` was restored to `whynot-design`.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Do not paste `NPM_AUTH_TOKEN` into Git, State Hub, chat, shell history, logs,
|
||||
workplans, or screenshots.
|
||||
- Do not run verification with shell tracing enabled.
|
||||
- Record only non-secret evidence: path, field name, metadata keys, policy name,
|
||||
role name, actor, timestamp, and pass/fail result.
|
||||
- Mark ops-warden catalog entries ready only after positive and negative
|
||||
verification are complete. For this lane, both checks have passed.
|
||||
|
||||
## OpenBao Secret Check
|
||||
|
||||
In the OpenBao UI, confirm the secret exists under the `platform` KV engine:
|
||||
|
||||
```text
|
||||
workloads/coulomb/whynot-design/npm-publish
|
||||
```
|
||||
|
||||
Expected field:
|
||||
|
||||
```text
|
||||
NPM_AUTH_TOKEN
|
||||
```
|
||||
|
||||
Expected custom metadata:
|
||||
|
||||
```text
|
||||
catalog-id = whynot-design-npm-publish
|
||||
```
|
||||
|
||||
Do not reveal the value during review.
|
||||
|
||||
## Policy
|
||||
|
||||
Create or update ACL policy:
|
||||
|
||||
```text
|
||||
workload-kv-read-whynot-design-npm-publish
|
||||
```
|
||||
|
||||
Policy body:
|
||||
|
||||
```hcl
|
||||
path "platform/data/workloads/coulomb/whynot-design/npm-publish" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/workloads/coulomb/whynot-design/npm-publish" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent CLI command from an approved OpenBao operator context:
|
||||
|
||||
```bash
|
||||
bao policy write workload-kv-read-whynot-design-npm-publish \
|
||||
openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl
|
||||
```
|
||||
|
||||
## OIDC Role
|
||||
|
||||
Create or update:
|
||||
|
||||
```text
|
||||
auth/netkingdom/role/whynot-design-workload-kv-read
|
||||
```
|
||||
|
||||
Role payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"role_type": "oidc",
|
||||
"allowed_redirect_uris": [
|
||||
"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback",
|
||||
"http://localhost:8250/oidc/callback",
|
||||
"http://127.0.0.1:8250/oidc/callback"
|
||||
],
|
||||
"oidc_scopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"groups"
|
||||
],
|
||||
"user_claim": "sub",
|
||||
"groups_claim": "groups",
|
||||
"bound_claims": {
|
||||
"groups": ["whynot-design"]
|
||||
},
|
||||
"policies": "workload-kv-read-whynot-design-npm-publish",
|
||||
"ttl": "15m"
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent CLI command from an approved OpenBao operator shell:
|
||||
|
||||
```bash
|
||||
role_payload_file="$(mktemp)"
|
||||
trap 'rm -f "$role_payload_file"' EXIT
|
||||
cat >"$role_payload_file" <<'JSON'
|
||||
{
|
||||
"allowed_redirect_uris": [
|
||||
"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback",
|
||||
"http://localhost:8250/oidc/callback",
|
||||
"http://127.0.0.1:8250/oidc/callback"
|
||||
],
|
||||
"oidc_scopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"groups"
|
||||
],
|
||||
"bound_claims": {
|
||||
"groups": [
|
||||
"whynot-design"
|
||||
]
|
||||
},
|
||||
"groups_claim": "groups",
|
||||
"policies": "workload-kv-read-whynot-design-npm-publish",
|
||||
"role_type": "oidc",
|
||||
"ttl": "15m",
|
||||
"user_claim": "sub"
|
||||
}
|
||||
JSON
|
||||
bao write auth/netkingdom/role/whynot-design-workload-kv-read @"$role_payload_file"
|
||||
```
|
||||
|
||||
The OpenBao Browser CLI cannot run this shell block and may treat
|
||||
`bound_claims={...}` as a string. When staying in the Web UI, open the API
|
||||
Explorer and submit the role payload JSON above with:
|
||||
|
||||
```text
|
||||
method: PUT
|
||||
path: /v1/auth/netkingdom/role/whynot-design-workload-kv-read
|
||||
```
|
||||
|
||||
If the API Explorer asks for a path without the API prefix, use
|
||||
`auth/netkingdom/role/whynot-design-workload-kv-read`.
|
||||
|
||||
## Non-Secret Reads
|
||||
|
||||
These commands should succeed from an operator-capable identity and do not print
|
||||
the token value:
|
||||
|
||||
```bash
|
||||
bao kv metadata get platform/workloads/coulomb/whynot-design/npm-publish
|
||||
bao policy read workload-kv-read-whynot-design-npm-publish
|
||||
bao read auth/netkingdom/role/whynot-design-workload-kv-read
|
||||
```
|
||||
|
||||
## Positive Verification
|
||||
|
||||
Positive verification proves the approved whynot-design identity can fetch the
|
||||
field without exposing it in logs.
|
||||
|
||||
Before retrying, confirm the account used for OIDC login is a member of the
|
||||
`whynot-design` LLDAP group. If OpenBao reports:
|
||||
|
||||
```text
|
||||
claim "groups" does not match any associated bound claim values
|
||||
```
|
||||
|
||||
then the groups claim is present, but the account is not in `whynot-design` or
|
||||
KeyCape did not emit that membership in the fresh login.
|
||||
|
||||
The positive verification passed on 2026-06-28. During that run, the CLI printed
|
||||
the short-lived OpenBao login token; it was revoked immediately by accessor.
|
||||
Prefer `bao login -no-print` for future attended verification if the installed
|
||||
CLI accepts that flag.
|
||||
|
||||
Use an attended shell, keep tracing disabled, and suppress command output:
|
||||
|
||||
```bash
|
||||
set +x
|
||||
bao login -no-print -method=oidc -path=netkingdom role=whynot-design-workload-kv-read
|
||||
bao kv get -format=json platform/workloads/coulomb/whynot-design/npm-publish \
|
||||
| jq -e '.data.data.NPM_AUTH_TOKEN | type == "string" and length > 0' \
|
||||
>/dev/null
|
||||
```
|
||||
|
||||
Record only that the check passed.
|
||||
|
||||
## Negative Verification
|
||||
|
||||
Negative verification proves a non-whynot identity cannot read the same field.
|
||||
|
||||
Use a non-whynot identity or role and confirm the read is denied. Do not print
|
||||
or store any token value.
|
||||
|
||||
Record only the denial result and non-secret audit timestamp/request metadata.
|
||||
|
||||
The negative verification passed on 2026-06-28. `platform-root` was temporarily
|
||||
removed from `whynot-design`; OpenBao rejected the OIDC login with a groups
|
||||
bound-claim mismatch, so no OpenBao client token was issued and the secret was
|
||||
not read. `platform-root` was then restored to `whynot-design`.
|
||||
|
||||
## Activation
|
||||
|
||||
Only after these are true:
|
||||
|
||||
- secret metadata confirmed;
|
||||
- policy exists and is scoped to the corrected `coulomb/whynot-design` path;
|
||||
- OIDC role exists and binds only `groups=["whynot-design"]` with approved
|
||||
browser/local CLI callback URIs and `groups` OIDC scope;
|
||||
- positive verification passed;
|
||||
- negative verification passed;
|
||||
|
||||
`CCR-2026-0001` is now `active`, and ops-warden can mark
|
||||
`whynot-design-npm-publish` `ready`/`resolvable=true`.
|
||||
|
||||
Current front door:
|
||||
|
||||
```text
|
||||
readiness = ready
|
||||
resolvable = true
|
||||
```
|
||||
230
docs/workload-kv-access-lanes.md
Normal file
230
docs/workload-kv-access-lanes.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Workload KV Access Lanes
|
||||
|
||||
This document records concrete OpenBao workload KV paths that external access
|
||||
front doors can reference without storing or vending secret values themselves.
|
||||
The first lane is for ops-warden `warden access --fetch` / `--exec`.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Do not put secret values in Git, State Hub, chat, prompts, workplans, or logs.
|
||||
- Store only non-secret pointers here: path, field name, policy name, auth role,
|
||||
flex-auth reference, and verification status.
|
||||
- ops-warden may proxy a read as the caller, but it must not hold the returned
|
||||
value beyond the caller-requested fetch/exec process.
|
||||
- Live writes require an approved OpenBao/operator path and attended handling
|
||||
of the secret value.
|
||||
|
||||
## whynot-design npm Publish Token
|
||||
|
||||
Ops-warden original request:
|
||||
`551031d1-335e-4db8-9535-820fea52d0a3`
|
||||
|
||||
Ops-warden batch follow-up:
|
||||
`fe5b1696-8956-4bd5-9d6f-dbde1901a076`
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| ops-warden catalog id | `whynot-design-npm-publish` |
|
||||
| Tenant/org | `coulomb` |
|
||||
| Workload/project | `whynot-design` |
|
||||
| KV mount | `platform` |
|
||||
| OpenBao CLI path | `platform/workloads/coulomb/whynot-design/npm-publish` |
|
||||
| Secret field | `NPM_AUTH_TOKEN` |
|
||||
| Front-door readiness | `active`, `resolvable=true` in ops-warden |
|
||||
| Read policy | `workload-kv-read-whynot-design-npm-publish` |
|
||||
| Policy file | `openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl` |
|
||||
| OIDC auth mount | `netkingdom` |
|
||||
| OIDC role | `whynot-design-workload-kv-read` |
|
||||
| OIDC callback URIs | `https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback`, `http://localhost:8250/oidc/callback`, `http://127.0.0.1:8250/oidc/callback` |
|
||||
| Kubernetes auth role | `whynot-design-workload-kv-read` if an in-cluster service account consumes this lane |
|
||||
| flex-auth ref | `secret.read:whynot-design` if tenant policy requires pre-approval |
|
||||
|
||||
Expected caller login shape:
|
||||
|
||||
```bash
|
||||
bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read
|
||||
```
|
||||
|
||||
Expected OpenBao fetch shape:
|
||||
|
||||
```bash
|
||||
bao kv get -field=NPM_AUTH_TOKEN platform/workloads/coulomb/whynot-design/npm-publish
|
||||
```
|
||||
|
||||
Expected ops-warden exec shape after activation:
|
||||
|
||||
```bash
|
||||
warden access whynot-design-npm-publish --exec -- npm publish
|
||||
```
|
||||
|
||||
Ops-warden confirmed activation in State Hub message
|
||||
`f76d3a9e-a98f-4081-885d-b79d94312699`: selector
|
||||
`whynot-design-npm-publish` is active, resolvable, and wired to this
|
||||
caller-scoped lane. The sibling lanes `issue-core-ingestion-api-key` and
|
||||
`openrouter-llm-connect` remain draft and are tracked separately by
|
||||
`RAILIANCE-WP-0009` and `RAILIANCE-WP-0010`.
|
||||
|
||||
The fetch command returns the secret value to the authenticated caller. Run it
|
||||
only in an attended shell or through a process that consumes the value without
|
||||
logging it.
|
||||
|
||||
## OpenBao Policy
|
||||
|
||||
The source policy grants only:
|
||||
|
||||
```text
|
||||
read platform/data/workloads/coulomb/whynot-design/npm-publish
|
||||
read platform/metadata/workloads/coulomb/whynot-design/npm-publish
|
||||
```
|
||||
|
||||
It does not grant write, delete, patch, sudo, auth, sibling workload, or parent
|
||||
list capabilities.
|
||||
|
||||
Dry-run the policy apply path:
|
||||
|
||||
```bash
|
||||
make openbao-workload-kv-lanes-dry-run
|
||||
```
|
||||
|
||||
Apply the policy with an approved platform-admin/operator token:
|
||||
|
||||
```bash
|
||||
OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token \
|
||||
make openbao-configure-workload-kv-lanes
|
||||
```
|
||||
|
||||
If the OpenBao pod has an approved token-helper session, use:
|
||||
|
||||
```bash
|
||||
make openbao-configure-workload-kv-lanes OPENBAO_WORKLOAD_KV_ARGS=--use-token-helper
|
||||
```
|
||||
|
||||
Do not paste the token into shell history or logs. The helper reads a token
|
||||
from `OPENBAO_TOKEN_FILE` or an interactive hidden prompt unless
|
||||
`--use-token-helper` is set, and passes it to OpenBao through stdin.
|
||||
|
||||
## Auth Role
|
||||
|
||||
The intended OpenBao OIDC role is:
|
||||
|
||||
```text
|
||||
auth/netkingdom/role/whynot-design-workload-kv-read
|
||||
```
|
||||
|
||||
The role must attach only:
|
||||
|
||||
```text
|
||||
workload-kv-read-whynot-design-npm-publish
|
||||
```
|
||||
|
||||
The OIDC role must include the browser and local CLI callback URIs accepted by
|
||||
OpenBao:
|
||||
|
||||
```text
|
||||
https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback
|
||||
http://localhost:8250/oidc/callback
|
||||
http://127.0.0.1:8250/oidc/callback
|
||||
```
|
||||
|
||||
The role must request these OIDC scopes so KeyCape emits the group claim OpenBao
|
||||
checks:
|
||||
|
||||
```text
|
||||
openid
|
||||
profile
|
||||
email
|
||||
groups
|
||||
```
|
||||
|
||||
The whynot-design pilot claim is confirmed as `groups=whynot-design`. Before
|
||||
applying any changed role, re-confirm the KeyCape/NetKingdom claim that
|
||||
identifies the whynot-design caller. The role must bind to that claim; do not
|
||||
create an unbounded OIDC role that grants this policy to every OIDC user.
|
||||
|
||||
If the consumer is an in-cluster service account instead of an OIDC caller, use
|
||||
Kubernetes auth with the same role name and bind only the approved namespace
|
||||
and service account.
|
||||
|
||||
## Secret Provisioning
|
||||
|
||||
An approved operator must create or confirm the secret with:
|
||||
|
||||
```text
|
||||
path: platform/workloads/coulomb/whynot-design/npm-publish
|
||||
field: NPM_AUTH_TOKEN
|
||||
```
|
||||
|
||||
In the OpenBao UI, open the `platform` KV engine and create or edit the secret
|
||||
at:
|
||||
|
||||
```text
|
||||
workloads/coulomb/whynot-design/npm-publish
|
||||
```
|
||||
|
||||
For policies and API checks, the same KV-v2 secret is addressed as:
|
||||
|
||||
```text
|
||||
platform/data/workloads/coulomb/whynot-design/npm-publish
|
||||
platform/metadata/workloads/coulomb/whynot-design/npm-publish
|
||||
```
|
||||
|
||||
The OpenBao UI path does not include the `data/` or `metadata/` segment. Those
|
||||
segments are the KV-v2 API and ACL policy paths.
|
||||
|
||||
The value must be entered directly through OpenBao/operator custody. Record only
|
||||
non-secret evidence: actor, timestamp, path, field name, policy name, and
|
||||
verification result.
|
||||
|
||||
## Verification
|
||||
|
||||
Positive verification:
|
||||
|
||||
1. Authenticate as the whynot-design caller using the approved OIDC or
|
||||
Kubernetes auth role.
|
||||
2. Fetch the field in an attended session or through `warden access --exec`.
|
||||
3. Record only that the fetch succeeded; do not record the value.
|
||||
|
||||
Safe attended command shape before the dedicated ops-warden catalog id is
|
||||
activated:
|
||||
|
||||
```bash
|
||||
set +x
|
||||
bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read
|
||||
warden access "npm token" \
|
||||
--path platform/workloads/coulomb/whynot-design/npm-publish \
|
||||
--field NPM_AUTH_TOKEN \
|
||||
--no-policy \
|
||||
--exec -- sh -lc 'test -n "$NPM_AUTH_TOKEN"'
|
||||
```
|
||||
|
||||
Use `--no-policy` only while the local ops-warden config reports
|
||||
`policy.enabled=false`; remove it once the flex-auth gate is enforced. If login
|
||||
fails with `groups claim not found`, the OpenBao role is missing the `groups`
|
||||
OIDC scope and must be corrected before retrying.
|
||||
|
||||
Negative verification:
|
||||
|
||||
1. Authenticate as a non-whynot identity.
|
||||
2. Confirm the same field read is denied.
|
||||
3. Record the non-secret OpenBao audit request ids or timestamps for the
|
||||
allowed and denied attempts.
|
||||
|
||||
## ops-warden Handoff
|
||||
|
||||
Send ops-warden only these pointers:
|
||||
|
||||
```text
|
||||
catalog id: whynot-design-npm-publish
|
||||
mount: platform
|
||||
path: platform/workloads/coulomb/whynot-design/npm-publish
|
||||
field: NPM_AUTH_TOKEN
|
||||
oidc login: bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read
|
||||
policy: workload-kv-read-whynot-design-npm-publish
|
||||
policy file: openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl
|
||||
flex-auth ref: secret.read:whynot-design, if tenant policy requires it
|
||||
runbook: docs/workload-kv-access-lanes.md
|
||||
```
|
||||
|
||||
Until positive and negative caller verification are complete, ops-warden should
|
||||
keep the catalog entry in `applied-pending-verify`/non-active state with
|
||||
`resolvable=false`.
|
||||
38
helm/openbao-middleware.yaml
Normal file
38
helm/openbao-middleware.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Traefik middlewares for OpenBao browser UI/API exposure.
|
||||
#
|
||||
# These names are referenced by helm/openbao-values.yaml as:
|
||||
# openbao-openbao-rate-limit@kubernetescrd
|
||||
# openbao-openbao-hsts@kubernetescrd
|
||||
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: openbao-rate-limit
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: openbao
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/component: secrets
|
||||
spec:
|
||||
rateLimit:
|
||||
# The OpenBao browser UI performs a burst of API calls on load, including
|
||||
# repeated /v1/sys/health checks. Keep this high enough for normal admin
|
||||
# use while still bounding runaway clients.
|
||||
average: 600
|
||||
period: 1m
|
||||
burst: 180
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: openbao-hsts
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: openbao
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/component: secrets
|
||||
spec:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
121
helm/openbao-ui-overlay-k8s.yaml
Normal file
121
helm/openbao-ui-overlay-k8s.yaml
Normal file
@@ -0,0 +1,121 @@
|
||||
# OpenBao browser UI gateway — injects the KeyCape login overlay and proxies
|
||||
# to the OpenBao service. Public ingress for bao.coulomb.social targets this
|
||||
# gateway instead of the chart-managed OpenBao ingress.
|
||||
#
|
||||
# ConfigMap data is applied by scripts/openbao-ui-overlay-apply.sh from
|
||||
# helm/openbao-ui-overlay/*.
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openbao-ui-gateway
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: openbao-ui-gateway
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/component: secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: openbao-ui-gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: openbao-ui-gateway
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/component: secrets
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.27-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ui/platform-overlay/presets.json
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ui/platform-overlay/presets.json
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
volumeMounts:
|
||||
- name: nginx-config
|
||||
mountPath: /etc/nginx/nginx.conf
|
||||
subPath: nginx.conf
|
||||
readOnly: true
|
||||
- name: overlay-assets
|
||||
mountPath: /etc/nginx/overlay
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: nginx-config
|
||||
configMap:
|
||||
name: openbao-ui-gateway-nginx
|
||||
- name: overlay-assets
|
||||
configMap:
|
||||
name: openbao-ui-overlay
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: openbao-ui-gateway
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: openbao-ui-gateway
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/component: secrets
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: openbao-ui-gateway
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: openbao-ui-gateway
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: openbao-ui-gateway
|
||||
app.kubernetes.io/part-of: railiance-platform
|
||||
railiance-platform/component: secrets
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.middlewares: >-
|
||||
openbao-openbao-rate-limit@kubernetescrd,
|
||||
openbao-openbao-hsts@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- secretName: bao-tls
|
||||
hosts:
|
||||
- bao.coulomb.social
|
||||
rules:
|
||||
- host: bao.coulomb.social
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: openbao-ui-gateway
|
||||
port:
|
||||
number: 8080
|
||||
69
helm/openbao-ui-overlay/README.md
Normal file
69
helm/openbao-ui-overlay/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# OpenBao KeyCape login overlay
|
||||
|
||||
Streamlines the browser login mask at `https://bao.coulomb.social` to a single
|
||||
**Sign in with KeyCape** action. Namespace, auth method, mount path, and role
|
||||
are preset in `presets.json` and hidden by `overlay.css` / `overlay.js`.
|
||||
|
||||
## Mechanism (T01 decision)
|
||||
|
||||
OpenBao ships UI assets inside the container image. There is no supported API
|
||||
to customize the login form ([`/sys/config/ui`](https://openbao.org/api-docs/system/config-ui/)
|
||||
only configures response headers).
|
||||
|
||||
We use an **nginx UI gateway** (`openbao-ui-gateway`) that:
|
||||
|
||||
1. Proxies all traffic to `openbao.openbao.svc.cluster.local:8200`.
|
||||
2. Serves overlay assets from a ConfigMap at `/ui/platform-overlay/`.
|
||||
3. Injects `overlay.css` and `overlay.js` into HTML responses via `sub_filter`.
|
||||
|
||||
Overlay assets live entirely in this directory. Upgrading OpenBao does not
|
||||
require hand-editing files inside the OpenBao pod.
|
||||
|
||||
Track upstream [openbao/openbao#2936](https://github.com/openbao/openbao/issues/2936)
|
||||
for native custom CSS. When available, keep `presets.json` and branding assets
|
||||
and retire nginx `sub_filter` injection if the upstream API covers the same
|
||||
behaviour.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `VERSION` | OpenBao image tag this overlay targets (`openbao-values.yaml`) |
|
||||
| `presets.json` | Hidden login defaults (`netkingdom`, `platform-admin`, …) |
|
||||
| `overlay.css` | Hide raw OpenBao login fields |
|
||||
| `overlay.js` | Apply presets, branding on post-login Ember pages |
|
||||
| `login.html` / `login.js` / `login.css` | Standalone KeyCape login at `/ui/vault/auth` |
|
||||
| `callback.html` / `callback.js` | OIDC code exchange at `/ui/vault/auth/*/oidc/callback` |
|
||||
| `nginx.conf` | Gateway proxy + standalone auth page + HTML injection |
|
||||
| `patches/<version>/manifest.sha256` | Upstream UI fingerprints for drift detection |
|
||||
|
||||
## Deploy
|
||||
|
||||
From `railiance-platform`:
|
||||
|
||||
```bash
|
||||
make openbao-overlay-apply # overlay only
|
||||
make openbao-deploy # middleware + overlay + Helm upgrade
|
||||
make openbao-verify-login-overlay
|
||||
```
|
||||
|
||||
## Reapply after an OpenBao upgrade
|
||||
|
||||
1. Bump `server.image.tag` in `helm/openbao-values.yaml`.
|
||||
2. Deploy: `make openbao-deploy`.
|
||||
3. Fetch live UI assets and compare hashes:
|
||||
|
||||
```bash
|
||||
curl -sS https://bao.coulomb.social/ui/ -o /tmp/index.html
|
||||
# locate vault-*.js path in /tmp/index.html, then:
|
||||
curl -sS "https://bao.coulomb.social/ui/assets/vault-....js" -o /tmp/vault.js
|
||||
sha256sum /tmp/index.html /tmp/vault.js
|
||||
```
|
||||
|
||||
4. If hashes differ from `patches/<old-version>/manifest.sha256`, update
|
||||
`overlay.css` / `overlay.js` selectors against the new Ember templates.
|
||||
5. Write `patches/<new-version>/manifest.sha256`, update `VERSION`.
|
||||
6. Run `make openbao-verify-login-overlay CHECK_UPSTREAM_DRIFT=1`.
|
||||
7. Attended browser login through KeyCape MFA.
|
||||
|
||||
Workplan: `helix-forge/workplans/HF-WP-0003-openbao-keycape-login-overlay.md`
|
||||
1
helm/openbao-ui-overlay/VERSION
Normal file
1
helm/openbao-ui-overlay/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
2.5.4
|
||||
16
helm/openbao-ui-overlay/callback.html
Normal file
16
helm/openbao-ui-overlay/callback.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Signing in with KeyCape</title>
|
||||
<link rel="stylesheet" href="/ui/platform-overlay/login.css" />
|
||||
<script src="/ui/platform-overlay/callback.js" defer></script>
|
||||
</head>
|
||||
<body id="callback-root">
|
||||
<main class="login-card">
|
||||
<h1>Signing in with KeyCape</h1>
|
||||
<p>Completing sign-in and opening OpenBao…</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
142
helm/openbao-ui-overlay/callback.js
Normal file
142
helm/openbao-ui-overlay/callback.js
Normal file
@@ -0,0 +1,142 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const PRESETS_URL = "/ui/platform-overlay/presets.json";
|
||||
const TOKEN_PREFIX = "vault-";
|
||||
const TOKEN_SEPARATOR = "☃";
|
||||
const CLUSTER_ID = "1";
|
||||
const DEFAULT_PRESETS = { mount: "netkingdom", role: "platform-admin" };
|
||||
const OIDC_BACKEND = {
|
||||
type: "oidc",
|
||||
typeDisplay: "OIDC",
|
||||
description: "Authenticate using JWT or OIDC provider.",
|
||||
tokenPath: "client_token",
|
||||
displayNamePath: "display_name",
|
||||
formAttributes: ["role", "jwt"],
|
||||
};
|
||||
const POST_LOGIN_PATH = "/ui/vault/vault/secrets";
|
||||
|
||||
function parseCallbackContext() {
|
||||
const match = window.location.pathname.match(
|
||||
/\/ui\/vault\/auth\/(.+)\/oidc\/callback\/?$/
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error("Unsupported OIDC callback path");
|
||||
}
|
||||
|
||||
const mount = decodeURIComponent(match[1]).replace(/\/$/, "");
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let state = params.get("state") || "";
|
||||
let namespace = "";
|
||||
|
||||
if (state.includes(",ns=")) {
|
||||
const parts = state.split(",ns=");
|
||||
state = parts[0];
|
||||
namespace = parts[1] || "";
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
if (!mount || !state || !code) {
|
||||
throw new Error("OIDC callback missing required parameters");
|
||||
}
|
||||
|
||||
return { mount, state, code, namespace };
|
||||
}
|
||||
|
||||
async function loadPresets() {
|
||||
try {
|
||||
const response = await fetch(PRESETS_URL, { cache: "no-store" });
|
||||
if (!response.ok) return { ...DEFAULT_PRESETS };
|
||||
return { ...DEFAULT_PRESETS, ...(await response.json()) };
|
||||
} catch (_error) {
|
||||
return { ...DEFAULT_PRESETS };
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeOidc({ mount, state, code }) {
|
||||
const query = new URLSearchParams({ state, code });
|
||||
const response = await fetch(
|
||||
`/v1/auth/${encodeURIComponent(mount)}/oidc/callback?${query}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" } }
|
||||
);
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
payload?.errors?.[0] ||
|
||||
`OIDC callback exchange failed (${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const auth = payload.auth || payload.data?.auth || payload.data;
|
||||
if (!auth?.client_token) {
|
||||
throw new Error("OIDC callback did not return a client token");
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
function persistAuthToken(auth, presets) {
|
||||
const mount = presets.mount || "netkingdom";
|
||||
const selectedAuth = mount.endsWith("/") ? mount : `${mount}/`;
|
||||
const tokenName = `${TOKEN_PREFIX}oidc${TOKEN_SEPARATOR}${CLUSTER_ID}`;
|
||||
const namespacePath = auth.namespace_path?.replace(/\/$/, "") || "";
|
||||
const tokenData = {
|
||||
userRootNamespace: namespacePath,
|
||||
displayName:
|
||||
auth.display_name ||
|
||||
auth.metadata?.name ||
|
||||
auth.metadata?.username ||
|
||||
"KeyCape",
|
||||
backend: OIDC_BACKEND,
|
||||
token: auth.client_token,
|
||||
policies: auth.policies || [],
|
||||
renewable: Boolean(auth.renewable),
|
||||
entity_id: auth.entity_id,
|
||||
};
|
||||
|
||||
if (tokenData.renewable && auth.lease_duration) {
|
||||
tokenData.ttl = auth.lease_duration;
|
||||
tokenData.tokenExpirationEpoch =
|
||||
Date.now() + auth.lease_duration * 1000;
|
||||
}
|
||||
|
||||
window.localStorage.setItem("selectedAuth", selectedAuth);
|
||||
window.localStorage.setItem(tokenName, JSON.stringify(tokenData));
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const root = document.getElementById("callback-root");
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<main class="login-card">
|
||||
<h1>Sign-in failed</h1>
|
||||
<p class="login-error is-visible">${message}</p>
|
||||
<button class="login-button" type="button" id="callback-retry">
|
||||
Back to sign in
|
||||
</button>
|
||||
</main>
|
||||
`;
|
||||
document.getElementById("callback-retry")?.addEventListener("click", () => {
|
||||
window.location.assign("/ui/vault/auth");
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const presets = await loadPresets();
|
||||
const context = parseCallbackContext();
|
||||
const auth = await exchangeOidc(context);
|
||||
persistAuthToken(auth, presets);
|
||||
window.location.replace(POST_LOGIN_PATH);
|
||||
} catch (error) {
|
||||
showError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "OIDC sign-in failed. Contact your administrator."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
85
helm/openbao-ui-overlay/login.css
Normal file
85
helm/openbao-ui-overlay/login.css
Normal file
@@ -0,0 +1,85 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f0f2f5;
|
||||
--card: #ffffff;
|
||||
--text: #1f2a37;
|
||||
--muted: #5b6b7c;
|
||||
--border: #d9dee3;
|
||||
--accent: #1565c0;
|
||||
--accent-hover: #0d47a1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 26rem);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.08);
|
||||
padding: 2rem 1.75rem 1.75rem;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
background: #fdecea;
|
||||
color: #8a1c13;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-error.is-visible {
|
||||
display: block;
|
||||
}
|
||||
22
helm/openbao-ui-overlay/login.html
Normal file
22
helm/openbao-ui-overlay/login.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sign in with KeyCape</title>
|
||||
<link rel="stylesheet" href="/ui/platform-overlay/login.css" />
|
||||
<script src="/ui/platform-overlay/login.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-card">
|
||||
<h1 id="login-title">Sign in with KeyCape</h1>
|
||||
<p id="login-banner">
|
||||
Platform operators authenticate through KeyCape at kc.coulomb.social.
|
||||
</p>
|
||||
<button id="login-submit" class="login-button" type="button">
|
||||
Sign in with KeyCape
|
||||
</button>
|
||||
<div id="login-error" class="login-error" role="alert"></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
86
helm/openbao-ui-overlay/login.js
Normal file
86
helm/openbao-ui-overlay/login.js
Normal file
@@ -0,0 +1,86 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const PRESETS_URL = "/ui/platform-overlay/presets.json";
|
||||
const DEFAULT_PRESETS = {
|
||||
mount: "netkingdom",
|
||||
role: "platform-admin",
|
||||
title: "Sign in with KeyCape",
|
||||
signInLabel: "Sign in with KeyCape",
|
||||
banner:
|
||||
"Platform operators authenticate through KeyCape at kc.coulomb.social.",
|
||||
};
|
||||
|
||||
async function loadPresets() {
|
||||
try {
|
||||
const response = await fetch(PRESETS_URL, { cache: "no-store" });
|
||||
if (!response.ok) return { ...DEFAULT_PRESETS };
|
||||
return { ...DEFAULT_PRESETS, ...(await response.json()) };
|
||||
} catch (_error) {
|
||||
return { ...DEFAULT_PRESETS };
|
||||
}
|
||||
}
|
||||
|
||||
async function redirectToKeyCape(presets) {
|
||||
const mount = presets.mount || "netkingdom";
|
||||
const role = presets.role || "platform-admin";
|
||||
const redirectUri = `${window.location.origin}/ui/vault/auth/${mount}/oidc/callback`;
|
||||
|
||||
const response = await fetch(`/v1/auth/${mount}/oidc/auth_url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OIDC auth_url request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const authUrl = payload?.data?.auth_url;
|
||||
if (!authUrl) {
|
||||
throw new Error("OIDC auth_url missing from OpenBao response");
|
||||
}
|
||||
|
||||
window.location.assign(authUrl);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const error = document.getElementById("login-error");
|
||||
if (!error) return;
|
||||
error.textContent = message;
|
||||
error.classList.add("is-visible");
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const presets = await loadPresets();
|
||||
const title = document.getElementById("login-title");
|
||||
const banner = document.getElementById("login-banner");
|
||||
const button = document.getElementById("login-submit");
|
||||
|
||||
if (title) title.textContent = presets.title;
|
||||
if (banner) banner.textContent = presets.banner;
|
||||
if (button) button.textContent = presets.signInLabel;
|
||||
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await redirectToKeyCape(presets);
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
showError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Sign-in failed. Contact your administrator."
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
69
helm/openbao-ui-overlay/nginx.conf
Normal file
69
helm/openbao-ui-overlay/nginx.conf
Normal file
@@ -0,0 +1,69 @@
|
||||
worker_processes auto;
|
||||
error_log /dev/stderr notice;
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
server_tokens off;
|
||||
|
||||
upstream openbao_upstream {
|
||||
server openbao.openbao.svc.cluster.local:8200;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
location /ui/platform-overlay/ {
|
||||
alias /etc/nginx/overlay/;
|
||||
add_header Cache-Control "public, max-age=300";
|
||||
}
|
||||
|
||||
# Standalone KeyCape login page — bypasses Ember auth route and ?with= bounce.
|
||||
location = /ui/vault/auth {
|
||||
alias /etc/nginx/overlay/login.html;
|
||||
default_type text/html;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# OIDC callback handler — exchanges code without Ember popup/postMessage flow.
|
||||
location ~ ^/ui/vault/auth/.+/oidc/callback/?$ {
|
||||
alias /etc/nginx/overlay/callback.html;
|
||||
default_type text/html;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# Static UI bundles and API calls bypass HTML injection and stay compressed.
|
||||
location ~ ^/(v1|ui/assets|ui/engines-dist|ui/favicon\.svg) {
|
||||
proxy_pass http://openbao_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://openbao_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Disable upstream compression only for HTML shell injection.
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_buffering on;
|
||||
|
||||
sub_filter_types text/html;
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<link rel="stylesheet" href="/ui/platform-overlay/overlay.css"><script src="/ui/platform-overlay/overlay.js" defer></script></head>';
|
||||
}
|
||||
}
|
||||
}
|
||||
40
helm/openbao-ui-overlay/overlay.css
Normal file
40
helm/openbao-ui-overlay/overlay.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/* KeyCape login overlay for OpenBao UI — see presets.json and overlay.js */
|
||||
|
||||
html.keycape-overlay-active .toolbar-namespace-picker,
|
||||
html.keycape-overlay-active nav.tabs,
|
||||
html.keycape-overlay-active label[for="namespace"],
|
||||
html.keycape-overlay-active label[for="role"],
|
||||
html.keycape-overlay-active label[for="custom-path"],
|
||||
html.keycape-overlay-active #namespace,
|
||||
html.keycape-overlay-active #role,
|
||||
html.keycape-overlay-active #custom-path,
|
||||
html.keycape-overlay-active #token,
|
||||
html.keycape-overlay-active #username,
|
||||
html.keycape-overlay-active #password,
|
||||
html.keycape-overlay-active select[name="auth-method"],
|
||||
html.keycape-overlay-active .auth-form .box.has-slim-padding.is-shadowless,
|
||||
html.keycape-overlay-active .auth-form .has-bottom-margin-s {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html.keycape-overlay-active .splash-page-header .brand-icon-large {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html.keycape-overlay-active h1.title.is-3 {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.keycape-overlay-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f4f6f8;
|
||||
border-bottom: 1px solid #d9dee3;
|
||||
font-size: 0.875rem;
|
||||
color: #3d4f5f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
html.keycape-overlay-active .login-form .auth-form {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
279
helm/openbao-ui-overlay/overlay.js
Normal file
279
helm/openbao-ui-overlay/overlay.js
Normal file
@@ -0,0 +1,279 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const PRESETS_URL = "/ui/platform-overlay/presets.json";
|
||||
const MAX_APPLY_ATTEMPTS = 40;
|
||||
const APPLY_INTERVAL_MS = 250;
|
||||
const DEFAULT_PRESETS = {
|
||||
namespace: "",
|
||||
method: "oidc",
|
||||
mount: "netkingdom",
|
||||
role: "platform-admin",
|
||||
title: "Sign in with KeyCape",
|
||||
signInLabel: "Sign in with KeyCape",
|
||||
banner:
|
||||
"Platform operators authenticate through KeyCape at kc.coulomb.social.",
|
||||
};
|
||||
|
||||
let presets = { ...DEFAULT_PRESETS };
|
||||
let applyAttempts = 0;
|
||||
let applyTimer = null;
|
||||
let overlayApplied = false;
|
||||
let signInHandlerInstalled = false;
|
||||
|
||||
function hasStoredSession() {
|
||||
try {
|
||||
return Object.keys(window.localStorage).some((key) =>
|
||||
key.startsWith("vault-")
|
||||
);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isUiEntryPath() {
|
||||
const path = window.location.pathname;
|
||||
return path === "/ui" || path === "/ui/";
|
||||
}
|
||||
|
||||
function redirectUnauthenticatedUiEntry() {
|
||||
if (!isUiEntryPath() || hasStoredSession()) return;
|
||||
window.location.replace("/ui/vault/auth");
|
||||
}
|
||||
|
||||
function isAuthPage() {
|
||||
const path = window.location.pathname;
|
||||
return (
|
||||
/\/ui\/vault\/auth(?:\/|$)/.test(path) ||
|
||||
/\/ui\/?$/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isOidcCallbackPage() {
|
||||
return window.location.pathname.includes("/oidc/");
|
||||
}
|
||||
|
||||
function hideNode(node) {
|
||||
if (!node || node.dataset.keycapeOverlayHidden === "true") return;
|
||||
const field =
|
||||
node.closest(".field.is-horizontal") ||
|
||||
node.closest(".field") ||
|
||||
node.closest(".box") ||
|
||||
node;
|
||||
if (field.dataset.keycapeOverlayHidden === "true") return;
|
||||
field.style.display = "none";
|
||||
field.setAttribute("aria-hidden", "true");
|
||||
field.dataset.keycapeOverlayHidden = "true";
|
||||
}
|
||||
|
||||
function setInputValue(input, value) {
|
||||
if (!input || input.dataset.keycapeOverlayPreset === value) return;
|
||||
input.value = value;
|
||||
input.dataset.keycapeOverlayPreset = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function redirectToKeyCape() {
|
||||
const mount = presets.mount || "netkingdom";
|
||||
const role = presets.role || "platform-admin";
|
||||
const redirectUri = `${window.location.origin}/ui/vault/auth/${mount}/oidc/callback`;
|
||||
|
||||
const response = await fetch(`/v1/auth/${mount}/oidc/auth_url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OIDC auth_url request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const authUrl = payload?.data?.auth_url;
|
||||
if (!authUrl) {
|
||||
throw new Error("OIDC auth_url missing from OpenBao response");
|
||||
}
|
||||
|
||||
window.location.assign(authUrl);
|
||||
}
|
||||
|
||||
function installKeyCapeSignInHandler() {
|
||||
if (signInHandlerInstalled) return;
|
||||
signInHandlerInstalled = true;
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (!isAuthPage() || isOidcCallbackPage()) return;
|
||||
|
||||
const button = event.target.closest(
|
||||
'#auth-submit, button[data-test="auth-submit"], form#auth-form button[type="submit"]'
|
||||
);
|
||||
if (!button) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
button.disabled = true;
|
||||
button.classList.add("is-loading");
|
||||
redirectToKeyCape().catch(() => {
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-loading");
|
||||
});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function loginShellReady() {
|
||||
return Boolean(
|
||||
document.querySelector(".login-form") ||
|
||||
document.querySelector(".auth-form") ||
|
||||
document.querySelector(".toolbar-namespace-picker")
|
||||
);
|
||||
}
|
||||
|
||||
function applyDom() {
|
||||
if (!isAuthPage() || isOidcCallbackPage() || overlayApplied) return false;
|
||||
|
||||
hideNode(document.querySelector(".toolbar-namespace-picker"));
|
||||
document
|
||||
.querySelectorAll(
|
||||
'#namespace, input[name="namespace"], label[for="namespace"]'
|
||||
)
|
||||
.forEach(hideNode);
|
||||
|
||||
document
|
||||
.querySelectorAll('select[name="auth-method"], #auth-method')
|
||||
.forEach((el) => hideNode(el.closest(".field") || el));
|
||||
|
||||
document
|
||||
.querySelectorAll('#custom-path, input[name="custom-path"]')
|
||||
.forEach(hideNode);
|
||||
|
||||
document
|
||||
.querySelectorAll('#role, input[name="role"], label[for="role"]')
|
||||
.forEach(hideNode);
|
||||
|
||||
document
|
||||
.querySelectorAll(
|
||||
'#token, input[name="token"], label[for="token"], #username, input[name="username"], #password, input[name="password"]'
|
||||
)
|
||||
.forEach(hideNode);
|
||||
|
||||
document.querySelectorAll("nav.tabs").forEach((el) => {
|
||||
if (el.dataset.keycapeOverlayHidden === "true") return;
|
||||
el.style.display = "none";
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
el.dataset.keycapeOverlayHidden = "true";
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll(".auth-form .has-bottom-margin-s")
|
||||
.forEach(hideNode);
|
||||
|
||||
document.querySelectorAll("h1.title.is-3").forEach((heading) => {
|
||||
if (
|
||||
/Sign in to OpenBao|Authenticate/.test(heading.textContent) &&
|
||||
heading.textContent !== presets.title
|
||||
) {
|
||||
heading.textContent = presets.title;
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll('#auth-submit, button[data-test="auth-submit"]')
|
||||
.forEach((button) => {
|
||||
if (button.textContent !== presets.signInLabel) {
|
||||
button.textContent = presets.signInLabel;
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll('#namespace, input[name="namespace"]')
|
||||
.forEach((input) => setInputValue(input, presets.namespace || ""));
|
||||
|
||||
document
|
||||
.querySelectorAll('#role, input[name="role"]')
|
||||
.forEach((input) =>
|
||||
setInputValue(input, presets.role || "platform-admin")
|
||||
);
|
||||
|
||||
if (!document.getElementById("keycape-overlay-banner")) {
|
||||
const banner = document.createElement("div");
|
||||
banner.id = "keycape-overlay-banner";
|
||||
banner.className = "keycape-overlay-banner";
|
||||
banner.textContent = presets.banner;
|
||||
const loginForm = document.querySelector(".login-form");
|
||||
if (loginForm) {
|
||||
loginForm.prepend(banner);
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.classList.add("keycape-overlay-active");
|
||||
installKeyCapeSignInHandler();
|
||||
|
||||
if (loginShellReady()) {
|
||||
overlayApplied = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function stopApplyLoop() {
|
||||
if (applyTimer !== null) {
|
||||
window.clearInterval(applyTimer);
|
||||
applyTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleApply() {
|
||||
stopApplyLoop();
|
||||
applyAttempts = 0;
|
||||
|
||||
const tick = () => {
|
||||
applyAttempts += 1;
|
||||
if (applyDom() || applyAttempts >= MAX_APPLY_ATTEMPTS) {
|
||||
stopApplyLoop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
applyTimer = window.setInterval(tick, APPLY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function loadPresets() {
|
||||
try {
|
||||
const response = await fetch(PRESETS_URL, { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
presets = { ...DEFAULT_PRESETS, ...data };
|
||||
} catch (_error) {
|
||||
presets = { ...DEFAULT_PRESETS };
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
redirectUnauthenticatedUiEntry();
|
||||
if (!isAuthPage() || isOidcCallbackPage()) return;
|
||||
|
||||
await loadPresets();
|
||||
installKeyCapeSignInHandler();
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", scheduleApply, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
scheduleApply();
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
8
helm/openbao-ui-overlay/patches/2.5.4/manifest.sha256
Normal file
8
helm/openbao-ui-overlay/patches/2.5.4/manifest.sha256
Normal file
@@ -0,0 +1,8 @@
|
||||
# OpenBao UI asset fingerprints for image tag 2.5.4.
|
||||
# Regenerate after an OpenBao image bump when login markup drifts.
|
||||
# Compare vault.js only — index.html is intentionally modified by the gateway.
|
||||
# curl -sS https://bao.coulomb.social/ui/ -o /tmp/index.html
|
||||
# vault_path=$(rg -o '/ui/assets/vault-[a-f0-9]+\\.js' /tmp/index.html | head -1)
|
||||
# curl -sS "https://bao.coulomb.social${vault_path}" -o /tmp/vault.js
|
||||
# sha256sum /tmp/vault.js
|
||||
f0214b5be89377395f8d6521c34139877529bd95ba703901c78b527ab0f1c231 ui/assets/vault-bae6b876038fbf475728f993b5a62002.js
|
||||
9
helm/openbao-ui-overlay/presets.json
Normal file
9
helm/openbao-ui-overlay/presets.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"namespace": "",
|
||||
"method": "oidc",
|
||||
"mount": "netkingdom",
|
||||
"role": "platform-admin",
|
||||
"title": "Sign in with KeyCape",
|
||||
"signInLabel": "Sign in with KeyCape",
|
||||
"banner": "Platform operators authenticate through KeyCape at kc.coulomb.social."
|
||||
}
|
||||
@@ -30,6 +30,8 @@ server:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
# Public browser ingress is owned by helm/openbao-ui-overlay-k8s.yaml so the
|
||||
# KeyCape login overlay gateway can inject overlay assets.
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
|
||||
23
openbao/policies/credential-broker-warden-sign-issuer.hcl
Normal file
23
openbao/policies/credential-broker-warden-sign-issuer.hcl
Normal file
@@ -0,0 +1,23 @@
|
||||
# Narrow issuer policy for the credential broker warden-sign pilot.
|
||||
# This policy can create child tokens only through the warden-sign token role.
|
||||
# Bind it to a broker/operator issuer identity, not to tenant workloads.
|
||||
|
||||
path "auth/token/create/warden-sign" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
|
||||
path "auth/token/lookup-accessor" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
|
||||
path "auth/token/revoke-accessor" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
|
||||
path "auth/token/lookup-self" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "sys/capabilities-self" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
40
openbao/policies/credential-change-nonprod-applier.hcl
Normal file
40
openbao/policies/credential-change-nonprod-applier.hcl
Normal file
@@ -0,0 +1,40 @@
|
||||
# Non-production metadata applier for reviewed credential changes.
|
||||
#
|
||||
# This policy is intended for build/test/staging OpenBao lanes after a
|
||||
# non-production mount or namespace is declared. It intentionally keeps the same
|
||||
# no-secret-value rule as production; generated test secrets still require a
|
||||
# separately approved non-production value path.
|
||||
|
||||
# Workload KV read-lane policies generated from approved CCRs.
|
||||
path "sys/policies/acl/workload-kv-read-*" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Credential broker issuer policies generated from approved grant metadata.
|
||||
path "sys/policies/acl/credential-broker-*-issuer" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# OIDC roles for caller-scoped workload KV lanes.
|
||||
path "auth/netkingdom/role/*-workload-kv-read" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Kubernetes roles for in-cluster workload and provider-secret lanes.
|
||||
path "auth/kubernetes/role/*" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Token roles for approved credential-broker child-token issuers.
|
||||
path "auth/token/roles/credential-broker-*" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Self-checks and capability introspection only.
|
||||
path "auth/token/lookup-self" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "sys/capabilities-self" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
41
openbao/policies/credential-change-prod-applier.hcl
Normal file
41
openbao/policies/credential-change-prod-applier.hcl
Normal file
@@ -0,0 +1,41 @@
|
||||
# Production metadata applier for reviewed credential changes.
|
||||
#
|
||||
# This policy intentionally permits only non-secret OpenBao metadata writes for
|
||||
# approved CCRs. Secret value paths under platform/data are not granted here.
|
||||
# The local credential-change applier-dry-run command must validate the CCR
|
||||
# before this policy is used for any live mutation.
|
||||
|
||||
# Workload KV read-lane policies generated from approved CCRs.
|
||||
path "sys/policies/acl/workload-kv-read-*" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Credential broker issuer policies generated from approved grant metadata.
|
||||
path "sys/policies/acl/credential-broker-*-issuer" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# OIDC roles for caller-scoped workload KV lanes.
|
||||
path "auth/netkingdom/role/*-workload-kv-read" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Kubernetes roles for in-cluster workload and provider-secret lanes. The local
|
||||
# applier dry-run constrains role names and bound service accounts per CCR.
|
||||
path "auth/kubernetes/role/*" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Token roles for approved credential-broker child-token issuers.
|
||||
path "auth/token/roles/credential-broker-*" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
|
||||
# Self-checks and capability introspection only.
|
||||
path "auth/token/lookup-self" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "sys/capabilities-self" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
13
openbao/policies/external-secrets-issue-core.hcl
Normal file
13
openbao/policies/external-secrets-issue-core.hcl
Normal file
@@ -0,0 +1,13 @@
|
||||
# Least-privilege policy for the External Secrets Operator issue-core pilot.
|
||||
#
|
||||
# The matching Kubernetes auth role binds only the ESO service account in the
|
||||
# external-secrets namespace. ClusterSecretStore usage is separately limited to
|
||||
# the issue-core namespace.
|
||||
|
||||
path "platform/data/workloads/issue-core/issue-core/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/workloads/issue-core/issue-core/*" {
|
||||
capabilities = ["read", "list"]
|
||||
}
|
||||
18
openbao/policies/warden-sign.hcl
Normal file
18
openbao/policies/warden-sign.hcl
Normal file
@@ -0,0 +1,18 @@
|
||||
# Narrow policy for ops-warden SSH signing (Vault/OpenBao SSH secrets engine).
|
||||
# Bind to dedicated tokens or Kubernetes auth roles — not platform-admin.
|
||||
|
||||
path "ssh/sign/adm-role" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
|
||||
path "ssh/sign/agt-role" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
|
||||
path "ssh/sign/atm-role" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
|
||||
path "ssh/roles" {
|
||||
capabilities = ["list", "read"]
|
||||
}
|
||||
7
openbao/policies/workload-kv-read-issue-core-runtime.hcl
Normal file
7
openbao/policies/workload-kv-read-issue-core-runtime.hcl
Normal file
@@ -0,0 +1,7 @@
|
||||
path "platform/data/workloads/issue-core/issue-core/issue-core-runtime" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/workloads/issue-core/issue-core/issue-core-runtime" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
path "platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/workloads/activity-core/llm-connect/llm-connect-provider-secrets" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Least-privilege read policy for the whynot-design npm publish token.
|
||||
#
|
||||
# This policy intentionally grants only read access to the single KV-v2 secret
|
||||
# path used by ops-warden's caller-scoped access lane. It does not grant list
|
||||
# access to sibling workloads or mutation capabilities.
|
||||
|
||||
path "platform/data/workloads/coulomb/whynot-design/npm-publish" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/workloads/coulomb/whynot-design/npm-publish" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
30
openbao/ssh/roles-spec.yaml
Normal file
30
openbao/ssh/roles-spec.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
# Declarative SSH CA roles for ops-warden ActorType policy.
|
||||
# TTL max: adm 48h, agt 24h, atm 8h — wiki/OpsWardenConfig.md (ops-warden)
|
||||
|
||||
mount: ssh
|
||||
|
||||
roles:
|
||||
adm-role:
|
||||
key_type: ca
|
||||
allowed_users: "*"
|
||||
allow_user_certificates: true
|
||||
allow_user_key_ids: true
|
||||
default_user: adm
|
||||
ttl: 48h
|
||||
max_ttl: 48h
|
||||
agt-role:
|
||||
key_type: ca
|
||||
allowed_users: "*"
|
||||
allow_user_certificates: true
|
||||
allow_user_key_ids: true
|
||||
default_user: agt
|
||||
ttl: 24h
|
||||
max_ttl: 24h
|
||||
atm-role:
|
||||
key_type: ca
|
||||
allowed_users: "*"
|
||||
allow_user_certificates: true
|
||||
allow_user_key_ids: true
|
||||
default_user: atm
|
||||
ttl: 8h
|
||||
max_ttl: 8h
|
||||
115
schemas/credential-change-request.schema.yaml
Normal file
115
schemas/credential-change-request.schema.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
schema_version: 1
|
||||
kind: credential-change-request-schema
|
||||
description: Non-secret schema contract for credential/security change requests.
|
||||
|
||||
required_top_level:
|
||||
- id
|
||||
- kind
|
||||
- schema_version
|
||||
- request_type
|
||||
- title
|
||||
- status
|
||||
- created
|
||||
- updated
|
||||
- requester
|
||||
- target
|
||||
- openbao
|
||||
- access_frontdoor
|
||||
- risk
|
||||
- verification
|
||||
- lifecycle
|
||||
|
||||
allowed_statuses:
|
||||
- draft
|
||||
- proposed
|
||||
- needs_changes
|
||||
- approved
|
||||
- denied
|
||||
- apply_pending
|
||||
- applied
|
||||
- verified
|
||||
- active
|
||||
- deactivated
|
||||
- rotated
|
||||
- compromised
|
||||
- superseded
|
||||
- cancelled
|
||||
|
||||
allowed_request_types:
|
||||
- workload-kv-read
|
||||
|
||||
secret_markers_rejected:
|
||||
- AGE-SECRET-KEY-1
|
||||
- "-----BEGIN PRIVATE KEY-----"
|
||||
- "-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
- OPENBAO_ROOT_TOKEN=
|
||||
- VAULT_TOKEN=
|
||||
- BAO_TOKEN=
|
||||
- hvb.
|
||||
- hvc.
|
||||
- hvs.
|
||||
- npm_
|
||||
- ghp_
|
||||
- sk-
|
||||
|
||||
workload_kv_read:
|
||||
required:
|
||||
openbao:
|
||||
- mount
|
||||
- kv_path
|
||||
- fields
|
||||
- policy_name
|
||||
- policy_file
|
||||
- auth
|
||||
openbao.auth:
|
||||
- method
|
||||
- mount
|
||||
- role
|
||||
- bound_claims
|
||||
- bound_claims_confirmed
|
||||
- policies
|
||||
access_frontdoor:
|
||||
- type
|
||||
- catalog_id
|
||||
- readiness
|
||||
- resolvable
|
||||
verification:
|
||||
- positive
|
||||
- negative
|
||||
- activation_conditions
|
||||
lifecycle:
|
||||
- deactivate
|
||||
- rotate
|
||||
- compromised
|
||||
conditional:
|
||||
openbao.auth.method=oidc:
|
||||
required:
|
||||
- allowed_redirect_uris
|
||||
allowed_redirect_uris: non-empty list of OpenBao callback URIs accepted by the role
|
||||
groups_claim: requires openbao.auth.oidc_scopes to include groups
|
||||
|
||||
access_frontdoor_readiness:
|
||||
allowed:
|
||||
- template
|
||||
- pending-review
|
||||
- approved-pending-apply
|
||||
- applied-pending-verify
|
||||
- ready
|
||||
- disabled
|
||||
- compromised
|
||||
resolvable_true_requires_status: active
|
||||
ops_warden_should_consume_only:
|
||||
readiness: ready
|
||||
resolvable: true
|
||||
|
||||
guardrails:
|
||||
apply_plan_requires_status:
|
||||
- approved
|
||||
active_requires_status:
|
||||
- verified
|
||||
disallowed_policy_names:
|
||||
- root
|
||||
- platform-admin
|
||||
disallowed_path_fragments:
|
||||
- "*"
|
||||
- ".."
|
||||
2189
scripts/credential-change.py
Executable file
2189
scripts/credential-change.py
Executable file
File diff suppressed because it is too large
Load Diff
389
scripts/credential-grants-validate.py
Executable file
389
scripts/credential-grants-validate.py
Executable file
@@ -0,0 +1,389 @@
|
||||
#!/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_nonempty_string(
|
||||
openbao.get("issuer_policy"), f"{prefix}.openbao.issuer_policy", 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")
|
||||
if "kubernetes-auth" in allowed:
|
||||
kubernetes_auth = require_dict(
|
||||
delivery.get("kubernetes_auth"),
|
||||
f"{prefix}.delivery.kubernetes_auth",
|
||||
errors,
|
||||
)
|
||||
require_nonempty_string(
|
||||
kubernetes_auth.get("mount"),
|
||||
f"{prefix}.delivery.kubernetes_auth.mount",
|
||||
errors,
|
||||
)
|
||||
require_nonempty_string(
|
||||
kubernetes_auth.get("role"),
|
||||
f"{prefix}.delivery.kubernetes_auth.role",
|
||||
errors,
|
||||
)
|
||||
if not require_list(
|
||||
kubernetes_auth.get("service_account_names"),
|
||||
f"{prefix}.delivery.kubernetes_auth.service_account_names",
|
||||
errors,
|
||||
):
|
||||
errors.append(
|
||||
f"{prefix}.delivery.kubernetes_auth.service_account_names must not be empty"
|
||||
)
|
||||
if not require_list(
|
||||
kubernetes_auth.get("namespaces"),
|
||||
f"{prefix}.delivery.kubernetes_auth.namespaces",
|
||||
errors,
|
||||
):
|
||||
errors.append(
|
||||
f"{prefix}.delivery.kubernetes_auth.namespaces must not be empty"
|
||||
)
|
||||
|
||||
audit = require_dict(grant_obj.get("audit"), f"{prefix}.audit", errors)
|
||||
if audit.get("openbao_audit_required") is not True:
|
||||
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())
|
||||
1075
scripts/credential.py
Executable file
1075
scripts/credential.py
Executable file
File diff suppressed because it is too large
Load Diff
217
scripts/openbao-apply-credential-change-appliers.py
Executable file
217
scripts/openbao-apply-credential-change-appliers.py
Executable file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
APPLIERS: dict[str, dict[str, Any]] = {
|
||||
"nonprod": {
|
||||
"title": "Credential change non-production metadata applier",
|
||||
"policy_name": "credential-change-nonprod-applier",
|
||||
"policy_file": "openbao/policies/credential-change-nonprod-applier.hcl",
|
||||
"token_role": "credential-change-nonprod-applier",
|
||||
"max_ttl": "1h",
|
||||
},
|
||||
"prod": {
|
||||
"title": "Credential change production metadata applier",
|
||||
"policy_name": "credential-change-prod-applier",
|
||||
"policy_file": "openbao/policies/credential-change-prod-applier.hcl",
|
||||
"token_role": "credential-change-prod-applier",
|
||||
"max_ttl": "30m",
|
||||
},
|
||||
}
|
||||
|
||||
DISALLOWED_POLICIES = ("root", "platform-admin")
|
||||
|
||||
|
||||
class BaoRunner:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
kubectl: str,
|
||||
namespace: str,
|
||||
release: str,
|
||||
dry_run: bool,
|
||||
use_token_helper: bool,
|
||||
token: str | None,
|
||||
) -> None:
|
||||
self.kubectl_parts = shlex.split(kubectl)
|
||||
self.namespace = namespace
|
||||
self.pod = f"{release}-0"
|
||||
self.dry_run = dry_run
|
||||
self.use_token_helper = use_token_helper
|
||||
self.token = token
|
||||
|
||||
def run(
|
||||
self, args: list[str], input_text: str | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
rendered = "bao " + shlex.join(args)
|
||||
if self.dry_run:
|
||||
print(f"DRY-RUN: {rendered}")
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
|
||||
if self.use_token_helper:
|
||||
cmd = (
|
||||
self.kubectl_parts
|
||||
+ ["exec", "-i", "-n", self.namespace, self.pod, "--", "bao"]
|
||||
+ args
|
||||
)
|
||||
proc_input = input_text
|
||||
else:
|
||||
if not self.token:
|
||||
raise RuntimeError(
|
||||
"OpenBao token is required unless --use-token-helper is set"
|
||||
)
|
||||
cmd = (
|
||||
self.kubectl_parts
|
||||
+ [
|
||||
"exec",
|
||||
"-i",
|
||||
"-n",
|
||||
self.namespace,
|
||||
self.pod,
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"',
|
||||
"sh",
|
||||
]
|
||||
+ args
|
||||
)
|
||||
proc_input = self.token + "\n" + (input_text or "")
|
||||
|
||||
result = subprocess.run(cmd, input=proc_input, capture_output=True, text=True)
|
||||
if result.stdout:
|
||||
print(result.stdout, end="")
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr, end="")
|
||||
raise SystemExit(result.returncode)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr, end="")
|
||||
return result
|
||||
|
||||
|
||||
def read_token(
|
||||
token_file: str | None, dry_run: bool, use_token_helper: bool
|
||||
) -> str | None:
|
||||
if dry_run or use_token_helper:
|
||||
return None
|
||||
if token_file:
|
||||
path = Path(token_file)
|
||||
if not path.exists():
|
||||
raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE does not exist: {path}")
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
token = lines[0].strip() if lines else ""
|
||||
if not token:
|
||||
raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE is empty: {path}")
|
||||
return token
|
||||
token = getpass.getpass("OpenBao token: ")
|
||||
if not token:
|
||||
raise SystemExit("ERROR: empty OpenBao token")
|
||||
return token
|
||||
|
||||
|
||||
def selected_appliers(selector: str) -> list[dict[str, Any]]:
|
||||
if selector == "all":
|
||||
return [APPLIERS["nonprod"], APPLIERS["prod"]]
|
||||
try:
|
||||
return [APPLIERS[selector]]
|
||||
except KeyError:
|
||||
raise SystemExit(f"ERROR: applier must be one of {sorted(APPLIERS) + ['all']}")
|
||||
|
||||
|
||||
def role_args(applier: dict[str, Any]) -> list[str]:
|
||||
return [
|
||||
"write",
|
||||
f"auth/token/roles/{applier['token_role']}",
|
||||
f"allowed_policies={applier['policy_name']}",
|
||||
f"disallowed_policies={','.join(DISALLOWED_POLICIES)}",
|
||||
"orphan=true",
|
||||
"renewable=false",
|
||||
f"token_explicit_max_ttl={applier['max_ttl']}",
|
||||
"token_no_default_policy=true",
|
||||
"token_type=service",
|
||||
]
|
||||
|
||||
|
||||
def write_policy(runner: BaoRunner, applier: dict[str, Any], policy_dir: Path) -> None:
|
||||
policy_file = policy_dir / Path(applier["policy_file"]).name
|
||||
if not policy_file.exists():
|
||||
raise SystemExit(f"ERROR: missing policy file: {policy_file}")
|
||||
if runner.dry_run:
|
||||
print(f"DRY-RUN: bao policy write {applier['policy_name']} {policy_file}")
|
||||
return
|
||||
runner.run(
|
||||
["policy", "write", applier["policy_name"], "-"],
|
||||
input_text=policy_file.read_text(encoding="utf-8"),
|
||||
)
|
||||
print(f"OK: policy {applier['policy_name']} applied")
|
||||
|
||||
|
||||
def apply_applier(runner: BaoRunner, applier: dict[str, Any], policy_dir: Path) -> None:
|
||||
write_policy(runner, applier, policy_dir)
|
||||
runner.run(role_args(applier))
|
||||
runner.run(["read", f"auth/token/roles/{applier['token_role']}"])
|
||||
print(
|
||||
"OK: applier role "
|
||||
f"{applier['token_role']} configured for policy {applier['policy_name']}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Apply OpenBao credential-change delegated applier policies and token roles."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--applier", choices=["nonprod", "prod", "all"], default="all"
|
||||
)
|
||||
parser.add_argument("--policy-dir", default="openbao/policies")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument(
|
||||
"--use-token-helper",
|
||||
action="store_true",
|
||||
help="Use the OpenBao CLI token helper inside the pod",
|
||||
)
|
||||
parser.add_argument("--namespace", default=None)
|
||||
parser.add_argument("--release", default=None)
|
||||
parser.add_argument("--kubectl", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
namespace = args.namespace or os.environ.get("OPENBAO_NAMESPACE", "openbao")
|
||||
release = args.release or os.environ.get("OPENBAO_RELEASE", "openbao")
|
||||
kubectl = args.kubectl or os.environ.get("KUBECTL", "kubectl")
|
||||
token_file = os.environ.get("OPENBAO_TOKEN_FILE")
|
||||
token = read_token(token_file, args.dry_run, args.use_token_helper)
|
||||
|
||||
runner = BaoRunner(
|
||||
kubectl=kubectl,
|
||||
namespace=namespace,
|
||||
release=release,
|
||||
dry_run=args.dry_run,
|
||||
use_token_helper=args.use_token_helper,
|
||||
token=token,
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
runner.run(["status"])
|
||||
|
||||
policy_dir = REPO_DIR / args.policy_dir
|
||||
for applier in selected_appliers(args.applier):
|
||||
apply_applier(runner, applier, policy_dir)
|
||||
|
||||
print("NEXT: issue short-lived child tokens through an approved custody path only.")
|
||||
print("NEXT: run scripts/credential-change.py applier-apply <CCR> with that ambient delegated authority.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
139
scripts/openbao-apply-external-secrets-issue-core.sh
Executable file
139
scripts/openbao-apply-external-secrets-issue-core.sh
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
|
||||
OPENBAO_RELEASE="${OPENBAO_RELEASE:-openbao}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
TOKEN_FILE="${OPENBAO_TOKEN_FILE:-}"
|
||||
ROLE_NAME="${OPENBAO_ESO_ROLE:-external-secrets-issue-core}"
|
||||
POLICY_NAME="${OPENBAO_ESO_POLICY:-external-secrets-issue-core}"
|
||||
ESO_NAMESPACE="${ESO_NAMESPACE:-external-secrets}"
|
||||
ESO_SERVICE_ACCOUNT="${ESO_SERVICE_ACCOUNT:-external-secrets}"
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
POLICY_FILE="${POLICY_FILE:-$REPO_DIR/openbao/policies/external-secrets-issue-core.hcl}"
|
||||
NEXT_KV_PATH="${OPENBAO_ESO_NEXT_PATH:-platform/workloads/issue-core/issue-core/issue-core-runtime}"
|
||||
NEXT_FIELDS="${OPENBAO_ESO_NEXT_FIELDS:-ISSUE_CORE_API_KEY and GITEA_BACKEND_TOKEN}"
|
||||
NEXT_TARGET="${OPENBAO_ESO_NEXT_TARGET:-ExternalSecret/issue-core-runtime}"
|
||||
DRY_RUN=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/openbao-apply-external-secrets-issue-core.sh [--dry-run]
|
||||
|
||||
Configures OpenBao for the issue-core External Secrets Operator pilot:
|
||||
- refreshes Kubernetes auth config for in-cluster short-lived tokens
|
||||
- writes the external-secrets-issue-core read policy
|
||||
- writes the Kubernetes auth role bound to external-secrets/external-secrets
|
||||
|
||||
The script reads an OpenBao operator token from OPENBAO_TOKEN_FILE or an
|
||||
interactive hidden prompt. It never prints or stores the token.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
pod="${OPENBAO_RELEASE}-0"
|
||||
|
||||
read_token() {
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'dry-run-token\n'
|
||||
return
|
||||
fi
|
||||
if [ -n "$TOKEN_FILE" ]; then
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "ERROR: OPENBAO_TOKEN_FILE does not exist: $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
head -n 1 "$TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
local token
|
||||
read -r -s -p "OpenBao token: " token
|
||||
printf '\n' >&2
|
||||
printf '%s\n' "$token"
|
||||
}
|
||||
|
||||
kubectl_exec() {
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL "$@"
|
||||
}
|
||||
|
||||
remote_bao() {
|
||||
local token="$1"
|
||||
shift
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao %s\n' "$*"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$token" | kubectl_exec exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"' sh "$@"
|
||||
}
|
||||
|
||||
remote_sh() {
|
||||
local token="$1"
|
||||
local script="$2"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: remote shell: %s\n' "$script"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n%s\n' "$token" "$script" | kubectl_exec exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; sh'
|
||||
}
|
||||
write_policy() {
|
||||
local token="$1"
|
||||
if [ ! -f "$POLICY_FILE" ]; then
|
||||
echo "ERROR: missing policy file: $POLICY_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao policy write %s %s\n' "$POLICY_NAME" "$POLICY_FILE"
|
||||
return 0
|
||||
fi
|
||||
{ printf '%s\n' "$token"; cat "$POLICY_FILE"; } | kubectl_exec exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; bao policy write "$1" -' sh "$POLICY_NAME"
|
||||
}
|
||||
|
||||
token="$(read_token)"
|
||||
if [ -z "$token" ]; then
|
||||
echo "ERROR: empty token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
remote_bao "$token" status
|
||||
remote_sh "$token" 'bao write auth/kubernetes/config \
|
||||
kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" \
|
||||
disable_iss_validation=true'
|
||||
write_policy "$token"
|
||||
remote_bao "$token" write "auth/kubernetes/role/${ROLE_NAME}" \
|
||||
"bound_service_account_names=${ESO_SERVICE_ACCOUNT}" \
|
||||
"bound_service_account_namespaces=${ESO_NAMESPACE}" \
|
||||
"policies=${POLICY_NAME}" \
|
||||
ttl=15m
|
||||
|
||||
remote_bao "$token" read "auth/kubernetes/role/${ROLE_NAME}"
|
||||
|
||||
cat <<NEXT
|
||||
|
||||
External Secrets OpenBao role configured.
|
||||
|
||||
Next steps:
|
||||
1. Sync the external-secrets and openbao-secretstore ArgoCD Applications.
|
||||
2. Provision ${NEXT_KV_PATH} with ${NEXT_FIELDS} without printing values.
|
||||
3. Confirm ${NEXT_TARGET} becomes Ready.
|
||||
NEXT
|
||||
@@ -188,8 +188,7 @@ enable_optional "$token" "kubernetes/ auth method is already enabled." auth enab
|
||||
|
||||
remote_sh "$token" 'bao write auth/kubernetes/config \
|
||||
kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" \
|
||||
token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token \
|
||||
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
|
||||
disable_iss_validation=true'
|
||||
|
||||
write_policy "$token" platform-admin "$POLICY_DIR/platform-admin.hcl"
|
||||
write_policy "$token" platform-readonly "$POLICY_DIR/platform-readonly.hcl"
|
||||
|
||||
271
scripts/openbao-apply-ssh-engine.sh
Executable file
271
scripts/openbao-apply-ssh-engine.sh
Executable file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
|
||||
OPENBAO_RELEASE="${OPENBAO_RELEASE:-openbao}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
TOKEN_FILE="${OPENBAO_TOKEN_FILE:-}"
|
||||
SSH_MOUNT="${OPENBAO_SSH_MOUNT:-ssh}"
|
||||
ROLES_SPEC="${ROLES_SPEC:-}"
|
||||
POLICY_DIR="${POLICY_DIR:-}"
|
||||
CA_PUBKEY_OUT="${OPENBAO_SSH_CA_PUBKEY_OUT:-}"
|
||||
K8S_CA_SECRET="${OPENBAO_SSH_CA_K8S_SECRET:-openbao-ssh-ca-pub}"
|
||||
DRY_RUN=0
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROLES_SPEC="${ROLES_SPEC:-$REPO_DIR/openbao/ssh/roles-spec.yaml}"
|
||||
POLICY_DIR="${POLICY_DIR:-$REPO_DIR/openbao/policies}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/openbao-apply-ssh-engine.sh [--dry-run]
|
||||
|
||||
Applies the OpenBao SSH secrets engine for ops-warden signing:
|
||||
- enable ssh/ mount (idempotent)
|
||||
- write adm/agt/atm roles from openbao/ssh/roles-spec.yaml
|
||||
- load warden-sign policy
|
||||
- export CA public key (optional K8s secret + local file)
|
||||
|
||||
Requires initialized, unsealed OpenBao and a token with ssh engine admin
|
||||
(typically platform-admin). Token from OPENBAO_TOKEN_FILE or prompt.
|
||||
|
||||
Env:
|
||||
OPENBAO_SSH_CA_PUBKEY_OUT Write CA pubkey to this path (non-secret)
|
||||
OPENBAO_SSH_CA_K8S_SECRET K8s secret name in openbao namespace (default: openbao-ssh-ca-pub)
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
pod="${OPENBAO_RELEASE}-0"
|
||||
WARNINGS=0
|
||||
|
||||
warn() {
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
printf 'WARN: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
read_token() {
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'dry-run-token\n'
|
||||
return
|
||||
fi
|
||||
if [ -n "$TOKEN_FILE" ]; then
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "ERROR: OPENBAO_TOKEN_FILE does not exist: $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
head -n 1 "$TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
local token
|
||||
read -r -s -p "OpenBao token: " token
|
||||
printf '\n' >&2
|
||||
printf '%s\n' "$token"
|
||||
}
|
||||
|
||||
kubectl_exec() {
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL "$@"
|
||||
}
|
||||
|
||||
remote_bao() {
|
||||
local token="$1"
|
||||
shift
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao %s\n' "$*"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$token" | kubectl_exec exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"' sh "$@"
|
||||
}
|
||||
|
||||
write_policy() {
|
||||
local token="$1"
|
||||
local name="$2"
|
||||
local file="$3"
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "ERROR: missing policy file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao policy write %s %s\n' "$name" "$file"
|
||||
return 0
|
||||
fi
|
||||
{ printf '%s\n' "$token"; cat "$file"; } | kubectl_exec exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; bao policy write "$1" -' sh "$name"
|
||||
}
|
||||
|
||||
ensure_default_issuer() {
|
||||
local token="$1"
|
||||
local issuers_out
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao read %s/config/issuers\n' "$SSH_MOUNT"
|
||||
printf 'DRY-RUN: bao write %s/config/ca generate_signing_key=true key_type=ed25519\n' "$SSH_MOUNT"
|
||||
return 0
|
||||
fi
|
||||
if issuers_out="$(remote_bao "$token" read "${SSH_MOUNT}/config/issuers" 2>&1)"; then
|
||||
printf 'OK: default SSH issuer already configured.\n'
|
||||
return 0
|
||||
fi
|
||||
case "$issuers_out" in
|
||||
*"no default issuer"*)
|
||||
remote_bao "$token" write "${SSH_MOUNT}/config/ca" \
|
||||
generate_signing_key=true key_type=ed25519
|
||||
printf 'OK: generated default SSH CA issuer.\n'
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$issuers_out" >&2
|
||||
echo "ERROR: failed to read SSH issuer configuration" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
enable_ssh_engine() {
|
||||
local token="$1"
|
||||
local output status
|
||||
if output="$(remote_bao "$token" secrets enable -path="$SSH_MOUNT" ssh 2>&1)"; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
case "$output" in
|
||||
*"path is already in use"*)
|
||||
printf 'OK: %s/ SSH secrets engine is already enabled.\n' "$SSH_MOUNT"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$output" >&2
|
||||
echo "ERROR: failed to enable SSH engine (exit $status)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
apply_roles() {
|
||||
local token="$1"
|
||||
if [ ! -f "$ROLES_SPEC" ]; then
|
||||
echo "ERROR: roles spec not found: $ROLES_SPEC" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
python3 - "$ROLES_SPEC" "$SSH_MOUNT" <<'PY'
|
||||
import sys, yaml
|
||||
spec = yaml.safe_load(open(sys.argv[1]))
|
||||
mount = sys.argv[2]
|
||||
for name, params in (spec.get("roles") or {}).items():
|
||||
args = " ".join(f"{k}={v}" for k, v in params.items())
|
||||
print(f"DRY-RUN: bao write {mount}/roles/{name} {args}")
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
python3 - "$token" "$ROLES_SPEC" "$SSH_MOUNT" "$OPENBAO_NAMESPACE" "$pod" "$KUBECTL" <<'PY'
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
token, spec_path, mount, namespace, pod, kubectl = sys.argv[1:7]
|
||||
import yaml
|
||||
|
||||
kubectl_parts = shlex.split(kubectl) if kubectl else ["kubectl"]
|
||||
spec = yaml.safe_load(open(spec_path))
|
||||
roles = spec.get("roles") or {}
|
||||
for role_name, params in roles.items():
|
||||
args = [f"{k}={v}" for k, v in params.items()]
|
||||
cmd = ["bao", "write", f"{mount}/roles/{role_name}"] + args
|
||||
proc = subprocess.run(
|
||||
kubectl_parts + ["exec", "-i", "-n", namespace, pod, "--", "sh", "-c",
|
||||
"read -r BAO_TOKEN; export BAO_TOKEN; exec bao \"$@\"", "sh"] + cmd,
|
||||
input=(token + "\n").encode(),
|
||||
capture_output=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
sys.stderr.write(proc.stderr.decode())
|
||||
raise SystemExit(f"failed to write role {role_name}")
|
||||
print(f"OK: {mount}/roles/{role_name}")
|
||||
PY
|
||||
}
|
||||
|
||||
export_ca_pubkey() {
|
||||
local token="$1"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao read -field=public_key %s/public_key\n' "$SSH_MOUNT"
|
||||
return 0
|
||||
fi
|
||||
local pubkey
|
||||
pubkey="$(remote_bao "$token" read -field=public_key "${SSH_MOUNT}/config/ca" 2>/dev/null || true)"
|
||||
if [ -z "$pubkey" ]; then
|
||||
pubkey="$(remote_bao "$token" read -field=public_key "${SSH_MOUNT}/public_key" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$pubkey" ]; then
|
||||
warn "Could not read SSH CA public key from ${SSH_MOUNT}/config/ca or ${SSH_MOUNT}/public_key"
|
||||
return 0
|
||||
fi
|
||||
local fingerprint
|
||||
fingerprint="$(printf '%s' "$pubkey" | sha256sum | awk '{print $1}')"
|
||||
printf 'OK: SSH CA public key fingerprint sha256:%s\n' "$fingerprint"
|
||||
|
||||
if [ -n "$CA_PUBKEY_OUT" ]; then
|
||||
mkdir -p "$(dirname "$CA_PUBKEY_OUT")"
|
||||
printf '%s\n' "$pubkey" >"$CA_PUBKEY_OUT"
|
||||
chmod 644 "$CA_PUBKEY_OUT"
|
||||
printf 'OK: wrote CA pubkey to %s\n' "$CA_PUBKEY_OUT"
|
||||
fi
|
||||
|
||||
if [ -n "$K8S_CA_SECRET" ]; then
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$pubkey" >"$tmp"
|
||||
kubectl_exec create secret generic "$K8S_CA_SECRET" \
|
||||
--namespace "$OPENBAO_NAMESPACE" \
|
||||
--from-file=ca_user.pub="$tmp" \
|
||||
--dry-run=client -o yaml | kubectl_exec apply -f -
|
||||
rm -f "$tmp"
|
||||
printf 'OK: K8s secret %s/%s updated\n' "$OPENBAO_NAMESPACE" "$K8S_CA_SECRET"
|
||||
fi
|
||||
}
|
||||
|
||||
token="$(read_token)"
|
||||
if [ -z "$token" ]; then
|
||||
echo "ERROR: empty token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
remote_bao "$token" status
|
||||
enable_ssh_engine "$token"
|
||||
ensure_default_issuer "$token"
|
||||
apply_roles "$token"
|
||||
write_policy "$token" warden-sign "$POLICY_DIR/warden-sign.hcl"
|
||||
remote_bao "$token" list "${SSH_MOUNT}/roles"
|
||||
export_ca_pubkey "$token"
|
||||
|
||||
cat <<NEXT
|
||||
|
||||
OpenBao SSH engine configuration applied.
|
||||
|
||||
Next steps:
|
||||
1. make openbao-verify-ssh
|
||||
2. railiance-infra: make bootstrap-ssh-ca SSH_CA_PUBKEY=<path>
|
||||
3. ops-warden: warden sign smoke (WP-0008 T2)
|
||||
NEXT
|
||||
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
printf '\nCompleted with %s warning(s).\n' "$WARNINGS"
|
||||
fi
|
||||
219
scripts/openbao-apply-token-grants.py
Executable file
219
scripts/openbao-apply-token-grants.py
Executable file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class BaoRunner:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
kubectl: str,
|
||||
namespace: str,
|
||||
release: str,
|
||||
dry_run: bool,
|
||||
use_token_helper: bool,
|
||||
token: str | None,
|
||||
) -> None:
|
||||
self.kubectl_parts = shlex.split(kubectl)
|
||||
self.namespace = namespace
|
||||
self.pod = f"{release}-0"
|
||||
self.dry_run = dry_run
|
||||
self.use_token_helper = use_token_helper
|
||||
self.token = token
|
||||
|
||||
def run(
|
||||
self, args: list[str], input_text: str | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
rendered = "bao " + shlex.join(args)
|
||||
if self.dry_run:
|
||||
print(f"DRY-RUN: {rendered}")
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
|
||||
if self.use_token_helper:
|
||||
cmd = (
|
||||
self.kubectl_parts
|
||||
+ ["exec", "-i", "-n", self.namespace, self.pod, "--", "bao"]
|
||||
+ args
|
||||
)
|
||||
proc_input = input_text
|
||||
else:
|
||||
if not self.token:
|
||||
raise RuntimeError(
|
||||
"OpenBao token is required unless --use-token-helper is set"
|
||||
)
|
||||
cmd = (
|
||||
self.kubectl_parts
|
||||
+ [
|
||||
"exec",
|
||||
"-i",
|
||||
"-n",
|
||||
self.namespace,
|
||||
self.pod,
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
'read -r BAO_TOKEN; export BAO_TOKEN; export VAULT_TOKEN="$BAO_TOKEN"; exec bao "$@"',
|
||||
"sh",
|
||||
]
|
||||
+ args
|
||||
)
|
||||
proc_input = self.token + "\n" + (input_text or "")
|
||||
|
||||
result = subprocess.run(cmd, input=proc_input, capture_output=True, text=True)
|
||||
if result.stdout:
|
||||
print(result.stdout, end="")
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr, end="")
|
||||
raise SystemExit(result.returncode)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr, end="")
|
||||
return result
|
||||
|
||||
|
||||
def read_token(
|
||||
token_file: str | None, dry_run: bool, use_token_helper: bool
|
||||
) -> str | None:
|
||||
if dry_run or use_token_helper:
|
||||
return None
|
||||
if token_file:
|
||||
path = Path(token_file)
|
||||
if not path.exists():
|
||||
raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE does not exist: {path}")
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
token = lines[0].strip() if lines else ""
|
||||
if not token:
|
||||
raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE is empty: {path}")
|
||||
return token
|
||||
token = getpass.getpass("OpenBao token: ")
|
||||
if not token:
|
||||
raise SystemExit("ERROR: empty OpenBao token")
|
||||
return token
|
||||
|
||||
|
||||
def load_catalog(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"ERROR: catalog root must be an object: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def selected_grants(
|
||||
catalog: dict[str, Any], grant_id: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
grants = catalog.get("grants") or []
|
||||
if not isinstance(grants, list):
|
||||
raise SystemExit("ERROR: catalog grants must be a list")
|
||||
selected = [
|
||||
grant for grant in grants if not grant_id or grant.get("id") == grant_id
|
||||
]
|
||||
if grant_id and not selected:
|
||||
raise SystemExit(f"ERROR: grant not found in catalog: {grant_id}")
|
||||
return selected
|
||||
|
||||
|
||||
def role_args(grant: dict[str, Any]) -> list[str]:
|
||||
openbao = grant["openbao"]
|
||||
ttl = grant["ttl"]
|
||||
policies = ",".join(openbao["policies"])
|
||||
disallowed = ",".join(openbao.get("disallowed_policies") or [])
|
||||
args = [
|
||||
"write",
|
||||
f"auth/token/roles/{openbao['token_role']}",
|
||||
f"allowed_policies={policies}",
|
||||
f"disallowed_policies={disallowed}",
|
||||
"orphan=true",
|
||||
"renewable=false",
|
||||
f"token_explicit_max_ttl={ttl['max']}",
|
||||
"token_no_default_policy=true",
|
||||
"token_type=service",
|
||||
]
|
||||
return args
|
||||
|
||||
|
||||
def write_policy(runner: BaoRunner, name: str, policy_file: Path) -> None:
|
||||
if not policy_file.exists():
|
||||
raise SystemExit(f"ERROR: missing policy file: {policy_file}")
|
||||
if runner.dry_run:
|
||||
print(f"DRY-RUN: bao policy write {name} {policy_file}")
|
||||
return
|
||||
runner.run(
|
||||
["policy", "write", name, "-"],
|
||||
input_text=policy_file.read_text(encoding="utf-8"),
|
||||
)
|
||||
print(f"OK: policy {name} applied")
|
||||
|
||||
|
||||
def apply_grant(runner: BaoRunner, grant: dict[str, Any], policy_dir: Path) -> None:
|
||||
if grant.get("credential_type") != "openbao-token":
|
||||
print(f"SKIP: {grant.get('id')} is not an openbao-token grant")
|
||||
return
|
||||
openbao = grant["openbao"]
|
||||
issuer_policy = openbao["issuer_policy"]
|
||||
policy_file = policy_dir / f"{issuer_policy}.hcl"
|
||||
write_policy(runner, issuer_policy, policy_file)
|
||||
runner.run(role_args(grant))
|
||||
runner.run(["read", f"auth/token/roles/{openbao['token_role']}"])
|
||||
print(f"OK: token role {openbao['token_role']} configured for grant {grant['id']}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Apply OpenBao token roles for credential grants."
|
||||
)
|
||||
parser.add_argument("--catalog", default="credential-grants/catalog.yaml")
|
||||
parser.add_argument("--policy-dir", default="openbao/policies")
|
||||
parser.add_argument("--grant", help="Limit to one grant id")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument(
|
||||
"--use-token-helper",
|
||||
action="store_true",
|
||||
help="Use the OpenBao CLI token helper inside the pod",
|
||||
)
|
||||
parser.add_argument("--namespace", default=None)
|
||||
parser.add_argument("--release", default=None)
|
||||
parser.add_argument("--kubectl", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
|
||||
namespace = args.namespace or os.environ.get("OPENBAO_NAMESPACE", "openbao")
|
||||
release = args.release or os.environ.get("OPENBAO_RELEASE", "openbao")
|
||||
kubectl = args.kubectl or os.environ.get("KUBECTL", "kubectl")
|
||||
token_file = os.environ.get("OPENBAO_TOKEN_FILE")
|
||||
token = read_token(token_file, args.dry_run, args.use_token_helper)
|
||||
|
||||
catalog = load_catalog(REPO_DIR / args.catalog)
|
||||
runner = BaoRunner(
|
||||
kubectl=kubectl,
|
||||
namespace=namespace,
|
||||
release=release,
|
||||
dry_run=args.dry_run,
|
||||
use_token_helper=args.use_token_helper,
|
||||
token=token,
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
runner.run(["status"])
|
||||
|
||||
policy_dir = REPO_DIR / args.policy_dir
|
||||
for grant in selected_grants(catalog, args.grant):
|
||||
apply_grant(runner, grant, policy_dir)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
137
scripts/openbao-apply-workload-kv-lanes.sh
Executable file
137
scripts/openbao-apply-workload-kv-lanes.sh
Executable file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
|
||||
OPENBAO_RELEASE="${OPENBAO_RELEASE:-openbao}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
TOKEN_FILE="${OPENBAO_TOKEN_FILE:-}"
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
POLICY_NAME="${WORKLOAD_KV_POLICY_NAME:-workload-kv-read-whynot-design-npm-publish}"
|
||||
POLICY_FILE="${WORKLOAD_KV_POLICY_FILE:-$REPO_DIR/openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl}"
|
||||
DRY_RUN=0
|
||||
USE_TOKEN_HELPER=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/openbao-apply-workload-kv-lanes.sh [--dry-run] [--use-token-helper]
|
||||
|
||||
Applies source-owned OpenBao workload KV read-lane policies.
|
||||
|
||||
Current lane:
|
||||
- policy: workload-kv-read-whynot-design-npm-publish
|
||||
- path: platform/workloads/coulomb/whynot-design/npm-publish
|
||||
- field: NPM_AUTH_TOKEN
|
||||
|
||||
The script reads an OpenBao operator token from OPENBAO_TOKEN_FILE or an
|
||||
interactive hidden prompt unless --dry-run or --use-token-helper is set. It
|
||||
never prints or stores the token.
|
||||
|
||||
This script intentionally does not create an OIDC role until the whynot-design
|
||||
KeyCape/NetKingdom bound claim is confirmed.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--use-token-helper)
|
||||
USE_TOKEN_HELPER=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
pod="${OPENBAO_RELEASE}-0"
|
||||
|
||||
read_token() {
|
||||
if [ "$DRY_RUN" -eq 1 ] || [ "$USE_TOKEN_HELPER" -eq 1 ]; then
|
||||
return
|
||||
fi
|
||||
if [ -n "$TOKEN_FILE" ]; then
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "ERROR: OPENBAO_TOKEN_FILE does not exist: $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
head -n 1 "$TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local token
|
||||
read -r -s -p "OpenBao token: " token
|
||||
printf '\n' >&2
|
||||
printf '%s\n' "$token"
|
||||
}
|
||||
|
||||
remote_bao() {
|
||||
local token="$1"
|
||||
shift
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao %s\n' "$*"
|
||||
return 0
|
||||
fi
|
||||
if [ "$USE_TOKEN_HELPER" -eq 1 ]; then
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- bao "$@"
|
||||
return
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
printf '%s\n' "$token" | $KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"' sh "$@"
|
||||
}
|
||||
|
||||
write_policy() {
|
||||
local token="$1"
|
||||
if [ ! -f "$POLICY_FILE" ]; then
|
||||
echo "ERROR: missing policy file: $POLICY_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao policy write %s %s\n' "$POLICY_NAME" "$POLICY_FILE"
|
||||
return 0
|
||||
fi
|
||||
if [ "$USE_TOKEN_HELPER" -eq 1 ]; then
|
||||
# shellcheck disable=SC2086
|
||||
cat "$POLICY_FILE" | $KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
bao policy write "$POLICY_NAME" -
|
||||
return
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
{ printf '%s\n' "$token"; cat "$POLICY_FILE"; } | \
|
||||
$KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; bao policy write "$1" -' sh "$POLICY_NAME"
|
||||
}
|
||||
|
||||
token="$(read_token)"
|
||||
if [ "$DRY_RUN" -eq 0 ] && [ "$USE_TOKEN_HELPER" -eq 0 ] && [ -z "$token" ]; then
|
||||
echo "ERROR: empty OpenBao token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
remote_bao "$token" status
|
||||
write_policy "$token"
|
||||
remote_bao "$token" policy read "$POLICY_NAME"
|
||||
|
||||
cat <<'NEXT'
|
||||
|
||||
Workload KV read-lane policy apply path completed.
|
||||
|
||||
Remaining live steps:
|
||||
1. Confirm the whynot-design KeyCape/NetKingdom bound claim or service account.
|
||||
2. Create auth/netkingdom/role/whynot-design-workload-kv-read with only the
|
||||
workload-kv-read-whynot-design-npm-publish policy.
|
||||
3. Provision platform/workloads/coulomb/whynot-design/npm-publish with
|
||||
field NPM_AUTH_TOKEN through approved OpenBao/operator custody.
|
||||
4. Run positive and negative fetch verification without printing the token.
|
||||
NEXT
|
||||
49
scripts/openbao-tune-auth-listing.sh
Executable file
49
scripts/openbao-tune-auth-listing.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
|
||||
OPENBAO_RELEASE="${OPENBAO_RELEASE:-openbao}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
TOKEN_FILE="${OPENBAO_TOKEN_FILE:-}"
|
||||
MOUNTS="${OPENBAO_AUTH_LISTING_MOUNTS:-netkingdom keycape}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/openbao-tune-auth-listing.sh
|
||||
|
||||
Sets listing_visibility=unauth on configured OIDC auth mounts so the OpenBao
|
||||
browser UI can discover netkingdom without falling back to token auth.
|
||||
|
||||
Environment:
|
||||
OPENBAO_TOKEN_FILE Token file with platform-admin or root token
|
||||
OPENBAO_AUTH_LISTING_MOUNTS Space-separated mount paths. Default: netkingdom keycape
|
||||
USAGE
|
||||
}
|
||||
|
||||
read_token() {
|
||||
if [ -n "$TOKEN_FILE" ]; then
|
||||
head -n 1 "$TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
local token
|
||||
read -r -s -p "OpenBao token: " token
|
||||
printf '\n' >&2
|
||||
printf '%s\n' "$token"
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pod="${OPENBAO_RELEASE}-0"
|
||||
token="$(read_token)"
|
||||
|
||||
for mount in $MOUNTS; do
|
||||
printf '%s\n' "$token" | $KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
bao write "sys/auth/${mount}/tune" listing_visibility=unauth
|
||||
printf '[OK] auth/%s listing_visibility=unauth\n' "$mount"
|
||||
done
|
||||
|
||||
printf '\nVerify unauthenticated UI mount listing:\n'
|
||||
curl -fsS "https://bao.coulomb.social/v1/sys/internal/ui/mounts" | python3 -m json.tool
|
||||
74
scripts/openbao-ui-overlay-apply.sh
Executable file
74
scripts/openbao-ui-overlay-apply.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OVERLAY_DIR="${OPENBAO_UI_OVERLAY_DIR:-$ROOT_DIR/helm/openbao-ui-overlay}"
|
||||
K8S_MANIFEST="${OPENBAO_UI_OVERLAY_K8S:-$ROOT_DIR/helm/openbao-ui-overlay-k8s.yaml}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/openbao-ui-overlay-apply.sh
|
||||
|
||||
Builds and applies the OpenBao KeyCape login overlay ConfigMaps and gateway
|
||||
Deployment/Service/Ingress. Idempotent — safe to run on every openbao-deploy.
|
||||
|
||||
Environment:
|
||||
OPENBAO_NAMESPACE Kubernetes namespace. Default: openbao
|
||||
KUBECTL kubectl command, including --kubeconfig if needed
|
||||
OPENBAO_UI_OVERLAY_DIR Overlay asset directory
|
||||
OPENBAO_UI_OVERLAY_K8S Gateway manifest path
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for required in overlay.css overlay.js callback.html callback.js login.css login.html login.js presets.json nginx.conf VERSION; do
|
||||
if [ ! -f "$OVERLAY_DIR/$required" ]; then
|
||||
echo "missing overlay asset: $OVERLAY_DIR/$required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "$K8S_MANIFEST" ]; then
|
||||
echo "missing gateway manifest: $K8S_MANIFEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL create namespace "$OPENBAO_NAMESPACE" --dry-run=client -o yaml | $KUBECTL apply -f -
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL create configmap openbao-ui-overlay \
|
||||
--namespace "$OPENBAO_NAMESPACE" \
|
||||
--from-file="$OVERLAY_DIR/overlay.css" \
|
||||
--from-file="$OVERLAY_DIR/overlay.js" \
|
||||
--from-file="$OVERLAY_DIR/callback.html" \
|
||||
--from-file="$OVERLAY_DIR/callback.js" \
|
||||
--from-file="$OVERLAY_DIR/login.css" \
|
||||
--from-file="$OVERLAY_DIR/login.html" \
|
||||
--from-file="$OVERLAY_DIR/login.js" \
|
||||
--from-file="$OVERLAY_DIR/presets.json" \
|
||||
--from-file="$OVERLAY_DIR/VERSION" \
|
||||
--dry-run=client -o yaml | $KUBECTL apply -f -
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL create configmap openbao-ui-gateway-nginx \
|
||||
--namespace "$OPENBAO_NAMESPACE" \
|
||||
--from-file=nginx.conf="$OVERLAY_DIR/nginx.conf" \
|
||||
--dry-run=client -o yaml | $KUBECTL apply -f -
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL apply -f "$K8S_MANIFEST"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL rollout restart deployment/openbao-ui-gateway -n "$OPENBAO_NAMESPACE"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$KUBECTL rollout status deployment/openbao-ui-gateway -n "$OPENBAO_NAMESPACE" --timeout=120s
|
||||
|
||||
printf '[OK] OpenBao UI overlay applied from %s\n' "$OVERLAY_DIR"
|
||||
@@ -15,7 +15,7 @@ Usage: scripts/openbao-verify-authenticated.sh [--dry-run] [--use-token-helper]
|
||||
Runs authenticated, non-mutating OpenBao readiness checks:
|
||||
- audit list includes file/
|
||||
- secrets list includes platform/
|
||||
- auth list includes kubernetes/ and keycape/
|
||||
- auth list includes kubernetes/, netkingdom/, and keycape/
|
||||
- audit log exists and is non-empty
|
||||
|
||||
The token is read from OPENBAO_TOKEN_FILE or an interactive hidden prompt. The
|
||||
@@ -130,6 +130,7 @@ Path Type
|
||||
---- ----
|
||||
keycape/ oidc
|
||||
kubernetes/ kubernetes
|
||||
netkingdom/ oidc
|
||||
token/ token
|
||||
AUTH
|
||||
;;
|
||||
@@ -210,6 +211,7 @@ step "Auth methods"
|
||||
if auth_output="$(remote_bao "$token" auth list 2>&1)"; then
|
||||
printf '%s\n' "$auth_output"
|
||||
require_pattern "kubernetes/ auth method is visible" "$auth_output" '(^|[[:space:]])kubernetes/'
|
||||
require_pattern "netkingdom/ auth method is visible" "$auth_output" '(^|[[:space:]])netkingdom/'
|
||||
require_pattern "keycape/ auth method is visible" "$auth_output" '(^|[[:space:]])keycape/'
|
||||
else
|
||||
printf '%s\n' "$auth_output" >&2
|
||||
|
||||
173
scripts/openbao-verify-login-overlay.sh
Executable file
173
scripts/openbao-verify-login-overlay.sh
Executable file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${OPENBAO_UI_BASE_URL:-https://bao.coulomb.social}"
|
||||
OVERLAY_DIR="${OPENBAO_UI_OVERLAY_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/helm/openbao-ui-overlay}"
|
||||
CHECK_DRIFT="${CHECK_UPSTREAM_DRIFT:-0}"
|
||||
|
||||
ok() { printf '[OK] %s\n' "$*"; }
|
||||
err() { printf '[ERR] %s\n' "$*" >&2; }
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/openbao-verify-login-overlay.sh [--check-upstream-drift]
|
||||
|
||||
Verifies the public OpenBao UI serves the KeyCape login overlay assets and
|
||||
that index.html injection is present.
|
||||
|
||||
Environment:
|
||||
OPENBAO_UI_BASE_URL Public UI base URL. Default: https://bao.coulomb.social
|
||||
OPENBAO_UI_OVERLAY_DIR Local overlay directory for drift fingerprints
|
||||
CHECK_UPSTREAM_DRIFT Set to 1 to compare live UI hashes with patches/
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--check-upstream-drift)
|
||||
CHECK_DRIFT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
err "unknown argument: $1"
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_pattern() {
|
||||
local label="$1"
|
||||
local haystack="$2"
|
||||
local pattern="$3"
|
||||
if ! grep -Eq "$pattern" <<<"$haystack"; then
|
||||
err "$label"
|
||||
return 1
|
||||
fi
|
||||
ok "$label"
|
||||
}
|
||||
|
||||
step "Standalone login page"
|
||||
auth_html="$(curl -fsS "$BASE_URL/ui/vault/auth")"
|
||||
require_pattern \
|
||||
"auth page serves standalone KeyCape login" \
|
||||
"$auth_html" \
|
||||
'id="login-submit"|Sign in with KeyCape'
|
||||
|
||||
if grep -Eq 'vault-|engines-dist' <<<"$auth_html"; then
|
||||
err "auth page still serves Ember shell (expected standalone login.html)"
|
||||
exit 1
|
||||
fi
|
||||
ok "auth page is standalone login.html (no Ember shell)"
|
||||
|
||||
callback_html="$(curl -fsS "$BASE_URL/ui/vault/auth/netkingdom/oidc/callback")"
|
||||
require_pattern \
|
||||
"OIDC callback serves standalone handler" \
|
||||
"$callback_html" \
|
||||
'Signing in with KeyCape|callback.js'
|
||||
|
||||
if grep -Eq 'window\.opener\.postMessage|vault-' <<<"$callback_html"; then
|
||||
err "OIDC callback still serves Ember shell (expected standalone callback.html)"
|
||||
exit 1
|
||||
fi
|
||||
ok "OIDC callback is standalone callback.html (no Ember postMessage flow)"
|
||||
|
||||
step "Overlay asset endpoints"
|
||||
index_html="$(curl -fsS "$BASE_URL/ui/")"
|
||||
overlay_js="$(curl -fsS "$BASE_URL/ui/platform-overlay/overlay.js")"
|
||||
overlay_css="$(curl -fsS "$BASE_URL/ui/platform-overlay/overlay.css")"
|
||||
presets_json="$(curl -fsS "$BASE_URL/ui/platform-overlay/presets.json")"
|
||||
|
||||
require_pattern \
|
||||
"index.html injects overlay.js" \
|
||||
"$index_html" \
|
||||
'/ui/platform-overlay/overlay\.js'
|
||||
|
||||
require_pattern \
|
||||
"index.html injects overlay.css" \
|
||||
"$index_html" \
|
||||
'/ui/platform-overlay/overlay\.css'
|
||||
|
||||
require_pattern \
|
||||
"overlay.js activates KeyCape overlay" \
|
||||
"$overlay_js" \
|
||||
'keycape-overlay-active'
|
||||
|
||||
require_pattern \
|
||||
"overlay.js starts direct KeyCape OIDC redirect" \
|
||||
"$overlay_js" \
|
||||
'oidc/auth_url'
|
||||
|
||||
require_pattern \
|
||||
"presets.json targets netkingdom mount" \
|
||||
"$presets_json" \
|
||||
'"mount"[[:space:]]*:[[:space:]]*"netkingdom"'
|
||||
|
||||
require_pattern \
|
||||
"presets.json targets platform-admin role" \
|
||||
"$presets_json" \
|
||||
'"role"[[:space:]]*:[[:space:]]*"platform-admin"'
|
||||
|
||||
require_pattern \
|
||||
"overlay.css hides namespace picker" \
|
||||
"$overlay_css" \
|
||||
'toolbar-namespace-picker'
|
||||
|
||||
require_pattern \
|
||||
"overlay branding title present in presets" \
|
||||
"$presets_json" \
|
||||
'Sign in with KeyCape'
|
||||
|
||||
step "Hidden-field selectors still present in overlay.js"
|
||||
require_pattern \
|
||||
"overlay.js hides namespace input" \
|
||||
"$overlay_js" \
|
||||
'#namespace|input\[name="namespace"\]'
|
||||
|
||||
require_pattern \
|
||||
"overlay.js hides role input" \
|
||||
"$overlay_js" \
|
||||
'#role|input\[name="role"\]'
|
||||
|
||||
require_pattern \
|
||||
"overlay.js hides mount path input" \
|
||||
"$overlay_js" \
|
||||
'#custom-path|input\[name="custom-path"\]'
|
||||
|
||||
if [ "$CHECK_DRIFT" = "1" ]; then
|
||||
step "Upstream UI drift check"
|
||||
version_file="$OVERLAY_DIR/VERSION"
|
||||
if [ ! -f "$version_file" ]; then
|
||||
err "missing overlay VERSION file: $version_file"
|
||||
exit 1
|
||||
fi
|
||||
version="$(tr -d '[:space:]' < "$version_file")"
|
||||
manifest="$OVERLAY_DIR/patches/$version/manifest.sha256"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
err "missing fingerprint manifest: $manifest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
vault_asset="$(grep -Eo '/ui/assets/vault-[a-f0-9]+\.js' <<<"$index_html" | head -1 || true)"
|
||||
if [ -z "$vault_asset" ]; then
|
||||
err "could not locate vault.js asset path in index.html"
|
||||
exit 1
|
||||
fi
|
||||
live_vault_hash="$(curl -fsS "$BASE_URL$vault_asset" | sha256sum | awk '{print $1}')"
|
||||
|
||||
expected_vault_hash="$(awk '!/^#/ && /ui\/assets\/vault-/ {print $1; exit}' "$manifest")"
|
||||
expected_vault_path="$(awk '!/^#/ && /ui\/assets\/vault-/ {print $2; exit}' "$manifest")"
|
||||
|
||||
if [ -n "$expected_vault_hash" ] && [ "$live_vault_hash" != "$expected_vault_hash" ]; then
|
||||
err "vault bundle hash drift for ${vault_asset:-unknown}: expected $expected_vault_hash got $live_vault_hash"
|
||||
exit 1
|
||||
fi
|
||||
ok "vault bundle hash matches patches/$version/manifest.sha256 (${expected_vault_path:-$vault_asset})"
|
||||
fi
|
||||
|
||||
printf '\nOpenBao login overlay verification passed for %s\n' "$BASE_URL"
|
||||
111
scripts/openbao-verify-ssh-engine.sh
Executable file
111
scripts/openbao-verify-ssh-engine.sh
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OPENBAO_NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
|
||||
OPENBAO_RELEASE="${OPENBAO_RELEASE:-openbao}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
TOKEN_FILE="${OPENBAO_TOKEN_FILE:-}"
|
||||
SSH_MOUNT="${OPENBAO_SSH_MOUNT:-ssh}"
|
||||
EXPECTED_ROLES="${OPENBAO_SSH_EXPECTED_ROLES:-adm-role agt-role atm-role}"
|
||||
USE_TOKEN_HELPER=0
|
||||
DRY_RUN=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/openbao-verify-ssh-engine.sh [--dry-run] [--use-token-helper]
|
||||
|
||||
Non-mutating checks: ssh/ mount present, expected roles listed, warden-sign policy.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--use-token-helper) USE_TOKEN_HELPER=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
pod="${OPENBAO_RELEASE}-0"
|
||||
FAILURES=0
|
||||
|
||||
fail() { FAILURES=$((FAILURES + 1)); printf '[FAIL] %s\n' "$*" >&2; }
|
||||
ok() { printf '[OK] %s\n' "$*"; }
|
||||
|
||||
read_token() {
|
||||
if [ "$USE_TOKEN_HELPER" -eq 1 ]; then
|
||||
printf '__USE_TOKEN_HELPER__\n'
|
||||
return
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'dry-run-token\n'
|
||||
return
|
||||
fi
|
||||
if [ -n "$TOKEN_FILE" ] && [ -f "$TOKEN_FILE" ]; then
|
||||
head -n 1 "$TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
local token
|
||||
read -r -s -p "OpenBao token: " token
|
||||
printf '\n' >&2
|
||||
printf '%s\n' "$token"
|
||||
}
|
||||
|
||||
remote_bao() {
|
||||
local token="$1"
|
||||
shift
|
||||
if [ "$token" = "__USE_TOKEN_HELPER__" ]; then
|
||||
$KUBECTL exec -n "$OPENBAO_NAMESPACE" "$pod" -- bao "$@"
|
||||
return
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'DRY-RUN: bao %s\n' "$*"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$token" | $KUBECTL exec -i -n "$OPENBAO_NAMESPACE" "$pod" -- \
|
||||
sh -c 'read -r BAO_TOKEN; export BAO_TOKEN; exec bao "$@"' sh "$@"
|
||||
}
|
||||
|
||||
token="$(read_token)"
|
||||
secrets_out="$(remote_bao "$token" secrets list 2>&1)" || {
|
||||
fail "secrets list failed: $secrets_out"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if printf '%s\n' "$secrets_out" | grep -Eq "(^|[[:space:]])${SSH_MOUNT}/"; then
|
||||
ok "SSH mount ${SSH_MOUNT}/ is enabled"
|
||||
else
|
||||
fail "SSH mount ${SSH_MOUNT}/ not found in secrets list"
|
||||
fi
|
||||
|
||||
roles_out="$(remote_bao "$token" list "${SSH_MOUNT}/roles" 2>&1)" || {
|
||||
fail "list ${SSH_MOUNT}/roles failed: $roles_out"
|
||||
exit 1
|
||||
}
|
||||
|
||||
for role in $EXPECTED_ROLES; do
|
||||
if printf '%s\n' "$roles_out" | grep -q "$role"; then
|
||||
ok "role ${role} exists"
|
||||
else
|
||||
fail "role ${role} missing"
|
||||
fi
|
||||
done
|
||||
|
||||
policy_out="$(remote_bao "$token" policy list 2>&1)" || {
|
||||
fail "policy list failed: $policy_out"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if printf '%s\n' "$policy_out" | grep -q 'warden-sign'; then
|
||||
ok "policy warden-sign present"
|
||||
else
|
||||
fail "policy warden-sign missing"
|
||||
fi
|
||||
|
||||
if [ "$FAILURES" -gt 0 ]; then
|
||||
printf '\nSSH engine verification failed (%s failure(s)).\n' "$FAILURES" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '\nSSH engine verification passed.\n'
|
||||
313
scripts/openbao-verify-token-grants.py
Executable file
313
scripts/openbao-verify-token-grants.py
Executable file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class BaoRunner:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
kubectl: str,
|
||||
namespace: str,
|
||||
release: str,
|
||||
dry_run: bool,
|
||||
use_token_helper: bool,
|
||||
token: str | None,
|
||||
) -> None:
|
||||
self.kubectl_parts = shlex.split(kubectl)
|
||||
self.namespace = namespace
|
||||
self.pod = f"{release}-0"
|
||||
self.dry_run = dry_run
|
||||
self.use_token_helper = use_token_helper
|
||||
self.token = token
|
||||
|
||||
def run(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
input_text: str | None = None,
|
||||
check: bool = True,
|
||||
quiet: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if self.dry_run:
|
||||
print("DRY-RUN: bao " + shlex.join(args))
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
|
||||
if self.use_token_helper:
|
||||
cmd = (
|
||||
self.kubectl_parts
|
||||
+ ["exec", "-i", "-n", self.namespace, self.pod, "--", "bao"]
|
||||
+ args
|
||||
)
|
||||
proc_input = input_text
|
||||
else:
|
||||
if not self.token:
|
||||
raise RuntimeError(
|
||||
"OpenBao token is required unless --use-token-helper is set"
|
||||
)
|
||||
cmd = (
|
||||
self.kubectl_parts
|
||||
+ [
|
||||
"exec",
|
||||
"-i",
|
||||
"-n",
|
||||
self.namespace,
|
||||
self.pod,
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
'read -r BAO_TOKEN; export BAO_TOKEN; export VAULT_TOKEN="$BAO_TOKEN"; exec bao "$@"',
|
||||
"sh",
|
||||
]
|
||||
+ args
|
||||
)
|
||||
proc_input = self.token + "\n" + (input_text or "")
|
||||
|
||||
result = subprocess.run(cmd, input=proc_input, capture_output=True, text=True)
|
||||
if check and result.returncode != 0:
|
||||
if result.stdout and not quiet:
|
||||
print(result.stdout, end="")
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr, end="")
|
||||
raise SystemExit(result.returncode)
|
||||
if not quiet and result.stdout:
|
||||
print(result.stdout, end="")
|
||||
if not quiet and result.stderr:
|
||||
print(result.stderr, file=sys.stderr, end="")
|
||||
return result
|
||||
|
||||
|
||||
def run_with_token(
|
||||
*,
|
||||
kubectl: str,
|
||||
namespace: str,
|
||||
release: str,
|
||||
token: str,
|
||||
args: list[str],
|
||||
check: bool,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
kubectl_parts = shlex.split(kubectl)
|
||||
cmd = (
|
||||
kubectl_parts
|
||||
+ [
|
||||
"exec",
|
||||
"-i",
|
||||
"-n",
|
||||
namespace,
|
||||
f"{release}-0",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
'read -r BAO_TOKEN; export BAO_TOKEN; export VAULT_TOKEN="$BAO_TOKEN"; exec bao "$@"',
|
||||
"sh",
|
||||
]
|
||||
+ args
|
||||
)
|
||||
return subprocess.run(
|
||||
cmd, input=token + "\n", capture_output=True, text=True, check=False
|
||||
)
|
||||
|
||||
|
||||
def read_token(
|
||||
token_file: str | None, dry_run: bool, use_token_helper: bool
|
||||
) -> str | None:
|
||||
if dry_run or use_token_helper:
|
||||
return None
|
||||
if token_file:
|
||||
path = Path(token_file).expanduser()
|
||||
if not path.exists():
|
||||
raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE does not exist: {path}")
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
token = lines[0].strip() if lines else ""
|
||||
if not token:
|
||||
raise SystemExit(f"ERROR: OPENBAO_TOKEN_FILE is empty: {path}")
|
||||
return token
|
||||
token = getpass.getpass("OpenBao token: ")
|
||||
if not token:
|
||||
raise SystemExit("ERROR: empty OpenBao token")
|
||||
return token
|
||||
|
||||
|
||||
def load_catalog(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"ERROR: catalog root must be an object: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def selected_grants(
|
||||
catalog: dict[str, Any], grant_id: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
grants = catalog.get("grants") or []
|
||||
if not isinstance(grants, list):
|
||||
raise SystemExit("ERROR: catalog grants must be a list")
|
||||
selected = [
|
||||
grant for grant in grants if not grant_id or grant.get("id") == grant_id
|
||||
]
|
||||
if grant_id and not selected:
|
||||
raise SystemExit(f"ERROR: grant not found in catalog: {grant_id}")
|
||||
return selected
|
||||
|
||||
|
||||
def verify_static(runner: BaoRunner, grant: dict[str, Any]) -> None:
|
||||
openbao = grant["openbao"]
|
||||
runner.run(["read", f"auth/token/roles/{openbao['token_role']}"])
|
||||
runner.run(["policy", "read", openbao["issuer_policy"]])
|
||||
runner.run(["policy", "read", openbao["policies"][0]])
|
||||
print(f"OK: static token-grant config readable for {grant['id']}")
|
||||
|
||||
|
||||
def issue_smoke_token(
|
||||
runner: BaoRunner,
|
||||
*,
|
||||
kubectl: str,
|
||||
namespace: str,
|
||||
release: str,
|
||||
grant: dict[str, Any],
|
||||
) -> None:
|
||||
openbao = grant["openbao"]
|
||||
ttl = grant["ttl"]["default"]
|
||||
args = [
|
||||
"token",
|
||||
"create",
|
||||
f"-role={openbao['token_role']}",
|
||||
f"-ttl={ttl}",
|
||||
"-format=json",
|
||||
]
|
||||
for policy in openbao["policies"]:
|
||||
args.append(f"-policy={policy}")
|
||||
result = runner.run(args, quiet=True, check=False)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(
|
||||
f"ERROR: token create failed (rc={result.returncode}): "
|
||||
f"{(result.stderr or result.stdout or '').strip()}"
|
||||
)
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
auth = payload.get("auth") or payload.get("data") or {}
|
||||
child_token = auth["client_token"]
|
||||
accessor = auth["accessor"]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise SystemExit(
|
||||
f"ERROR: could not parse token create response: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
key_path = Path(tmpdir) / "warden-sign-smoke_ed25519"
|
||||
keygen = subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if keygen.returncode != 0:
|
||||
raise SystemExit(
|
||||
"ERROR: could not generate smoke SSH key: "
|
||||
f"{(keygen.stderr or keygen.stdout).strip()}"
|
||||
)
|
||||
public_key = key_path.with_suffix(key_path.suffix + ".pub").read_text(encoding="utf-8").strip()
|
||||
|
||||
positive = run_with_token(
|
||||
kubectl=kubectl,
|
||||
namespace=namespace,
|
||||
release=release,
|
||||
token=child_token,
|
||||
args=["write", "-field=signed_key", "ssh/sign/agt-role", f"public_key={public_key}"],
|
||||
check=False,
|
||||
)
|
||||
if positive.returncode != 0 or not positive.stdout.strip():
|
||||
raise SystemExit(
|
||||
"ERROR: child token could not sign with ssh/sign/agt-role: "
|
||||
f"{(positive.stderr or positive.stdout).strip()}"
|
||||
)
|
||||
print("OK: child token can sign with ssh/sign/agt-role")
|
||||
|
||||
negative = run_with_token(
|
||||
kubectl=kubectl,
|
||||
namespace=namespace,
|
||||
release=release,
|
||||
token=child_token,
|
||||
args=["policy", "read", "warden-sign"],
|
||||
check=False,
|
||||
)
|
||||
if negative.returncode == 0:
|
||||
raise SystemExit("ERROR: child token unexpectedly read policy metadata")
|
||||
print("OK: child token cannot read policy metadata")
|
||||
finally:
|
||||
runner.run(["write", "auth/token/revoke-accessor", f"accessor={accessor}"], quiet=True)
|
||||
print("OK: smoke child token revoked by accessor")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify OpenBao token grants.")
|
||||
parser.add_argument("--catalog", default="credential-grants/catalog.yaml")
|
||||
parser.add_argument("--grant", help="Limit to one grant id")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument(
|
||||
"--use-token-helper",
|
||||
action="store_true",
|
||||
help="Use the OpenBao CLI token helper inside the pod",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--issue-smoke-token",
|
||||
action="store_true",
|
||||
help="Mint and revoke a short-lived child token for live verification",
|
||||
)
|
||||
parser.add_argument("--namespace", default=None)
|
||||
parser.add_argument("--release", default=None)
|
||||
parser.add_argument("--kubectl", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
|
||||
namespace = args.namespace or os.environ.get("OPENBAO_NAMESPACE", "openbao")
|
||||
release = args.release or os.environ.get("OPENBAO_RELEASE", "openbao")
|
||||
kubectl = args.kubectl or os.environ.get("KUBECTL", "kubectl")
|
||||
token_file = os.environ.get("OPENBAO_TOKEN_FILE")
|
||||
token = read_token(token_file, args.dry_run, args.use_token_helper)
|
||||
catalog = load_catalog(REPO_DIR / args.catalog)
|
||||
runner = BaoRunner(
|
||||
kubectl=kubectl,
|
||||
namespace=namespace,
|
||||
release=release,
|
||||
dry_run=args.dry_run,
|
||||
use_token_helper=args.use_token_helper,
|
||||
token=token,
|
||||
)
|
||||
|
||||
for grant in selected_grants(catalog, args.grant):
|
||||
verify_static(runner, grant)
|
||||
if args.issue_smoke_token:
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"DRY-RUN: would mint and revoke smoke child token for {grant['id']}"
|
||||
)
|
||||
else:
|
||||
issue_smoke_token(
|
||||
runner,
|
||||
kubectl=kubectl,
|
||||
namespace=namespace,
|
||||
release=release,
|
||||
grant=grant,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
672
tests/test_credential_change.py
Normal file
672
tests/test_credential_change.py
Normal file
@@ -0,0 +1,672 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"credential_change", REPO_DIR / "scripts/credential-change.py"
|
||||
)
|
||||
credential_change = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = credential_change
|
||||
SPEC.loader.exec_module(credential_change)
|
||||
|
||||
|
||||
class CredentialChangeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.sample = (
|
||||
REPO_DIR
|
||||
/ "credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml"
|
||||
)
|
||||
self.issue_core = (
|
||||
REPO_DIR
|
||||
/ "credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml"
|
||||
)
|
||||
|
||||
def test_sample_ccr_validates_without_bound_claim_warning(self) -> None:
|
||||
ccr, errors, warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertTrue(ccr["openbao"]["auth"]["bound_claims_confirmed"])
|
||||
|
||||
def test_issue_core_ccr_has_confirmed_eso_binding(self) -> None:
|
||||
ccr, errors, warnings = credential_change.validate_ccr(self.issue_core)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(ccr["openbao"]["auth"]["role"], "external-secrets-issue-core")
|
||||
|
||||
def test_all_repo_ccrs_validate(self) -> None:
|
||||
for path in sorted((REPO_DIR / "credential-change-requests").glob("*.yaml")):
|
||||
with self.subTest(path=path.name):
|
||||
_ccr, errors, _warnings = credential_change.validate_ccr(path)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_render_summary_contains_review_fields(self) -> None:
|
||||
ccr, _errors, warnings = credential_change.validate_ccr(self.sample)
|
||||
rendered = credential_change.render_summary(ccr, warnings)
|
||||
self.assertIn("whynot-design npm publish token lane", rendered)
|
||||
self.assertIn("platform/workloads/coulomb/whynot-design/npm-publish", rendered)
|
||||
self.assertIn("whynot-design-npm-publish", rendered)
|
||||
self.assertIn("readiness: ready resolvable=True", rendered)
|
||||
self.assertIn("approve | deny | needs_changes", rendered)
|
||||
|
||||
def test_status_payload_marks_active_ready_resolvable(self) -> None:
|
||||
ccr, _errors, warnings = credential_change.validate_ccr(self.sample)
|
||||
payload = credential_change.status_payload(ccr, warnings)
|
||||
self.assertFalse(payload["apply_allowed"])
|
||||
self.assertTrue(payload["apply_complete"])
|
||||
self.assertTrue(payload["frontdoor_resolvable"])
|
||||
self.assertEqual(payload["status"], "active")
|
||||
self.assertEqual(payload["access_frontdoor"]["readiness"], "ready")
|
||||
self.assertEqual(payload["access_frontdoor"]["catalog_id"], "whynot-design-npm-publish")
|
||||
self.assertEqual(payload["apply_blockers"], [])
|
||||
self.assertEqual(payload["frontdoor_blockers"], [])
|
||||
self.assertEqual(payload["warnings"], [])
|
||||
self.assertEqual(
|
||||
payload["state_hub"]["decision_id"],
|
||||
"e6381a56-6b04-4fd5-b2de-f3ef59cde888",
|
||||
)
|
||||
|
||||
def test_state_hub_rationale_prefix_maps_to_ccr_status(self) -> None:
|
||||
cases = {
|
||||
"APPROVE: scoped path and binding are correct": "approved",
|
||||
"DENY: wrong tenant": "denied",
|
||||
"NEEDS_CHANGES: use a read-only token": "needs_changes",
|
||||
"request changes: clarify service account": "needs_changes",
|
||||
}
|
||||
for rationale, expected in cases.items():
|
||||
with self.subTest(rationale=rationale):
|
||||
self.assertEqual(
|
||||
credential_change.ccr_status_from_state_hub_rationale(rationale),
|
||||
expected,
|
||||
)
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.ccr_status_from_state_hub_rationale("looks good")
|
||||
|
||||
def test_sync_state_hub_decision_updates_ccr_status(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
copied_ccr = credential_change.load_yaml(copied)
|
||||
copied_ccr["status"] = "proposed"
|
||||
copied_ccr["access_frontdoor"]["readiness"] = "template"
|
||||
copied_ccr["access_frontdoor"]["resolvable"] = False
|
||||
copied_ccr["access_frontdoor"]["activation"] = "pending-review"
|
||||
copied_ccr.setdefault("state_hub", {})[
|
||||
"decision_id"
|
||||
] = "250669d0-8475-4527-9624-cd072249f9a9"
|
||||
credential_change.dump_yaml(copied, copied_ccr)
|
||||
original = credential_change.state_hub_decision_status
|
||||
try:
|
||||
credential_change.state_hub_decision_status = lambda _ccr, _url: {
|
||||
"id": "250669d0-8475-4527-9624-cd072249f9a9",
|
||||
"status": "resolved",
|
||||
"rationale": "APPROVE: scoped path and confirmed binding are acceptable",
|
||||
"decided_by": "unit-test",
|
||||
"decided_at": "2026-06-27T22:00:00Z",
|
||||
}
|
||||
credential_change.sync_state_hub_decision(copied, "http://state-hub.test")
|
||||
finally:
|
||||
credential_change.state_hub_decision_status = original
|
||||
ccr, errors, warnings = credential_change.validate_ccr(copied)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(ccr["status"], "approved")
|
||||
self.assertEqual(ccr["review"]["comments"][-1]["reviewer"], "unit-test")
|
||||
self.assertIn("State Hub decision", ccr["review"]["comments"][-1]["comment"])
|
||||
self.assertEqual(ccr["state_hub"]["decision_resolved_at"], "2026-06-27T22:00:00Z")
|
||||
|
||||
def test_kubernetes_auth_payload_uses_service_account_bounds(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.issue_core)
|
||||
self.assertEqual(errors, [])
|
||||
payload = credential_change.auth_payload(ccr)
|
||||
self.assertEqual(payload["bound_service_account_names"], ["external-secrets"])
|
||||
self.assertEqual(payload["bound_service_account_namespaces"], ["external-secrets"])
|
||||
self.assertNotIn("bound_claims", payload)
|
||||
|
||||
def test_oidc_auth_payload_includes_redirect_uris(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
payload = credential_change.auth_payload(ccr)
|
||||
self.assertEqual(
|
||||
payload["allowed_redirect_uris"],
|
||||
[
|
||||
"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback",
|
||||
"http://localhost:8250/oidc/callback",
|
||||
"http://127.0.0.1:8250/oidc/callback",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["oidc_scopes"],
|
||||
["openid", "profile", "email", "groups"],
|
||||
)
|
||||
|
||||
def test_apply_plan_refuses_unapproved_ccr(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.command_apply_plan(type("Args", (), {"ref": str(self.issue_core)})())
|
||||
|
||||
def test_plan_includes_source_artifact_diff_status(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
rendered = credential_change.render_plan(ccr)
|
||||
self.assertIn("Source artifact diff:", rendered)
|
||||
self.assertIn("artifact status: matches", rendered)
|
||||
|
||||
def test_decision_templates_prefill_review_context(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
rendered = credential_change.render_decision_templates(ccr)
|
||||
self.assertIn("APPROVE: CCR-2026-0001", rendered)
|
||||
self.assertIn("DENY: CCR-2026-0001", rendered)
|
||||
self.assertIn("NEEDS_CHANGES: CCR-2026-0001", rendered)
|
||||
self.assertIn("platform/workloads/coulomb/whynot-design/npm-publish", rendered)
|
||||
self.assertIn("workload-kv-read-whynot-design-npm-publish", rendered)
|
||||
self.assertIn("auth/netkingdom/role/whynot-design-workload-kv-read", rendered)
|
||||
|
||||
def test_invalid_state_hub_rationale_shows_templates(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
credential_change.ccr_status_from_state_hub_rationale("looks good", ccr)
|
||||
self.assertIn("APPROVE: CCR-2026-0001", str(raised.exception))
|
||||
self.assertIn("NEEDS_CHANGES: CCR-2026-0001", str(raised.exception))
|
||||
|
||||
def test_decision_command_can_record_state_hub_event(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.issue_core.name
|
||||
shutil.copy2(self.issue_core, copied)
|
||||
events = []
|
||||
original = credential_change.state_hub_post_json
|
||||
try:
|
||||
credential_change.state_hub_post_json = (
|
||||
lambda _base_url, _path, payload: events.append(payload) or {"id": "event-1"}
|
||||
)
|
||||
exit_code = credential_change.command_decision(
|
||||
type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"ref": str(copied),
|
||||
"reviewer": "unit-test",
|
||||
"comment": "scoped metadata looks correct",
|
||||
"record_state_hub": True,
|
||||
"state_hub_url": "http://state-hub.test",
|
||||
},
|
||||
)(),
|
||||
"approved",
|
||||
)
|
||||
finally:
|
||||
credential_change.state_hub_post_json = original
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(events[0]["event_type"], "credential_change_decision")
|
||||
self.assertIn("CCR-2026-0002", events[0]["summary"])
|
||||
self.assertIn("ISSUE_CORE_API_KEY", events[0]["summary"])
|
||||
|
||||
def test_operator_commands_render_non_secret_policy_and_role_handoff(self) -> None:
|
||||
ccr, errors, warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
rendered = credential_change.render_operator_commands(ccr)
|
||||
self.assertIn(
|
||||
"bao policy write workload-kv-read-whynot-design-npm-publish",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"bao write auth/netkingdom/role/whynot-design-workload-kv-read",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("# Do not paste this shell block into the OpenBao Browser CLI.", rendered)
|
||||
self.assertIn(
|
||||
"# Web UI API Explorer path for the role JSON body: /v1/auth/netkingdom/role/whynot-design-workload-kv-read",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn('role_payload_file="$(mktemp)"', rendered)
|
||||
self.assertIn('"bound_claims": {', rendered)
|
||||
self.assertIn('"allowed_redirect_uris": [', rendered)
|
||||
self.assertIn('"oidc_scopes": [', rendered)
|
||||
self.assertIn('"groups"', rendered)
|
||||
self.assertIn(
|
||||
'"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback"',
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
'bao write auth/netkingdom/role/whynot-design-workload-kv-read @"$role_payload_file"',
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"# bao kv put platform/workloads/coulomb/whynot-design/npm-publish",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("NPM_AUTH_TOKEN=<enter-through-approved-custody>", rendered)
|
||||
self.assertNotIn("npm_", rendered)
|
||||
|
||||
def test_operator_commands_refuse_unapproved_ccr(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.command_operator_commands(
|
||||
type("Args", (), {"ref": str(self.issue_core)})()
|
||||
)
|
||||
|
||||
def test_approve_records_comment_but_unconfirmed_claim_still_blocks_apply(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
ccr_dir = tmp_path / "ccrs"
|
||||
ccr_dir.mkdir()
|
||||
copied = ccr_dir / self.issue_core.name
|
||||
shutil.copy2(self.issue_core, copied)
|
||||
old_ccr_dir = os.environ.get("CCR_DIR")
|
||||
os.environ["CCR_DIR"] = str(ccr_dir)
|
||||
try:
|
||||
credential_change.append_decision(
|
||||
copied, "approved", "unit-test", "looks right"
|
||||
)
|
||||
copied_data = credential_change.load_yaml(copied)
|
||||
copied_data["openbao"]["auth"]["bound_claims_confirmed"] = False
|
||||
credential_change.dump_yaml(copied, copied_data)
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(copied)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(ccr["status"], "approved")
|
||||
self.assertEqual(ccr["review"]["comments"][-1]["comment"], "looks right")
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.command_apply_plan(
|
||||
type("Args", (), {"ref": "CCR-2026-0002"})()
|
||||
)
|
||||
finally:
|
||||
if old_ccr_dir is None:
|
||||
os.environ.pop("CCR_DIR", None)
|
||||
else:
|
||||
os.environ["CCR_DIR"] = old_ccr_dir
|
||||
|
||||
def test_confirm_binding_records_comment_and_clears_warning(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.issue_core.name
|
||||
shutil.copy2(self.issue_core, copied)
|
||||
credential_change.confirm_binding(
|
||||
copied, "unit-test", "service account binding confirmed"
|
||||
)
|
||||
ccr, errors, warnings = credential_change.validate_ccr(copied)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertTrue(ccr["openbao"]["auth"]["bound_claims_confirmed"])
|
||||
self.assertEqual(ccr["review"]["comments"][-1]["decision"], "binding_confirmed")
|
||||
|
||||
def test_generated_policy_is_narrow(self) -> None:
|
||||
ccr, _errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
policy = credential_change.generated_policy_hcl(ccr)
|
||||
self.assertIn('path "platform/data/workloads/coulomb/whynot-design/npm-publish"', policy)
|
||||
self.assertNotIn("*", policy)
|
||||
self.assertNotIn("delete", policy)
|
||||
|
||||
def test_applier_dry_run_succeeds_for_active_ccr(self) -> None:
|
||||
ccr, errors, warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(credential_change.applier_readiness_blockers(ccr), [])
|
||||
payload = credential_change.applier_dry_run_payload(ccr, warnings)
|
||||
self.assertEqual(payload["source_artifacts"]["policy"]["status"], "matches")
|
||||
self.assertEqual(
|
||||
payload["mutations"][0]["openbao_path"],
|
||||
"sys/policies/acl/workload-kv-read-whynot-design-npm-publish",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["mutations"][1]["openbao_path"],
|
||||
"auth/netkingdom/role/whynot-design-workload-kv-read",
|
||||
)
|
||||
rendered = credential_change.render_applier_dry_run(payload)
|
||||
self.assertIn("Allowed metadata mutations", rendered)
|
||||
self.assertIn("secret value writes", rendered)
|
||||
self.assertNotIn("<enter-through-approved-custody>", rendered)
|
||||
|
||||
def test_applier_dry_run_refuses_unapproved_ccr(self) -> None:
|
||||
exit_code = credential_change.command_applier_dry_run(
|
||||
type("Args", (), {"ref": str(self.issue_core), "json": False})()
|
||||
)
|
||||
self.assertEqual(exit_code, 1)
|
||||
|
||||
def test_applier_dry_run_rejects_out_of_policy_policy_name(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
ccr["status"] = "approved"
|
||||
ccr["openbao"]["policy_name"] = "platform-admin"
|
||||
ccr["openbao"]["auth"]["policies"] = ["platform-admin"]
|
||||
blockers = credential_change.applier_readiness_blockers(ccr)
|
||||
self.assertTrue(
|
||||
any("disallowed" in blocker for blocker in blockers),
|
||||
blockers,
|
||||
)
|
||||
|
||||
def test_applier_dry_run_rejects_out_of_policy_auth_role(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
ccr["status"] = "approved"
|
||||
ccr["openbao"]["auth"]["role"] = "platform-admin"
|
||||
blockers = credential_change.applier_readiness_blockers(ccr)
|
||||
self.assertTrue(
|
||||
any("auth.role is disallowed" in blocker for blocker in blockers),
|
||||
blockers,
|
||||
)
|
||||
|
||||
def test_applier_dry_run_rejects_out_of_scope_mount_and_path(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
ccr["status"] = "approved"
|
||||
ccr["openbao"]["mount"] = "secret"
|
||||
ccr["openbao"]["kv_path"] = "secret/platform-admin"
|
||||
blockers = credential_change.applier_readiness_blockers(ccr)
|
||||
self.assertIn("openbao.mount must be platform, got secret", blockers)
|
||||
self.assertIn("openbao.kv_path must stay under platform/workloads/", blockers)
|
||||
|
||||
def test_nonprod_applier_policy_remains_metadata_only(self) -> None:
|
||||
policy = (
|
||||
REPO_DIR / "openbao/policies/credential-change-nonprod-applier.hcl"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn('path "sys/policies/acl/workload-kv-read-*"', policy)
|
||||
self.assertIn('path "auth/kubernetes/role/*"', policy)
|
||||
self.assertNotIn('path "platform/data/', policy)
|
||||
self.assertNotIn('path "platform/metadata/', policy)
|
||||
|
||||
def test_applier_apply_plan_renders_confirmation(self) -> None:
|
||||
ccr, errors, warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
rendered = credential_change.render_applier_apply_plan(ccr, warnings)
|
||||
self.assertIn("DELEGATED APPLY CCR-2026-0001", rendered)
|
||||
self.assertIn("applier-apply CCR-2026-0001", rendered)
|
||||
self.assertIn("secret value writes", rendered)
|
||||
|
||||
def test_applier_apply_refuses_unapproved_ccr(self) -> None:
|
||||
exit_code = credential_change.command_applier_apply(
|
||||
type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"ref": str(self.issue_core),
|
||||
"actor": "unit-test",
|
||||
"confirm": None,
|
||||
"bao_bin": "bao",
|
||||
"plan_only": False,
|
||||
"json": False,
|
||||
"quiet": True,
|
||||
"record_state_hub": False,
|
||||
"state_hub_url": "http://state-hub.test",
|
||||
},
|
||||
)()
|
||||
)
|
||||
self.assertEqual(exit_code, 1)
|
||||
|
||||
def test_applier_apply_records_metadata_evidence(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
ccr = credential_change.load_yaml(copied)
|
||||
ccr["status"] = "approved"
|
||||
ccr["access_frontdoor"]["readiness"] = "approved-pending-apply"
|
||||
ccr["access_frontdoor"]["resolvable"] = False
|
||||
credential_change.dump_yaml(copied, ccr)
|
||||
calls = []
|
||||
events = []
|
||||
original_apply = credential_change.run_bao_metadata_apply
|
||||
original_post = credential_change.state_hub_post_json
|
||||
try:
|
||||
credential_change.run_bao_metadata_apply = lambda ccr, bao_bin: calls.append((ccr["id"], bao_bin))
|
||||
credential_change.state_hub_post_json = (
|
||||
lambda _base_url, _path, payload: events.append(payload) or {"id": "event-1"}
|
||||
)
|
||||
exit_code = credential_change.command_applier_apply(
|
||||
type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"ref": str(copied),
|
||||
"actor": "unit-test",
|
||||
"confirm": "DELEGATED APPLY CCR-2026-0001",
|
||||
"bao_bin": "bao-test",
|
||||
"plan_only": False,
|
||||
"json": False,
|
||||
"quiet": True,
|
||||
"record_state_hub": True,
|
||||
"state_hub_url": "http://state-hub.test",
|
||||
},
|
||||
)()
|
||||
)
|
||||
finally:
|
||||
credential_change.run_bao_metadata_apply = original_apply
|
||||
credential_change.state_hub_post_json = original_post
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(calls, [("CCR-2026-0001", "bao-test")])
|
||||
updated = credential_change.load_yaml(copied)
|
||||
self.assertEqual(updated["status"], "applied")
|
||||
self.assertEqual(updated["verification"]["evidence"][-1]["kind"], "delegated_metadata_apply")
|
||||
self.assertEqual(events[0]["event_type"], "credential_change_evidence")
|
||||
self.assertIn("delegated_metadata_apply", events[0]["summary"])
|
||||
|
||||
def test_applier_apply_requires_exact_confirmation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
ccr = credential_change.load_yaml(copied)
|
||||
ccr["status"] = "approved"
|
||||
credential_change.dump_yaml(copied, ccr)
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.command_applier_apply(
|
||||
type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"ref": str(copied),
|
||||
"actor": "unit-test",
|
||||
"confirm": "apply it",
|
||||
"bao_bin": "bao",
|
||||
"plan_only": False,
|
||||
"json": False,
|
||||
"quiet": True,
|
||||
"record_state_hub": False,
|
||||
"state_hub_url": "http://state-hub.test",
|
||||
},
|
||||
)()
|
||||
)
|
||||
|
||||
def test_runbook_renders_apply_verify_guidance(self) -> None:
|
||||
ccr, errors, warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
payload = credential_change.runbook_payload(ccr, warnings)
|
||||
rendered = credential_change.render_runbook(payload)
|
||||
self.assertIn("APPLY CCR-2026-0001", rendered)
|
||||
self.assertIn("runbook <CCR> --execute-metadata", rendered)
|
||||
self.assertIn("record-evidence <CCR>", rendered)
|
||||
self.assertIn("Field presence checked without printing values", rendered)
|
||||
self.assertNotIn("npm_", rendered)
|
||||
|
||||
def test_runbook_refuses_unapproved_ccr(self) -> None:
|
||||
exit_code = credential_change.command_runbook(
|
||||
type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"ref": str(self.issue_core),
|
||||
"json": False,
|
||||
"execute_metadata": False,
|
||||
"actor": "unit-test",
|
||||
"confirm": None,
|
||||
"bao_bin": "bao",
|
||||
"record_state_hub": False,
|
||||
"state_hub_url": "http://state-hub.test",
|
||||
},
|
||||
)()
|
||||
)
|
||||
self.assertEqual(exit_code, 1)
|
||||
|
||||
def test_record_evidence_appends_non_secret_entry_and_status(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
ccr = credential_change.load_yaml(copied)
|
||||
ccr["status"] = "approved"
|
||||
ccr["access_frontdoor"]["readiness"] = "approved-pending-apply"
|
||||
ccr["access_frontdoor"]["resolvable"] = False
|
||||
credential_change.dump_yaml(copied, ccr)
|
||||
updated = credential_change.append_evidence(
|
||||
copied,
|
||||
"unit-test",
|
||||
"metadata_apply",
|
||||
"passed",
|
||||
["OpenBao audit timestamp recorded without secret values"],
|
||||
set_status="applied",
|
||||
)
|
||||
self.assertEqual(updated["status"], "applied")
|
||||
self.assertEqual(updated["verification"]["evidence"][-1]["kind"], "metadata_apply")
|
||||
self.assertEqual(
|
||||
updated["verification"]["evidence"][-1]["details"],
|
||||
["OpenBao audit timestamp recorded without secret values"],
|
||||
)
|
||||
|
||||
def test_record_evidence_can_mark_frontdoor_ready(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
updated = credential_change.append_evidence(
|
||||
copied,
|
||||
"unit-test",
|
||||
"frontdoor_activation",
|
||||
"passed",
|
||||
["Catalog readiness checked without secret values"],
|
||||
set_status="active",
|
||||
frontdoor_ready=True,
|
||||
)
|
||||
self.assertEqual(updated["status"], "active")
|
||||
self.assertEqual(updated["access_frontdoor"]["readiness"], "ready")
|
||||
self.assertTrue(updated["access_frontdoor"]["resolvable"])
|
||||
|
||||
def test_record_evidence_rejects_secret_markers(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.append_evidence(
|
||||
copied,
|
||||
"unit-test",
|
||||
"positive_verification",
|
||||
"passed",
|
||||
["accidentally pasted sk-test"],
|
||||
)
|
||||
|
||||
def test_lifecycle_plan_renders_deactivation_steps(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.sample)
|
||||
self.assertEqual(errors, [])
|
||||
payload = credential_change.lifecycle_payload(ccr, "deactivate")
|
||||
rendered = credential_change.render_lifecycle_plan(payload)
|
||||
self.assertIn("lifecycle plan: deactivate", rendered)
|
||||
self.assertIn("readiness=disabled resolvable=False", rendered)
|
||||
self.assertIn("bao delete auth/netkingdom/role/whynot-design-workload-kv-read", rendered)
|
||||
self.assertIn("bao policy delete workload-kv-read-whynot-design-npm-publish", rendered)
|
||||
self.assertNotIn("NPM_AUTH_TOKEN=", rendered)
|
||||
|
||||
def test_lifecycle_event_marks_deactivated_and_disables_frontdoor(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
updated = credential_change.append_lifecycle_event(
|
||||
copied,
|
||||
"unit-test",
|
||||
"deactivate",
|
||||
"No longer needed",
|
||||
["Front door disabled in catalog"],
|
||||
)
|
||||
self.assertEqual(updated["status"], "deactivated")
|
||||
self.assertEqual(updated["access_frontdoor"]["readiness"], "disabled")
|
||||
self.assertFalse(updated["access_frontdoor"]["resolvable"])
|
||||
self.assertEqual(updated["lifecycle"]["events"][-1]["action"], "deactivate")
|
||||
|
||||
def test_lifecycle_event_records_compromise_blast_radius_and_follow_up(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
updated = credential_change.append_lifecycle_event(
|
||||
copied,
|
||||
"unit-test",
|
||||
"compromise",
|
||||
"Unexpected exposure signal",
|
||||
["Access disabled before rotation"],
|
||||
blast_radius=["npm publishing lane only"],
|
||||
follow_up=["incident-task-1"],
|
||||
)
|
||||
event = updated["lifecycle"]["events"][-1]
|
||||
self.assertEqual(updated["status"], "compromised")
|
||||
self.assertEqual(updated["access_frontdoor"]["readiness"], "compromised")
|
||||
self.assertEqual(event["blast_radius"], ["npm publishing lane only"])
|
||||
self.assertEqual(event["follow_up"], ["incident-task-1"])
|
||||
|
||||
def test_lifecycle_event_rejects_secret_markers(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.append_lifecycle_event(
|
||||
copied,
|
||||
"unit-test",
|
||||
"rotate",
|
||||
"accidentally pasted ghp_bad",
|
||||
["rotation needed"],
|
||||
)
|
||||
|
||||
def test_import_inventory_writes_non_secret_ccr_and_policy(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
output_dir = tmp_path / "ccrs"
|
||||
policy_file = tmp_path / "policies" / "workload-kv-read-imported-lane.hcl"
|
||||
args = type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"id": "CCR-2099-0001",
|
||||
"title": "imported lane",
|
||||
"tenant": "coulomb",
|
||||
"workload": "imported",
|
||||
"environment": "production",
|
||||
"purpose": "runtime token",
|
||||
"mount": "platform",
|
||||
"kv_path": "platform/workloads/coulomb/imported/runtime-token",
|
||||
"field": ["RUNTIME_TOKEN"],
|
||||
"policy_name": "workload-kv-read-imported-lane",
|
||||
"policy_file": str(policy_file),
|
||||
"auth_method": "oidc",
|
||||
"auth_mount": "netkingdom",
|
||||
"auth_role": "imported-workload-kv-read",
|
||||
"bound_claim": ["groups=imported"],
|
||||
"service_account": None,
|
||||
"service_account_namespace": None,
|
||||
"bound_claims_confirmed": True,
|
||||
"ttl": "15m",
|
||||
"frontdoor_type": "ops-warden",
|
||||
"catalog_id": "imported-runtime-token",
|
||||
"selector": None,
|
||||
"command": None,
|
||||
"status": "active",
|
||||
"readiness": "ready",
|
||||
"resolvable": True,
|
||||
"risk": "high",
|
||||
"positive_check": "Authorized caller can fetch RUNTIME_TOKEN with output suppressed.",
|
||||
"negative_check": "Unauthorized caller cannot read the imported path.",
|
||||
"requester_agent": "unit-test",
|
||||
"actor": "unit-test",
|
||||
"reason": "Imported existing lane without secret values",
|
||||
"output_dir": str(output_dir),
|
||||
"write_policy": True,
|
||||
},
|
||||
)()
|
||||
path = credential_change.write_inventory_ccr(args)
|
||||
self.assertTrue(path.exists())
|
||||
self.assertTrue(policy_file.exists())
|
||||
ccr, errors, warnings = credential_change.validate_ccr(path)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(ccr["openbao"]["fields"], ["RUNTIME_TOKEN"])
|
||||
self.assertNotIn("ghp_", path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
69
tests/test_credential_change_appliers.py
Normal file
69
tests/test_credential_change_appliers.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"openbao_credential_change_appliers",
|
||||
REPO_DIR / "scripts/openbao-apply-credential-change-appliers.py",
|
||||
)
|
||||
appliers = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = appliers
|
||||
SPEC.loader.exec_module(appliers)
|
||||
|
||||
|
||||
class CredentialChangeApplierSetupTests(unittest.TestCase):
|
||||
def test_selected_appliers_all_is_stable(self) -> None:
|
||||
selected = appliers.selected_appliers("all")
|
||||
self.assertEqual(
|
||||
[item["token_role"] for item in selected],
|
||||
["credential-change-nonprod-applier", "credential-change-prod-applier"],
|
||||
)
|
||||
|
||||
def test_role_args_are_bounded(self) -> None:
|
||||
args = appliers.role_args(appliers.APPLIERS["prod"])
|
||||
self.assertIn("auth/token/roles/credential-change-prod-applier", args)
|
||||
self.assertIn("allowed_policies=credential-change-prod-applier", args)
|
||||
self.assertIn("disallowed_policies=root,platform-admin", args)
|
||||
self.assertIn("token_no_default_policy=true", args)
|
||||
self.assertIn("token_type=service", args)
|
||||
|
||||
def test_dry_run_applies_policy_role_and_readback(self) -> None:
|
||||
runner = appliers.BaoRunner(
|
||||
kubectl="kubectl",
|
||||
namespace="openbao",
|
||||
release="openbao",
|
||||
dry_run=True,
|
||||
use_token_helper=False,
|
||||
token=None,
|
||||
)
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
appliers.apply_applier(
|
||||
runner,
|
||||
appliers.APPLIERS["nonprod"],
|
||||
REPO_DIR / "openbao/policies",
|
||||
)
|
||||
rendered = output.getvalue()
|
||||
self.assertIn(
|
||||
"DRY-RUN: bao policy write credential-change-nonprod-applier",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"DRY-RUN: bao write auth/token/roles/credential-change-nonprod-applier",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"DRY-RUN: bao read auth/token/roles/credential-change-nonprod-applier",
|
||||
rendered,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
130
tests/test_credential_helper.py
Normal file
130
tests/test_credential_helper.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"credential_helper", REPO_DIR / "scripts/credential.py"
|
||||
)
|
||||
credential = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = credential
|
||||
SPEC.loader.exec_module(credential)
|
||||
|
||||
|
||||
def sample_grant() -> dict:
|
||||
return {
|
||||
"id": "ops-warden/warden-sign",
|
||||
"issuer": "openbao",
|
||||
"audience": "ops-warden",
|
||||
"credential_type": "openbao-token",
|
||||
"openbao": {
|
||||
"token_role": "warden-sign",
|
||||
"policies": ["warden-sign"],
|
||||
},
|
||||
"ttl": {"default": "15m", "max": "1h"},
|
||||
"actors": {"allowed_types": ["human-operator", "approved-agent"]},
|
||||
"delivery": {
|
||||
"allowed": [
|
||||
"exec-env",
|
||||
"response-wrap",
|
||||
"local-token-file",
|
||||
"kubernetes-auth",
|
||||
],
|
||||
"kubernetes_auth": {
|
||||
"mount": "auth/kubernetes",
|
||||
"role": "credential-broker-warden-sign",
|
||||
"service_account_names": ["credential-broker"],
|
||||
"namespaces": ["openbao"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CredentialHelperTests(unittest.TestCase):
|
||||
def test_ttl_over_max_is_rejected(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
credential.validate_issue_request(
|
||||
sample_grant(), "2h", "purpose", "exec-env", "approved-agent"
|
||||
)
|
||||
|
||||
def test_actor_type_is_checked(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
credential.validate_issue_request(
|
||||
sample_grant(), "15m", "purpose", "exec-env", "unknown-actor"
|
||||
)
|
||||
|
||||
def test_split_env_prefix_rejects_token_injection(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
credential.split_env_prefix(["--", "VAULT_TOKEN=hvs.bad", "/bin/true"])
|
||||
|
||||
def test_split_env_prefix_accepts_safe_assignments(self) -> None:
|
||||
extra_env, command = credential.split_env_prefix(
|
||||
["--", "SMOKE_VAULT=1", "/bin/true"]
|
||||
)
|
||||
self.assertEqual(extra_env, {"SMOKE_VAULT": "1"})
|
||||
self.assertEqual(command, ["/bin/true"])
|
||||
|
||||
def test_redaction_catches_bao_tokens_and_env_assignments(self) -> None:
|
||||
text = "token=hvb.abc123 VAULT_TOKEN=hvs.secret BAO_TOKEN=hvb.secret"
|
||||
redacted = credential.redact(text)
|
||||
self.assertNotIn("hvb.abc123", redacted)
|
||||
self.assertNotIn("hvs.secret", redacted)
|
||||
self.assertIn("[REDACTED]", redacted)
|
||||
|
||||
def test_local_lease_is_mode_0600_and_cleanup_stays_in_lease_dir(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lease_dir = Path(tmp) / "leases"
|
||||
authz = credential.AuthorizationResult(True, "unit-test", "decision-1")
|
||||
payload = credential.write_local_lease(
|
||||
lease_dir=lease_dir,
|
||||
grant=sample_grant(),
|
||||
purpose="unit-test",
|
||||
ttl="15m",
|
||||
token="hvb.unit-test-secret",
|
||||
accessor="accessor-unit-test",
|
||||
authz=authz,
|
||||
)
|
||||
token_file = Path(payload["token_file"])
|
||||
metadata_file = Path(payload["metadata_file"])
|
||||
self.assertEqual(stat.S_IMODE(token_file.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(metadata_file.stat().st_mode), 0o600)
|
||||
removed = credential.remove_local_lease_files(
|
||||
lease_dir, "accessor-unit-test"
|
||||
)
|
||||
self.assertIn(str(token_file), removed)
|
||||
self.assertIn(str(metadata_file), removed)
|
||||
self.assertFalse(token_file.exists())
|
||||
self.assertFalse(metadata_file.exists())
|
||||
|
||||
def test_kubernetes_auth_payload_issues_no_token(self) -> None:
|
||||
authz = credential.AuthorizationResult(True, "dry-run-local", None)
|
||||
payload = credential.kubernetes_auth_payload(
|
||||
sample_grant(), "15m", "unit-test", authz
|
||||
)
|
||||
self.assertEqual(payload["delivery_mode"], "kubernetes-auth")
|
||||
self.assertEqual(payload["openbao_auth_role"], "credential-broker-warden-sign")
|
||||
self.assertNotIn("token", payload)
|
||||
self.assertIn("service_account_names", payload)
|
||||
|
||||
def test_lease_paths_are_gitignored(self) -> None:
|
||||
result = subprocess.run(
|
||||
["git", "check-ignore", ".local/credential-leases/example.openbao-token"],
|
||||
cwd=REPO_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,7 +2,7 @@
|
||||
id: RAIL-PL-WP-0002
|
||||
type: workplan
|
||||
title: "OpenBao Platform Secrets Service"
|
||||
domain: railiance
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: finished
|
||||
owner: codex
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: RAILIANCE-WP-0003
|
||||
type: workplan
|
||||
title: "Provision shared CNPG cluster apps-pg"
|
||||
domain: railiance
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: finished
|
||||
owner: codex
|
||||
|
||||
281
workplans/RAILIANCE-WP-0004-argocd-gitops-bootstrap.md
Normal file
281
workplans/RAILIANCE-WP-0004-argocd-gitops-bootstrap.md
Normal file
@@ -0,0 +1,281 @@
|
||||
---
|
||||
id: RAILIANCE-WP-0004
|
||||
type: workplan
|
||||
title: "Establish ArgoCD GitOps bootstrap contract"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
planning_priority: high
|
||||
planning_order: 4
|
||||
created: "2026-06-19"
|
||||
updated: "2026-06-25"
|
||||
state_hub_workstream_id: "e57e487b-8557-439d-8093-0457c73ede93"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0004 - Establish ArgoCD GitOps Bootstrap Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Establish the minimal platform-owned ArgoCD GitOps contract needed for
|
||||
Railiance application teams to deploy through the already-installed ArgoCD
|
||||
instance on `railiance01`.
|
||||
|
||||
This work responds to the `issue-core` dependency message from 2026-06-18:
|
||||
ArgoCD is installed and healthy on `railiance01`, but unused. `issue-core`
|
||||
will be the first tenant Application and needs platform decisions before it
|
||||
can author its workload deployment.
|
||||
|
||||
## Intent Alignment
|
||||
|
||||
`INTENT.md` defines this repo as the shared platform-services layer:
|
||||
stateful services, secret custody, stable interfaces, and recoverable
|
||||
operational contracts.
|
||||
|
||||
ArgoCD itself is not an application, database, or secret store. The work in
|
||||
this repo is therefore intentionally limited to the platform contract around
|
||||
GitOps:
|
||||
|
||||
- repository trust and credential registration for ArgoCD;
|
||||
- AppProject guardrails that keep tenant syncs inside expected boundaries;
|
||||
- a root app-of-apps entrypoint that provides a stable onboarding surface;
|
||||
- the OpenBao-backed runtime secret delivery convention tenants must use.
|
||||
|
||||
Application workloads, container images, per-service manifests, and business
|
||||
logic remain owned by the tenant repos.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Define the bootstrap manifests for ArgoCD AppProjects and the root
|
||||
app-of-apps Application.
|
||||
- Define how Git source repositories are registered without committing
|
||||
credentials.
|
||||
- Define where tenant Application manifests are placed and how they point
|
||||
back to tenant-owned workload manifests.
|
||||
- Confirm the runtime secret delivery pattern: OpenBao custody delivered to
|
||||
Kubernetes via External Secrets Operator by default; CSI-mounted files only
|
||||
when a workload requires file references; OpenBao injector remains disabled.
|
||||
- Provide an `issue-core` pilot Application example so that repo can author
|
||||
its final manifest against a concrete contract.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Installing or upgrading ArgoCD itself; that is cluster/runtime ownership.
|
||||
- Moving S5 application workload manifests into this repo.
|
||||
- Storing ArgoCD repository credentials, API tokens, or application secrets in
|
||||
Git, workplans, State Hub, or chat.
|
||||
- Applying live manifests that require operator-owned credentials.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D-01 - Bootstrap Layout
|
||||
|
||||
Use this repo only for the platform-owned GitOps bootstrap:
|
||||
|
||||
```text
|
||||
argocd/bootstrap/ AppProjects and root app-of-apps Application
|
||||
argocd/applications/ thin tenant Application manifests reviewed by platform
|
||||
argocd/repositories/ SOPS templates for ArgoCD repository Secret objects
|
||||
docs/argocd-gitops.md GitOps contract and onboarding guidance
|
||||
```
|
||||
|
||||
The root Application syncs `argocd/applications/` from this repo. Tenant
|
||||
Application manifests in that directory point to workload manifests in each
|
||||
tenant repo, normally `k8s/railiance/`.
|
||||
|
||||
### D-02 - AppProject Model
|
||||
|
||||
Create two AppProjects:
|
||||
|
||||
- `railiance-bootstrap` only allows the root app to manage ArgoCD
|
||||
`Application` objects in the `argocd` namespace.
|
||||
- `railiance-tenants` allows tenant Applications to sync ordinary namespaced
|
||||
workload resources into their own namespaces, plus namespace creation. It
|
||||
does not grant CRD, ClusterRole, ClusterRoleBinding, or arbitrary
|
||||
cluster-admin authority.
|
||||
|
||||
### D-03 - Sync Policy
|
||||
|
||||
Default tenant Applications use automated sync with prune and self-heal
|
||||
enabled after platform review. Recommended sync options are:
|
||||
|
||||
```yaml
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
- PruneLast=true
|
||||
```
|
||||
|
||||
Sync waves are reserved for dependency ordering. Platform services and secret
|
||||
delivery resources should sync before workloads that consume them.
|
||||
|
||||
### D-04 - Secret Delivery
|
||||
|
||||
OpenBao remains the canonical runtime secret custody service. For ordinary
|
||||
Kubernetes workloads, use External Secrets Operator to materialize OpenBao
|
||||
values as Kubernetes Secrets. Do not use the OpenBao injector in the current
|
||||
deployment.
|
||||
|
||||
Runtime path convention for workload credential custody:
|
||||
|
||||
```text
|
||||
platform/workloads/<tenant-or-org>/<workload>/<secret-purpose>
|
||||
```
|
||||
|
||||
Kubernetes namespace and service-account bounds belong in the auth role or
|
||||
External Secrets binding unless the namespace is itself the approved workload
|
||||
identity.
|
||||
|
||||
ArgoCD repository credentials are operator credentials, not workload secrets,
|
||||
and should live under:
|
||||
|
||||
```text
|
||||
platform/operators/argocd/repositories/<repo-name>
|
||||
```
|
||||
|
||||
## Current Evidence
|
||||
|
||||
- State Hub inbox message `d7a18ff9-e6c6-4e44-a39e-78369e530dfc` reports
|
||||
ArgoCD is installed and healthy on `railiance01`, with zero Applications,
|
||||
zero ApplicationSets, zero registered repositories, and only the stock
|
||||
`default` AppProject.
|
||||
- `INTENT.md` and `SCOPE.md` keep this repo focused on shared platform
|
||||
services and secret custody. This work therefore creates a bootstrap
|
||||
contract and secret-delivery convention, not app workload ownership.
|
||||
- `docs/openbao.md` already states the preferred delivery pattern:
|
||||
External Secrets Operator for values that become Kubernetes Secrets, CSI for
|
||||
file-reference workloads, and no OpenBao injector in the current deployment.
|
||||
|
||||
|
||||
## Follow-up Progress (2026-06-25)
|
||||
|
||||
- Added a platform-owned `railiance-platform-addons` AppProject for
|
||||
cluster-scoped add-ons.
|
||||
- Added the `external-secrets` ArgoCD Application for External Secrets
|
||||
Operator and the `openbao-secretstore` Application for
|
||||
`ClusterSecretStore/openbao`.
|
||||
- Added the least-privilege OpenBao policy and Kubernetes auth role helper for
|
||||
the issue-core ESO pilot. The role binds only the
|
||||
`external-secrets/external-secrets` service account and reads only
|
||||
`platform/workloads/issue-core/issue-core/*`.
|
||||
- Limited the initial `ClusterSecretStore/openbao` to the `issue-core`
|
||||
namespace; broaden only through a later platform review.
|
||||
|
||||
## Target State
|
||||
|
||||
- `argocd/bootstrap/` contains the two AppProjects and root app-of-apps
|
||||
Application.
|
||||
- `argocd/applications/` documents the tenant Application contract and includes
|
||||
an `issue-core` example manifest.
|
||||
- `argocd/repositories/` contains non-secret SOPS templates for ArgoCD
|
||||
repository registration.
|
||||
- `docs/argocd-gitops.md` answers the four questions raised by `issue-core`:
|
||||
repository registration, source layout, sync policy, and secret delivery.
|
||||
- Make targets exist for dry-run, deploy, status, and SOPS-backed repository
|
||||
secret application.
|
||||
- `issue-core` can author its final Application and workload manifests against
|
||||
this contract without waiting for more platform design.
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 - Review intent and scope boundary
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0004-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "7cb56ad6-5435-41af-b416-e68fe661b7a0"
|
||||
```
|
||||
|
||||
Review `INTENT.md`, `SCOPE.md`, existing OpenBao delivery docs, and the
|
||||
`issue-core` inbox request. Capture the boundary that ArgoCD bootstrap belongs
|
||||
here only as a platform trust and secret-delivery contract.
|
||||
|
||||
### T02 - Add ArgoCD bootstrap manifests
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0004-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "68f7ef19-686d-4d16-bf75-ffcbba158023"
|
||||
```
|
||||
|
||||
Add AppProject manifests and the root app-of-apps Application under
|
||||
`argocd/bootstrap/`.
|
||||
|
||||
Done when the manifests can be rendered by `kubectl apply -k` and avoid secret
|
||||
material.
|
||||
|
||||
### T03 - Define tenant onboarding and repository registration
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0004-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e6dc9176-af33-4216-9871-a61ad7e69943"
|
||||
```
|
||||
|
||||
Add documentation and templates for tenant Applications, per-repo ArgoCD
|
||||
repository Secret registration, and the `issue-core` pilot example.
|
||||
|
||||
### T04 - Confirm OpenBao-backed secret delivery
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0004-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "d859e4ef-d8d1-4403-8225-839925f8bedf"
|
||||
```
|
||||
|
||||
Document that OpenBao remains the runtime custody authority, External Secrets
|
||||
Operator is the default Kubernetes delivery mechanism, CSI is reserved for
|
||||
file-reference workloads, and the OpenBao injector remains disabled.
|
||||
|
||||
### T05 - Operator live bootstrap
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0004-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "981f46c0-8dd7-4111-9a4f-2ca58ddb0664"
|
||||
```
|
||||
|
||||
Apply the bootstrap and repository credentials to live ArgoCD after these repo
|
||||
changes are merged to the Git source ArgoCD reads and, if the source repo is
|
||||
private, after an operator provides or materializes read-only repository
|
||||
credentials through the approved OpenBao/operator path.
|
||||
|
||||
Applied 2026-06-19 on the live ArgoCD cluster (`92.205.130.254`, default
|
||||
`~/.kube/config`). `make argocd-bootstrap-dry-run` and
|
||||
`make argocd-bootstrap-deploy` succeeded. Repository registration was skipped
|
||||
because `railiance-platform` and `issue-core` Gitea repos are currently public.
|
||||
|
||||
Post-bootstrap status:
|
||||
|
||||
- `railiance-apps-root`: Synced / Healthy
|
||||
- `issue-core`: OutOfSync / Missing — sync fails because
|
||||
`external-secrets.io/ExternalSecret` CRD is not installed on the cluster
|
||||
|
||||
Do not paste credentials into the workplan, State Hub, or chat.
|
||||
|
||||
### T06 - Notify first tenant
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0004-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "73bdda1d-8e25-48d2-ab92-b203c5050d45"
|
||||
```
|
||||
|
||||
Reply to `issue-core` with the GitOps contract pointer and confirm that it owns
|
||||
the final `issue-core` Application proposal and workload manifests. Include the
|
||||
OpenBao path convention for `ISSUE_CORE_API_KEY` and the Gitea backend token.
|
||||
|
||||
State Hub reply: `56df276d-77d0-427f-92a5-a99cacc1290f`.
|
||||
@@ -0,0 +1,421 @@
|
||||
---
|
||||
id: RAILIANCE-WP-0005
|
||||
type: workplan
|
||||
title: "Credential Request and Lease Broker"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
planning_priority: high
|
||||
planning_order: 5
|
||||
created: "2026-06-24"
|
||||
updated: "2026-07-01"
|
||||
depends_on_workplans:
|
||||
- RAIL-PL-WP-0002
|
||||
state_hub_workstream_id: "2731fece-6c49-45b8-ab8a-4ea6c04ac603"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0005 - Credential Request and Lease Broker
|
||||
|
||||
## Goal
|
||||
|
||||
Provide a clean, secure, low-friction way for operators, agents, and approved
|
||||
automations to request, generate, receive, use, renew, and revoke short-lived
|
||||
credentials such as the OpenBao token needed for ops-warden vault-backed SSH
|
||||
signing smoke.
|
||||
|
||||
The target experience is self-service for routine, policy-approved leases and
|
||||
explicit human approval for high-risk grants, without ever pasting secret values
|
||||
into Git, State Hub, chat, prompts, workplans, or shell history.
|
||||
|
||||
## Repository Decision
|
||||
|
||||
The primary owner is railiance-platform because OpenBao is the canonical
|
||||
runtime secret custody service and this repo owns platform secrets, identity
|
||||
integration, and shared credential delivery contracts.
|
||||
|
||||
Cross-repo responsibilities:
|
||||
|
||||
| Concern | Owner | Boundary |
|
||||
| --- | --- | --- |
|
||||
| OpenBao policies, token roles, lease broker, audit | railiance-platform | Owns secret custody and credential generation. |
|
||||
| Login, OIDC, MFA, IAM profile claims | key-cape | Authenticates humans and service identities. |
|
||||
| Authorization decision for requested grants | flex-auth | May decide whether actor X may request grant Y for purpose Z. |
|
||||
| SSH certificate signing | ops-warden | Issues SSH certs only; does not vend OpenBao tokens. |
|
||||
| Request tracking and progress | state-hub | Stores non-secret request metadata, status, decision ids, and audit pointers only. |
|
||||
| Agent inference/runtime | llm-connect and callers | Never place secrets in prompts; consume via local env injection or wrapped lease handles. |
|
||||
|
||||
This work should update the ops-warden routing catalog when complete, but the
|
||||
implementation belongs here. If the broker later becomes a general NetKingdom
|
||||
service, code can split to a dedicated credential-broker repo while OpenBao
|
||||
policies and grants remain owned by railiance-platform.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Prefer dynamic or short-lived leases over static secrets.
|
||||
- Use response wrapping or local exec-time injection; do not print raw tokens by default.
|
||||
- Store non-secret lease metadata only: actor, grant, TTL, purpose, lease id or accessor, decision id, timestamps, and revocation state.
|
||||
- Keep OpenBao audit logs as the source of truth for secret access.
|
||||
- Make the common path easy: one command to run a task with the right credential.
|
||||
- Keep high-risk paths explicit: human approval and MFA for elevated grants.
|
||||
- Every grant has a catalog entry, max TTL, allowed actors/subjects, delivery mode, audit expectations, and revocation behavior.
|
||||
|
||||
## Proposed User Experience
|
||||
|
||||
Initial pilot command shapes:
|
||||
|
||||
credential request vault-token --grant ops-warden/warden-sign --purpose flex-auth-openbao-smoke --ttl 15m
|
||||
credential exec --grant ops-warden/warden-sign --ttl 15m -- SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh
|
||||
credential status <lease-handle>
|
||||
credential revoke <lease-handle>
|
||||
|
||||
For the ops-warden smoke, the preferred path is credential exec. It obtains a
|
||||
bounded OpenBao token with the warden-sign policy, injects it as VAULT_TOKEN
|
||||
only into the child process environment, redacts logs, and revokes or lets the
|
||||
lease expire after the command finishes.
|
||||
|
||||
## Threat Model Summary
|
||||
|
||||
Primary risks:
|
||||
|
||||
- token leakage through shell history, logs, prompts, chat, State Hub, or Git;
|
||||
- confused-deputy issuance where an agent requests a broader token than needed;
|
||||
- stale leases surviving after a task completes;
|
||||
- bypassing KeyCape identity or flex-auth authorization checks;
|
||||
- replacing one manual secret-handling ritual with another brittle one.
|
||||
|
||||
Mitigations required by this workplan:
|
||||
|
||||
- no raw token in command-line arguments, State Hub payloads, workplans, or logs;
|
||||
- bounded OpenBao token roles and policies;
|
||||
- response wrapping for copy/paste or remote handoff flows;
|
||||
- exec-time environment injection for local command execution;
|
||||
- default TTLs measured in minutes, with explicit max TTLs per grant;
|
||||
- revocation by lease handle/accessor;
|
||||
- OpenBao audit verification and non-secret State Hub progress events.
|
||||
|
||||
## Tasks
|
||||
|
||||
## T01 - Record ownership and architecture decision
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "cd680de8-a483-40d6-84fa-369bad60e7c7"
|
||||
```
|
||||
|
||||
Write an ADR or docs section confirming railiance-platform as the owner for
|
||||
OpenBao credential request/generation/delivery, with key-cape, flex-auth,
|
||||
ops-warden, state-hub, and llm-connect boundaries.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Docs state that ops-warden routes SSH certs only and must not vend OpenBao tokens.
|
||||
- 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: done
|
||||
priority: high
|
||||
state_hub_task_id: "6b64ad4b-90cd-475b-aaa9-73997c6b011b"
|
||||
```
|
||||
|
||||
Add a non-secret grant catalog schema and initial grant entries.
|
||||
|
||||
Initial grant:
|
||||
|
||||
- id: ops-warden/warden-sign
|
||||
- credential type: openbao-token
|
||||
- policies: warden-sign
|
||||
- default TTL: 15 minutes
|
||||
- max TTL: 1 hour unless a human approves more
|
||||
- purpose examples: flex-auth OpenBao smoke, ops-warden production sign smoke
|
||||
- allowed delivery: exec-env, response-wrap, local-token-file mode 0600
|
||||
- denied delivery: chat, State Hub body, Git, command-line token argument
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Catalog can be validated in CI.
|
||||
- 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
|
||||
id: RAILIANCE-WP-0005-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "d8498e3b-b2fb-47b7-ab88-cd6592c1807e"
|
||||
```
|
||||
|
||||
Create idempotent scripts/manifests for OpenBao token roles or equivalent lease
|
||||
issuance paths that can generate child tokens only for approved policies and
|
||||
TTLs. Start with warden-sign.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A non-root issuer path can create a warden-sign token with bounded TTL.
|
||||
- The resulting token cannot administer OpenBao and can only call the SSH sign paths allowed by openbao/policies/warden-sign.hcl.
|
||||
- Verification proves the token can run ops-warden vault signing and cannot list unrelated secrets.
|
||||
|
||||
**2026-06-26:** Added the source-side OpenBao token-grant implementation for
|
||||
the `ops-warden/warden-sign` pilot: issuer policy
|
||||
`openbao/policies/credential-broker-warden-sign-issuer.hcl`, idempotent apply
|
||||
and verify scripts, Make targets for dry-run/live apply/live verification, and
|
||||
catalog validation for `openbao.issuer_policy`. Dry-run validation is expected
|
||||
to work offline. Live closure still requires an approved OpenBao operator token
|
||||
path and successful runs of `make openbao-configure-token-grants` and
|
||||
`make openbao-verify-token-grants-smoke`, so T03 remains `progress`.
|
||||
|
||||
**2026-06-27:** Attempted the live idempotent apply with
|
||||
`make openbao-configure-token-grants OPENBAO_TOKEN_GRANT_ARGS=--use-token-helper`.
|
||||
OpenBao was reachable and unsealed, but the pod token helper received
|
||||
`403 permission denied` while writing
|
||||
`sys/policies/acl/credential-broker-warden-sign-issuer`. T03 is now `wait`
|
||||
until an approved OpenBao issuer/platform-admin path applies the policy and
|
||||
role, or the pod token helper is granted that narrow capability.
|
||||
|
||||
**2026-07-01:** Operator unsealed OpenBao. Live apply succeeded with
|
||||
`OPENBAO_TOKEN_FILE=~/.local/openbao/platform-admin.token make openbao-configure-token-grants`:
|
||||
`credential-broker-warden-sign-issuer` policy and `warden-sign` token role are
|
||||
configured. T03 is `done`.
|
||||
|
||||
**2026-07-01 follow-up:** Live smoke succeeded with openbao-verify-token-grants-smoke: a child token minted from role warden-sign signed a throwaway SSH public key through ssh/sign/agt-role, was denied policy metadata read, and was revoked by accessor.
|
||||
|
||||
## T04 - Build credential helper MVP
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0c543cb3-36cb-4b25-9a58-de8efc1216c9"
|
||||
```
|
||||
|
||||
Build a small CLI/helper in this repo first, for example credential or
|
||||
openbao-lease, with request, exec, status, and revoke commands.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- credential exec can run the ops-warden production smoke with VAULT_TOKEN only in the child process environment.
|
||||
- request returns a wrapped token or lease handle by default, not the raw token.
|
||||
- status and revoke work by non-secret lease handle/accessor.
|
||||
- The helper redacts token-looking values from logs and refuses to run in verbose modes that would print secrets.
|
||||
|
||||
**2026-06-26:** Added `scripts/credential.py` as the source helper MVP with
|
||||
`request`, `exec`, `status`, and `revoke` subcommands. The helper validates the
|
||||
grant catalog, enforces purpose and TTL bounds, defaults `request` to a local
|
||||
mode-0600 token file plus non-secret accessor metadata, supports response-wrap
|
||||
handoff, injects `VAULT_TOKEN` only into the child process for `exec`, redacts
|
||||
token-looking child output, rejects caller-supplied token env assignments, and
|
||||
revokes exec tokens by accessor in a `finally` block. Added Make dry-run and
|
||||
ops-warden smoke targets. T04 remains `progress` until a live OpenBao issuer
|
||||
token is available to prove `credential-exec-ops-warden-smoke` end to end.
|
||||
|
||||
**2026-06-27:** Extended the helper with optional flex-auth preflight,
|
||||
non-secret State Hub lifecycle metadata, actor/subject binding fields,
|
||||
`--decision-id` support, and Kubernetes-auth delegation output. Fixed the Make
|
||||
surface so global helper flags such as `--use-token-helper` are passed before
|
||||
the subcommand. T04 is now `wait` on the same OpenBao live gate as T03 before
|
||||
ops-warden smoke can be proven end to end.
|
||||
|
||||
**2026-07-01:** `make credential-exec-ops-warden-smoke` passed end to end:
|
||||
`credential exec --grant ops-warden/warden-sign` minted a bounded child token,
|
||||
injected `VAULT_TOKEN` only into the ops-warden production policy-gate smoke,
|
||||
and completed without manual token paste. T04 is `done`.
|
||||
|
||||
**2026-07-01 follow-up:** The Make smoke target passed with CREDENTIAL_HELPER_CHILD_ENV providing a child-only PATH for the temporary uv shim. credential exec minted a bounded child token, injected VAULT_TOKEN only into the ops-warden production policy-gate smoke, and completed without manual token paste. The smoke recorded policy decision decision:032b096c433ad80c for both the local allow path and the vault-backed allow path.
|
||||
|
||||
## T05 - Implement secure delivery modes
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T05
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "66f3cd6d-7520-4584-90b8-672866ef3490"
|
||||
```
|
||||
|
||||
Support safe delivery modes for different runtime contexts.
|
||||
|
||||
Required modes:
|
||||
|
||||
- exec-env: inject credential into one child process, then forget it;
|
||||
- response-wrap: produce a single-use OpenBao wrapping token for attended handoff;
|
||||
- local-token-file: write mode 0600 under an ignored local state directory, with TTL metadata and cleanup;
|
||||
- kubernetes-auth: use service-account-bound auth for in-cluster workloads instead of handing them tokens manually.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- No delivery mode requires pasting the secret into chat or State Hub.
|
||||
- local-token-file paths are gitignored and rejected by secret scans if accidentally staged.
|
||||
- response-wrap unwraps once and fails on second use.
|
||||
|
||||
**2026-06-27:** Source support now covers all four delivery modes: `exec-env`,
|
||||
`response-wrap`, `local-token-file`, and `kubernetes-auth`. The helper refuses
|
||||
caller-supplied token env assignments, writes local leases under the ignored
|
||||
`.local/credential-leases/` path with mode `0600`, and emits only service
|
||||
account auth metadata for Kubernetes-auth. T05 is `wait` until live response-wrap
|
||||
single-use behavior and the OpenBao-backed exec path are verified with an
|
||||
approved issuer token.
|
||||
|
||||
**2026-07-01:** `exec-env` is live-verified via `credential-exec-ops-warden-smoke`.
|
||||
`response-wrap`, `local-token-file`, and `kubernetes-auth` still need live
|
||||
evidence. T05 is `progress`.
|
||||
|
||||
## T06 - Integrate KeyCape identity and agent subject binding
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e1dd5973-bf2b-4aa9-842e-9f530afa1ab6"
|
||||
```
|
||||
|
||||
Define how humans and agents authenticate to request grants.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Human operator path uses KeyCape/OIDC with MFA where required.
|
||||
- Agent/service path has a documented subject id shape compatible with IAM profile claims and existing actor naming.
|
||||
- Headless automation uses Kubernetes auth or an explicitly approved non-interactive identity; it does not reuse a human token.
|
||||
|
||||
**2026-06-27:** Documented the identity contract in `docs/credential-broker.md`:
|
||||
KeyCape/OIDC with MFA for human operators, stable IAM-compatible subjects for
|
||||
agents and CI, and Kubernetes service-account subjects for headless workloads.
|
||||
The helper now exposes `--actor`, `--actor-type`, and `--subject`, and validates
|
||||
actor type against the grant catalog. T06 is done source-side.
|
||||
|
||||
## T07 - Add flex-auth preflight authorization and State Hub request metadata
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T07
|
||||
status: wait
|
||||
priority: medium
|
||||
state_hub_task_id: "1269bb58-0699-43ef-aa4f-43bc49c61a49"
|
||||
```
|
||||
|
||||
Before issuing a lease, optionally call flex-auth with actor, subject, grant,
|
||||
purpose, TTL, audience, and requested delivery mode. Record non-secret request
|
||||
metadata and decision ids in State Hub when available.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- flex-auth can deny overbroad TTL, wrong actor type, wrong purpose, or disallowed delivery mode.
|
||||
- State Hub records request lifecycle without token values.
|
||||
- The helper works in offline/degraded mode only for pre-authorized local flows; it never caches new secret material in State Hub.
|
||||
|
||||
**2026-06-27:** Added optional flex-auth preflight via `--flex-auth-url` /
|
||||
`FLEX_AUTH_URL`, strict `--require-flex-auth`, provided decision ids via
|
||||
`--decision-id`, and opt-in State Hub lifecycle notes via `--record-state-hub`.
|
||||
The helper records only non-secret metadata. T07 is `wait` until a live flex-auth
|
||||
credential authorization endpoint is available and the OpenBao live gate is
|
||||
cleared.
|
||||
|
||||
## T08 - Integrate ops-warden smoke and routing catalog
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T08
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4571d4c9-d4de-4ee9-97e0-ff03e49e65ec"
|
||||
```
|
||||
|
||||
Replace the manual VAULT_TOKEN step in ops-warden smoke docs with the credential
|
||||
helper flow and update the credential routing catalog.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- FLEX-WP-0007 T4 can be run with one command once the grant is configured:
|
||||
credential exec --grant ops-warden/warden-sign --ttl 15m -- SMOKE_VAULT=1 /home/worsch/ops-warden/scripts/policy_gate_production_smoke.sh
|
||||
- ops-warden docs still make clear it owns SSH cert signing, not OpenBao token vending.
|
||||
- warden route find VAULT_TOKEN points to this railiance-platform flow.
|
||||
|
||||
**2026-06-27:** Added `make credential-exec-ops-warden-smoke` for the intended
|
||||
one-command smoke and confirmed credential routing locally with
|
||||
`uv run warden route show openbao-api-key --json`: OpenBao/API/dynamic lease
|
||||
needs belong to `railiance-platform`; ops-warden executes SSH cert issuance
|
||||
only. T08 is `wait` because this workspace cannot update the external
|
||||
ops-warden routing catalog and the live OpenBao grant apply is still denied.
|
||||
|
||||
**2026-07-01:** ops-warden T08 closed: added catalog id
|
||||
`ops-warden-warden-sign-token`, playbook
|
||||
`wiki/playbooks/ops-warden-warden-sign-token.md`, and updated
|
||||
`operator-openbao-token-hygiene.md`, `PolicyGatedSigning.md`, and
|
||||
`CredentialRouting.md`. `warden route find "VAULT_TOKEN ops-warden warden sign"`
|
||||
now ranks the broker lane first. Live smoke already proven via
|
||||
`make credential-exec-ops-warden-smoke`. T08 is `done`.
|
||||
|
||||
## T09 - Verification, audit, and red-team checks
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T09
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "78d1db83-12fb-4ac2-95eb-54c91ac125b5"
|
||||
```
|
||||
|
||||
Add tests and operator verification for the complete flow.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Unit tests cover grant validation, TTL bounds, redaction, and delivery-mode restrictions.
|
||||
- Dry-run tests require no secrets.
|
||||
- Live smoke proves OpenBao audit logs record issuance and use.
|
||||
- Negative tests prove denied grants do not mint tokens.
|
||||
- Documentation includes emergency revocation and cleanup commands.
|
||||
|
||||
**2026-06-27:** Added `tests/test_credential_helper.py` and `make credential-tests`
|
||||
covering TTL bounds, actor-type restrictions, token redaction, unsafe env
|
||||
rejection, local lease mode/cleanup, Kubernetes-auth delegation, and gitignore
|
||||
coverage for local lease files. Offline validation is passing. T09 is `wait`
|
||||
until live OpenBao audit evidence, response-wrap unwrap-once evidence, and
|
||||
negative live mint checks can be collected.
|
||||
|
||||
**2026-07-01:** Live verification moved forward. make credential-tests passed 50 tests. make openbao-verify-token-grants-smoke minted a child token with policy warden-sign, proved it can sign via ssh/sign/agt-role, proved it cannot read policy metadata, and revoked it by accessor. make credential-exec-ops-warden-smoke passed with the child-only PATH hook, proving the flex-auth allow/deny smoke and vault-backed ops-warden signing path without manual VAULT_TOKEN paste. T09 is progress; remaining evidence is OpenBao audit-log reference collection plus response-wrap unwrap-once verification.
|
||||
|
||||
## T10 - Rollout and migration
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0005-T10
|
||||
status: wait
|
||||
priority: medium
|
||||
state_hub_task_id: "44ce4082-fa8f-44d0-8f86-172d14ecfb0e"
|
||||
```
|
||||
|
||||
Roll out in phases.
|
||||
|
||||
Phases:
|
||||
|
||||
1. warden-sign VAULT_TOKEN pilot for flex-auth/ops-warden smoke.
|
||||
2. Platform-readonly token helper for diagnostics.
|
||||
3. Workload-specific grants for app repositories.
|
||||
4. Optional split to a dedicated credential-broker repo if code grows beyond railiance-platform ownership.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The VAULT_TOKEN blocker from FLEX-WP-0007 is cleared without manual token paste.
|
||||
- Operators have a documented fast path and a break-glass path.
|
||||
- State Hub, ops-warden, key-cape, and flex-auth docs link to the same routing truth.
|
||||
|
||||
**2026-06-27:** Documented rollout phases, emergency revocation, delivery modes,
|
||||
identity binding, flex-auth preflight, State Hub metadata, and routing ownership
|
||||
in `docs/credential-broker.md`. T10 is `wait` on the live warden-sign pilot and
|
||||
external routing-doc/catalog updates.
|
||||
|
||||
**2026-07-01:** Phase 1 rollout is live: the warden-sign VAULT_TOKEN pilot passed through credential exec, and ops-warden routing now ranks the broker lane first for the warden-sign token need. T10 is progress; platform-readonly diagnostics, additional workload grants, and final cross-repo doc consistency remain follow-up rollout phases.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- A policy-approved actor can request or exec with a short-lived OpenBao token without seeing or pasting the raw token.
|
||||
- The ops-warden vault-backed smoke can run without manual VAULT_TOKEN handling.
|
||||
- All issued credentials are bounded, auditable, and revocable.
|
||||
- State Hub and workplans contain only non-secret metadata.
|
||||
- The credential routing catalog points token/dynamic-lease requests to railiance-platform.
|
||||
359
workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md
Normal file
359
workplans/RAILIANCE-WP-0006-workload-kv-access-lanes.md
Normal file
@@ -0,0 +1,359 @@
|
||||
---
|
||||
id: RAILIANCE-WP-0006
|
||||
type: workplan
|
||||
title: "Workload KV Access Lanes for ops-warden Fetch"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
planning_priority: high
|
||||
planning_order: 6
|
||||
created: "2026-06-27"
|
||||
updated: "2026-06-29"
|
||||
depends_on_workplans:
|
||||
- RAIL-PL-WP-0002
|
||||
- RAILIANCE-WP-0004
|
||||
related_state_hub_messages:
|
||||
- "551031d1-335e-4db8-9535-820fea52d0a3"
|
||||
- "f76d3a9e-a98f-4081-885d-b79d94312699"
|
||||
state_hub_workstream_id: "96c8a93d-7a5a-4fa9-8f7b-865119551da3"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0006 - Workload KV Access Lanes for ops-warden Fetch
|
||||
|
||||
## Goal
|
||||
|
||||
Provision concrete, least-privilege OpenBao workload KV read lanes that
|
||||
`ops-warden` can expose through `warden access --fetch` / `--exec` without
|
||||
holding secret values itself.
|
||||
|
||||
The immediate request is for `whynot-design` to retrieve its npm publish token.
|
||||
The path must be concrete, policy-scoped, and documented so the ops-warden
|
||||
catalog can replace the current unresolved template path with a live
|
||||
`whynot-design-npm-publish` entry.
|
||||
|
||||
No task in this workplan may paste, commit, log, or send secret values through
|
||||
Git, State Hub, chat, prompts, or workplan text.
|
||||
|
||||
## Requirements Reviewed
|
||||
|
||||
Ops-warden message `551031d1-335e-4db8-9535-820fea52d0a3` asks
|
||||
`railiance-platform` to provide non-secret pointers for:
|
||||
|
||||
- a concrete OpenBao KV path and field for `NPM_AUTH_TOKEN`;
|
||||
- the KV mount used by the path;
|
||||
- the OIDC login role for whynot-design or its operator identity;
|
||||
- a read policy scoped to whynot-design's identity/service account;
|
||||
- the flex-auth policy reference, if pre-approval is required.
|
||||
|
||||
Once these pointers are live, ops-warden will add a dedicated
|
||||
`whynot-design-npm-publish` access catalog entry and a playbook, then notify
|
||||
whynot-design.
|
||||
|
||||
## Proposed Contract
|
||||
|
||||
Use the workload credential convention documented in `docs/openbao.md`:
|
||||
|
||||
```text
|
||||
platform/workloads/<tenant-or-org>/<workload>/<secret-purpose>
|
||||
```
|
||||
|
||||
For this lane, the proposed non-secret contract is:
|
||||
|
||||
| Item | Proposed value |
|
||||
| --- | --- |
|
||||
| KV mount | `platform` |
|
||||
| Tenant/org | `coulomb` |
|
||||
| Workload/project | `whynot-design` |
|
||||
| CLI path | `platform/workloads/coulomb/whynot-design/npm-publish` |
|
||||
| KV-v2 policy data path | `platform/data/workloads/coulomb/whynot-design/npm-publish` |
|
||||
| KV-v2 policy metadata path | `platform/metadata/workloads/coulomb/whynot-design/npm-publish` |
|
||||
| Secret field | `NPM_AUTH_TOKEN` |
|
||||
| OpenBao read policy | `workload-kv-read-whynot-design-npm-publish` |
|
||||
| OIDC auth mount | `netkingdom` unless KeyCape compatibility requires `keycape` |
|
||||
| OIDC role | `whynot-design-workload-kv-read` |
|
||||
| Kubernetes auth role | `whynot-design-workload-kv-read` if an in-cluster service account consumes it |
|
||||
| flex-auth ref | `secret.read:whynot-design` if tenant policy requires pre-approval |
|
||||
|
||||
The expected caller-facing read shape is:
|
||||
|
||||
```bash
|
||||
bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read
|
||||
bao kv get -field=NPM_AUTH_TOKEN platform/workloads/coulomb/whynot-design/npm-publish
|
||||
```
|
||||
|
||||
The command shape is illustrative only. Verification must avoid printing the
|
||||
secret value; use attended operator checks or commands that prove read access
|
||||
without persisting the token in logs.
|
||||
|
||||
## Tasks
|
||||
|
||||
## T01 - Capture ops-warden request and path contract
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0006-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0c93496a-48bf-44e7-a75b-52e51e2639bc"
|
||||
```
|
||||
|
||||
Record the ops-warden request, existing workload path convention, and proposed
|
||||
whynot-design contract in this workplan.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The workplan names the concrete path, field, mount, policy, auth role, and
|
||||
optional flex-auth ref needed by ops-warden.
|
||||
- The plan distinguishes non-secret pointers from secret values.
|
||||
- The plan keeps this workload KV read lane separate from
|
||||
`RAILIANCE-WP-0005`, which tracks short-lived OpenBao token issuance for the
|
||||
ops-warden signing smoke.
|
||||
|
||||
**2026-06-27:** Reviewed the unread ops-warden request and existing
|
||||
`platform/workloads/<tenant-or-org>/<workload>/<secret-purpose>` convention.
|
||||
Captured the proposed `whynot-design` npm publish lane above with no secret
|
||||
values.
|
||||
|
||||
## T02 - Add least-privilege OpenBao read policy
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0006-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "9c06d531-2566-4767-aa2f-8339605f23d5"
|
||||
```
|
||||
|
||||
Create a concrete policy artifact for the whynot-design npm publish lane,
|
||||
derived from `openbao/policies/workload-kv-read-template.hcl` but narrowed to
|
||||
the selected `npm-publish` path.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A policy file under `openbao/policies/` defines read access to the exact
|
||||
`platform/data/workloads/coulomb/whynot-design/npm-publish` path.
|
||||
- Metadata/list capabilities are only as broad as needed for the caller and
|
||||
ops-warden fetch UX.
|
||||
- The policy grants no write, delete, patch, sudo, auth, or unrelated workload
|
||||
capabilities.
|
||||
- The policy name matches the pointer intended for ops-warden:
|
||||
`workload-kv-read-whynot-design-npm-publish`.
|
||||
|
||||
**2026-06-27:** Added the concrete policy artifact at
|
||||
`openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl`. It grants
|
||||
only `read` on the exact KV-v2 data and metadata paths for
|
||||
`platform/workloads/coulomb/whynot-design/npm-publish`; it does not grant
|
||||
write/delete/list/sudo/auth or sibling workload access. Added
|
||||
`scripts/openbao-apply-workload-kv-lanes.sh`,
|
||||
`make openbao-workload-kv-lanes-dry-run`, and
|
||||
`make openbao-configure-workload-kv-lanes` for the source-owned policy apply
|
||||
step. Dry-run passed. A live apply attempt with
|
||||
`OPENBAO_WORKLOAD_KV_ARGS=--use-token-helper` reached unsealed OpenBao but was
|
||||
denied with `403 permission denied` while writing the policy, so live policy
|
||||
application waits on an approved platform-admin/operator token or a narrow
|
||||
token-helper capability.
|
||||
|
||||
**2026-06-28:** Using the temporary operator token provided outside the repo,
|
||||
Codex applied/confirmed the live policy in OpenBao. The verification read of the
|
||||
policy succeeded and no secret values were printed or recorded.
|
||||
|
||||
## T03 - Define and apply auth bindings
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0006-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a217371a-0f85-40c6-b691-ac67834c86b5"
|
||||
```
|
||||
|
||||
Define the auth role that lets whynot-design or an approved operator identity
|
||||
read the lane as itself.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The OIDC login role is documented as
|
||||
`bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read`,
|
||||
or a different approved role is recorded with the reason.
|
||||
- The role attaches only the whynot-design npm publish read policy.
|
||||
- If an in-cluster whynot-design service account consumes the token, the
|
||||
Kubernetes auth role binds only the approved namespace and service account.
|
||||
- Compatibility with the legacy `keycape` auth mount is either configured or
|
||||
explicitly declined.
|
||||
|
||||
**2026-06-27:** Documented the intended OIDC role pointer as
|
||||
`auth/netkingdom/role/whynot-design-workload-kv-read` in
|
||||
`docs/workload-kv-access-lanes.md`. Live application is waiting on confirmation
|
||||
of the KeyCape/NetKingdom whynot-design bound claim or approved service-account
|
||||
subject; do not create an unbounded OIDC role.
|
||||
|
||||
**2026-06-28:** Created/confirmed
|
||||
`auth/netkingdom/role/whynot-design-workload-kv-read` with
|
||||
`groups=["whynot-design"]`, only the
|
||||
`workload-kv-read-whynot-design-npm-publish` policy, `ttl=15m`, and the approved
|
||||
browser/local CLI callback URIs.
|
||||
|
||||
**2026-06-28:** Positive verification found the OIDC role was missing
|
||||
`oidc_scopes`, causing OpenBao login to fail with `groups claim not found`.
|
||||
Updated the live role and source CCR to request `openid`, `profile`, `email`,
|
||||
and `groups`, matching the platform-admin OIDC scope shape.
|
||||
|
||||
## T04 - Provision the KV path without exposing the token
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0006-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c43724a3-c83e-4ab6-b7d1-e427fd93a9a9"
|
||||
```
|
||||
|
||||
Have an approved operator create or confirm the OpenBao KV entry for the npm
|
||||
publish token.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The path exists at
|
||||
`platform/workloads/coulomb/whynot-design/npm-publish`.
|
||||
- The field is named exactly `NPM_AUTH_TOKEN`.
|
||||
- The token value is entered through an approved operator/OpenBao path and is
|
||||
never written to Git, State Hub, chat, prompts, shell history, or workplan
|
||||
text.
|
||||
- Non-secret evidence records only the path, field name, actor, timestamp,
|
||||
policy name, and verification result.
|
||||
|
||||
**2026-06-27:** The concrete path and field are now documented. Live secret
|
||||
provisioning is waiting on an approved operator/OpenBao custody path for the
|
||||
actual `NPM_AUTH_TOKEN` value.
|
||||
|
||||
**2026-06-28:** Confirmed the OpenBao metadata at
|
||||
`platform/workloads/coulomb/whynot-design/npm-publish` includes
|
||||
`catalog-id=whynot-design-npm-publish` and that the `NPM_AUTH_TOKEN` field is
|
||||
present. The value was not printed, recorded, or copied into Git, State Hub,
|
||||
chat, or workplans.
|
||||
|
||||
## T05 - Verify caller-scoped fetch behavior
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0006-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "dc1f470b-e78a-48a9-9957-965aed47861f"
|
||||
```
|
||||
|
||||
Prove that the authorized identity can read the token through the intended
|
||||
OpenBao path and that unauthorized identities cannot.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- An approved whynot-design identity or operator role can authenticate and
|
||||
perform the fetch without unresolved `<...>` placeholders.
|
||||
- Negative verification shows a non-whynot identity cannot read the path.
|
||||
- Verification output contains no token value.
|
||||
- OpenBao audit evidence exists for the authorized read and denied read, with
|
||||
only non-secret request ids/timestamps recorded in the workplan or State Hub.
|
||||
|
||||
**2026-06-27:** Verification is waiting on live policy/role application and
|
||||
secret provisioning. The runbook requires positive and negative fetch evidence
|
||||
without printing the token value.
|
||||
|
||||
**2026-06-28:** Non-secret operator checks now pass for policy, auth role,
|
||||
metadata, and field presence. Remaining verification is the attended
|
||||
whynot-design OIDC positive check and a non-whynot denial check, both without
|
||||
printing the token.
|
||||
|
||||
**2026-06-29:** Positive and negative caller verification passed without
|
||||
printing the token value. The negative check failed OIDC login with the expected
|
||||
groups bound-claim mismatch. `platform-root` was restored to the
|
||||
`whynot-design` group after the temporary negative-test removal.
|
||||
|
||||
## T06 - Coordinate ops-warden catalog activation
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0006-T06
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "8e84ec19-01db-4baf-a532-de87e51d4994"
|
||||
```
|
||||
|
||||
Send ops-warden the non-secret pointers needed to create and activate its
|
||||
dedicated access catalog entry.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The State Hub reply to ops-warden includes only path, field, KV mount,
|
||||
OIDC role, policy name/path, optional flex-auth ref, and runbook location.
|
||||
- Ops-warden confirms the `whynot-design-npm-publish` catalog entry no longer
|
||||
contains unresolved placeholders.
|
||||
- `warden access "npm auth token" --fetch` or the agreed exact selector resolves
|
||||
to the whynot-design lane and proxies the read as the caller.
|
||||
- ops-warden confirms it holds no token value and only proxies OpenBao access.
|
||||
|
||||
**2026-06-27:** Added `docs/workload-kv-access-lanes.md` with the non-secret
|
||||
handoff payload for ops-warden and sent the pointers by State Hub message. The
|
||||
entry should remain draft/non-active until live OpenBao provisioning and
|
||||
verification complete.
|
||||
|
||||
**2026-06-28:** The generic `openbao-api-key` ops-warden access lane can proxy
|
||||
the check with explicit `--path` and `--field`, but the dedicated
|
||||
`whynot-design-npm-publish` route is not yet present in the ops-warden routing
|
||||
catalog. Keep activation pending until caller verification and catalog update.
|
||||
|
||||
**2026-06-29:** `CCR-2026-0001` is now active with
|
||||
`access_frontdoor.readiness=ready` and `resolvable=true`. ops-warden still needs
|
||||
to confirm that its dedicated `whynot-design-npm-publish` catalog selector
|
||||
resolves through the caller-scoped lane.
|
||||
|
||||
**2026-06-29:** ops-warden confirmed in State Hub message
|
||||
`f76d3a9e-a98f-4081-885d-b79d94312699` that catalog selector
|
||||
`whynot-design-npm-publish` is `status: active`, `resolvable: true`, and wired
|
||||
to the owner-confirmed lane:
|
||||
`platform/workloads/coulomb/whynot-design/npm-publish`, field
|
||||
`NPM_AUTH_TOKEN`, OIDC role `whynot-design-workload-kv-read`, and policy
|
||||
`workload-kv-read-whynot-design-npm-publish`. ops-warden also confirmed it
|
||||
notified whynot-design with `warden access whynot-design-npm-publish --exec -- npm publish`,
|
||||
and that the sibling lanes remain draft for separate planning.
|
||||
|
||||
## T07 - Decide whether to batch sibling workload-KV requests
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0006-T07
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "0b3ab5f5-e933-41f2-b29a-ab4ac50593aa"
|
||||
```
|
||||
|
||||
Ops-warden noted similar still-open access lanes for
|
||||
`issue-core-ingestion-api-key` and `openrouter-llm-connect`. Decide whether to
|
||||
batch those paths in the same provisioning pass or keep this workplan scoped to
|
||||
whynot-design.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The decision is recorded without secret values.
|
||||
- If batching is approved, add concrete sub-tasks or a follow-up workplan for
|
||||
each additional lane.
|
||||
- If batching is deferred, notify ops-warden that this workplan will deliver
|
||||
whynot-design first and leave the sibling entries for separate planning.
|
||||
|
||||
**2026-06-27:** Initially deferred sibling lanes (`issue-core-ingestion-api-key`
|
||||
and `openrouter-llm-connect`) so the whynot-design npm token request could be
|
||||
serviced first. The later ops-warden batch follow-up is now represented as
|
||||
proposed CCRs in `RAILIANCE-WP-0007`, still unapproved and unresolvable until
|
||||
human review and verification.
|
||||
|
||||
**2026-06-29:** Reviewed the sibling lane suggestions against `INTENT.md`.
|
||||
Created follow-up workplans `RAILIANCE-WP-0009` for the issue-core runtime
|
||||
ingestion credential lane and `RAILIANCE-WP-0010` for the llm-connect
|
||||
OpenRouter provider key lane. Both plans keep this repo's scope limited to
|
||||
shared platform secret custody, least-privilege OpenBao/External Secrets
|
||||
delivery, verification, and ops-warden front-door handoff.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- The whynot-design npm publish token has a concrete OpenBao KV path, field,
|
||||
read policy, and auth role.
|
||||
- The authorized caller can fetch the token as itself through OpenBao and
|
||||
ops-warden without ops-warden storing the value.
|
||||
- Unauthorized reads are denied.
|
||||
- ops-warden has enough non-secret pointers to activate
|
||||
`whynot-design-npm-publish`.
|
||||
- No secret values appear in Git, State Hub, chat, prompts, logs, or workplans.
|
||||
@@ -0,0 +1,404 @@
|
||||
---
|
||||
id: RAILIANCE-WP-0007
|
||||
type: workplan
|
||||
title: "Credential Change Proposal Review Workflow"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
planning_priority: high
|
||||
planning_order: 7
|
||||
created: "2026-06-27"
|
||||
updated: "2026-06-30"
|
||||
depends_on_workplans:
|
||||
- RAIL-PL-WP-0002
|
||||
- RAILIANCE-WP-0005
|
||||
- RAILIANCE-WP-0006
|
||||
state_hub_workstream_id: "4d7ce243-f40a-4249-a46a-a24f75d6fe4c"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0007 - Credential Change Proposal Review Workflow
|
||||
|
||||
## Goal
|
||||
|
||||
Create a proposal -> review -> approve/deny with comment -> apply -> verify
|
||||
workflow for credential and it-sec changes, so operators do not need to author
|
||||
or mentally validate raw OpenBao commands.
|
||||
|
||||
The first target is the whynot-design npm token lane from `RAILIANCE-WP-0006`.
|
||||
The workflow should then generalize to workload KV paths, OpenBao token roles,
|
||||
ops-warden access catalog entries, External Secrets lanes, credential rotation,
|
||||
deactivation, and compromise handling.
|
||||
|
||||
## Direction
|
||||
|
||||
Do not start by extending OpenBao. Instead, build a small approval control
|
||||
plane around OpenBao:
|
||||
|
||||
- OpenBao remains the enforcement, secret storage, token, and audit engine.
|
||||
- State Hub stores non-secret request lifecycle, comments, decisions, and
|
||||
evidence.
|
||||
- Repo files store reviewable non-secret request specs and generated policy
|
||||
artifacts.
|
||||
- Agents and CLIs create proposals and render them for human review.
|
||||
- Humans approve or deny with comments.
|
||||
- Only approved requests can be applied by an operator-controlled runner or
|
||||
interactive runbook.
|
||||
|
||||
If the workflow proves valuable, a later UI or OpenBao extension can surface the
|
||||
same request index and statuses.
|
||||
|
||||
## Proposed Object
|
||||
|
||||
Introduce a non-secret Credential Change Request, or `CCR`.
|
||||
|
||||
Each CCR captures:
|
||||
|
||||
- request id, title, requester, reviewer, approver, and applier;
|
||||
- target tenant/workload/environment/purpose;
|
||||
- OpenBao mount, path, fields, policies, auth roles, and bound claims;
|
||||
- access front door such as ops-warden, External Secrets, CSI, or direct caller
|
||||
fetch;
|
||||
- risk classification and approval requirements;
|
||||
- generated apply plan and verification plan;
|
||||
- rollback, deactivate, rotate, and compromise response plan;
|
||||
- comments, decision, timestamps, and non-secret audit evidence.
|
||||
|
||||
Each CCR explicitly excludes secret values, token values, private keys,
|
||||
passwords, unseal/recovery material, and secret-bearing command output.
|
||||
|
||||
## Tasks
|
||||
|
||||
## T01 - Record the approval workflow design
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c82ee783-80f1-48da-a9ed-4565eac699fc"
|
||||
```
|
||||
|
||||
Document the desired operator workflow and why it should sit around OpenBao
|
||||
rather than inside the OpenBao UI initially.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The design describes the proposal, review, approval/denial, apply, verify,
|
||||
activate, deactivate, rotate, and compromised states.
|
||||
- The design names where State Hub, OpenBao, ops-warden, repo files, agents,
|
||||
and interactive runbooks fit.
|
||||
- The design keeps secret values out of State Hub, Git, chat, and prompts.
|
||||
|
||||
**2026-06-27:** Added `docs/credential-change-approval.md` with the control
|
||||
plane direction, CCR object, state machine, State Hub/OpenBao/ops-warden roles,
|
||||
interactive runbook role, and compromise/deactivation path.
|
||||
|
||||
## T02 - Define the CCR schema and storage layout
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "d50fb9e2-68c2-4a2b-8476-ce646d13e60a"
|
||||
```
|
||||
|
||||
Create a versioned non-secret schema for credential change requests.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A schema exists for `workload-kv-read` requests covering mount, path, fields,
|
||||
policy name, auth role, bound claims, access front door, verification plan,
|
||||
and activation conditions.
|
||||
- The schema supports decision metadata: requested, proposed, approved,
|
||||
denied, needs_changes, applied, verified, active, deactivated, rotated,
|
||||
compromised, superseded, and cancelled.
|
||||
- The schema supports comments and references State Hub ids without storing
|
||||
secrets.
|
||||
- Example CCR fixtures include the whynot-design npm token lane.
|
||||
|
||||
**2026-06-27:** Added `schemas/credential-change-request.schema.yaml`, the
|
||||
`credential-change-requests/` storage directory, and
|
||||
`credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml` as the
|
||||
first non-secret CCR fixture. The whynot CCR is intentionally `proposed` and
|
||||
marks the bound claim as unconfirmed, so apply is blocked until review.
|
||||
|
||||
## T03 - Add offline validation and rendering
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "012f05cd-30ce-43dd-802b-4acc938db133"
|
||||
```
|
||||
|
||||
Add a helper that validates CCR files and renders human review summaries.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Invalid CCRs fail before any OpenBao apply is attempted.
|
||||
- The renderer produces a compact review block that a human can understand in
|
||||
chat or State Hub.
|
||||
- The renderer highlights risky fields: broad claims, wildcard paths,
|
||||
privileged policies, missing negative verification, and missing deactivation
|
||||
plan.
|
||||
- A secret-pattern scan rejects likely token values in CCR files.
|
||||
|
||||
**2026-06-27:** Added `scripts/credential-change.py validate` and `render`,
|
||||
plus Make targets `credential-change-validate` and `credential-change-render`.
|
||||
Validation rejects secret-looking markers and broad/unsafe request shapes; render
|
||||
produces the chat/State Hub review summary and highlights unconfirmed bound
|
||||
claims. CCRs now also carry machine-readable front-door readiness fields:
|
||||
`access_frontdoor.readiness` and `access_frontdoor.resolvable`. Unit coverage
|
||||
lives in `tests/test_credential_change.py`.
|
||||
|
||||
## T04 - Generate OpenBao apply plans from approved CCRs
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1b2e7752-815c-46f8-a2e2-212e8d04da80"
|
||||
```
|
||||
|
||||
Generate deterministic, reviewable OpenBao apply plans from CCRs.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A workload KV CCR can generate policy HCL and auth-role commands or API
|
||||
payloads.
|
||||
- The plan includes a dry-run mode and a diff against existing source
|
||||
artifacts when available.
|
||||
- Applying a plan is refused unless the CCR is approved.
|
||||
- The applier uses an approved operator authority path and does not accept raw
|
||||
tokens in argv or logs.
|
||||
|
||||
**2026-06-27:** Added `plan` and guarded `apply-plan` rendering for workload KV
|
||||
CCRs, with Make targets `credential-change-plan` and
|
||||
`credential-change-apply-plan`. `apply-plan` currently refuses any CCR that is
|
||||
not `approved` and also refuses unconfirmed bound claims. Remaining T04 work is
|
||||
to add a richer diff against existing source artifacts and eventually bridge
|
||||
from reviewed plan to the interactive live applier.
|
||||
|
||||
**2026-06-28:** Added OIDC `allowed_redirect_uris` to the CCR contract and
|
||||
generated role payloads after live OpenBao rejected an OIDC role without
|
||||
callbacks. Unit coverage now checks the generated whynot-design role payload.
|
||||
|
||||
**2026-06-30:** Added source-artifact diff rendering to `plan` and delegated
|
||||
`applier-dry-run` output. The generated plan now reports whether the checked-in
|
||||
policy artifact matches the CCR-generated HCL and shows a unified diff when it
|
||||
does not. Approved-only `apply-plan`/`operator-commands` remain gated by CCR
|
||||
status and confirmed auth binding.
|
||||
|
||||
## T05 - Add chat/CLI approval commands
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e6d4d2d1-1881-4db7-92f8-05e3fdb846ae"
|
||||
```
|
||||
|
||||
Make the workflow usable from chat and command line.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Operators can approve, deny, or request changes with a comment.
|
||||
- Approvals/denials are recorded as non-secret State Hub events and in the CCR
|
||||
file or linked decision record.
|
||||
- The system refuses apply when the latest human decision is denied or
|
||||
needs_changes.
|
||||
- Agents can propose changes and respond to review comments without receiving
|
||||
secret values.
|
||||
|
||||
**2026-06-27:** Added file-backed `approve`, `deny`, and `needs-changes`
|
||||
commands that require reviewer and comment text and append non-secret review
|
||||
comments to the CCR. Added `confirm-binding` for explicit non-secret auth
|
||||
binding confirmation. Added `status` plus Make targets
|
||||
`credential-change-status` and `credential-change-status-json` so ops-warden can
|
||||
consume `readiness`/`resolvable` without scraping prose. Remaining T05 work is
|
||||
State Hub decision-event emission and tighter chat integration. Created a
|
||||
State Hub decision for `CCR-2026-0001` and added `sync-decision` so resolved
|
||||
State Hub decisions can update the file-backed CCR status.
|
||||
|
||||
**2026-06-30:** Added optional `--record-state-hub` emission for approve, deny,
|
||||
and needs-changes commands. Review comments are checked for known secret markers
|
||||
before being written, and the State Hub progress event records only non-secret
|
||||
CCR id/path/policy/field/auth-role metadata plus the reviewer comment.
|
||||
|
||||
## T06 - Build an interactive runbook for apply and verify
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T06
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3c3fc38c-afa4-4367-b3e6-ba4b286ced30"
|
||||
```
|
||||
|
||||
Wrap privileged application in an operator-friendly guided runbook.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The runbook loads an approved CCR, shows the plan, asks for final attended
|
||||
confirmation, then applies policy/auth metadata.
|
||||
- Secret value entry is handled through an approved OpenBao/operator path and
|
||||
is never echoed or logged.
|
||||
- Positive and negative verification steps are guided.
|
||||
- Non-secret evidence is recorded automatically.
|
||||
|
||||
**2026-06-30:** Added `scripts/credential-change.py runbook <CCR>` and Make
|
||||
target `credential-change-runbook` to render the attended operator checklist,
|
||||
final confirmation phrase, metadata apply guidance, secret custody instructions,
|
||||
positive/negative verification steps, activation conditions, and evidence
|
||||
commands. `runbook --execute-metadata` is opt-in, requires the exact `APPLY
|
||||
<CCR-ID>` confirmation phrase, uses the local `bao` CLI with ambient approved
|
||||
operator authority, writes only policy/auth metadata, and records a non-secret
|
||||
`metadata_apply` evidence entry. Added `record-evidence` plus Make target
|
||||
`credential-change-record-evidence` so operators can append apply, secret
|
||||
provisioning, verification, and activation evidence to the CCR and optionally
|
||||
State Hub without storing secret values.
|
||||
|
||||
## T07 - Pilot with whynot-design and ops-warden
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T07
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "07a7d8bf-5528-41c8-a791-d6ccd0466a33"
|
||||
```
|
||||
|
||||
Use the existing whynot-design npm token lane as the first end-to-end pilot.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The current whynot-design lane is represented as a CCR.
|
||||
- The CCR is rendered and reviewed in chat or State Hub.
|
||||
- A human approval or denial comment is recorded.
|
||||
- If approved, the runbook applies the policy/auth metadata, guides secret
|
||||
provisioning, verifies access, and notifies ops-warden.
|
||||
- ops-warden activates its catalog entry only after CCR verification.
|
||||
|
||||
**2026-06-27:** The whynot-design lane is represented as `CCR-2026-0001` and
|
||||
can be rendered for review. The whynot-design bound claim was confirmed from
|
||||
operator chat context and recorded in the CCR, but it remains proposed/unapproved,
|
||||
so live apply and ops-warden activation are correctly blocked.
|
||||
|
||||
**2026-06-27:** Converted the ops-warden batch follow-up
|
||||
`fe5b1696-8956-4bd5-9d6f-dbde1901a076` into three proposed CCRs:
|
||||
`CCR-2026-0001` for `whynot-design-npm-publish`, `CCR-2026-0002` for
|
||||
`issue-core-ingestion-api-key`, and `CCR-2026-0003` for
|
||||
`llm-connect-openrouter-api-key`. All three are explicitly `readiness: template`
|
||||
and `resolvable: false` until owner confirmation, approval, OpenBao apply,
|
||||
secret provisioning, and verification are complete.
|
||||
|
||||
**2026-06-28:** Synced State Hub decision
|
||||
`250669d0-8475-4527-9624-cd072249f9a9` into `CCR-2026-0001`; the lane is now
|
||||
`approved` with confirmed binding and `apply_allowed: true`. A live OpenBao
|
||||
policy apply using the available token helper reached the active OpenBao pod but
|
||||
still failed with `403 permission denied` on
|
||||
`sys/policies/acl/workload-kv-read-whynot-design-npm-publish`, so the front door
|
||||
remains `readiness: template` and `resolvable: false`. Added guarded
|
||||
`credential-change-operator-commands` output so a platform operator can run the
|
||||
reviewed non-secret policy and auth-role commands without hand-writing them;
|
||||
secret value provisioning and verification remain under approved custody.
|
||||
|
||||
**2026-06-28:** After correcting the tenant/org to `coulomb`, the corrected
|
||||
approval was synced from State Hub decision
|
||||
`e6381a56-6b04-4fd5-b2de-f3ef59cde888`; `CCR-2026-0001` is approved and
|
||||
`apply_allowed: true` for
|
||||
`platform/workloads/coulomb/whynot-design/npm-publish`. The operator reported
|
||||
secret provisioning likely completed, but Codex metadata-only verification still
|
||||
received `403 permission denied`. Prepared
|
||||
`docs/whynot-design-npm-publish-handoff.md` as the next-session checklist for
|
||||
policy, auth-role, metadata verification, positive verification, negative
|
||||
verification, and activation without printing the token.
|
||||
|
||||
**2026-06-28:** With the temporary operator token, Codex applied/confirmed the
|
||||
OpenBao read policy and OIDC role, confirmed metadata `catalog-id`, and confirmed
|
||||
`NPM_AUTH_TOKEN` field presence without printing or recording the value. The CCR
|
||||
now records non-secret evidence for that apply check. Positive whynot-design and
|
||||
negative non-whynot caller verification still gate `active`/`ready`.
|
||||
|
||||
**2026-06-29:** The whynot-design pilot completed OpenBao verification. Positive
|
||||
fetch succeeded with output suppressed, negative login failed with the expected
|
||||
groups bound-claim mismatch, `platform-root` membership was restored afterward,
|
||||
and `CCR-2026-0001` is now active/ready/resolvable. ops-warden catalog
|
||||
confirmation remains the external closeout step.
|
||||
|
||||
**2026-06-30:** Closed the pilot task based on the active/ready/resolvable CCR
|
||||
state and prior ops-warden catalog confirmation that the selector is active and
|
||||
resolvable. The remaining lifecycle work is now tracked separately in T08.
|
||||
|
||||
## T08 - Add deactivation, rotation, and compromise flows
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T08
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "23d6ef9d-8dbc-4468-b486-5ec8ada71130"
|
||||
```
|
||||
|
||||
Support lifecycle states beyond initial creation.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Existing credentials can be imported as CCR-backed inventory without secret
|
||||
values.
|
||||
- Operators can mark a lane deactivated, rotated, or compromised with reason
|
||||
and evidence.
|
||||
- Deactivation disables the relevant access front door and auth/policy path.
|
||||
- Compromise flow records blast-radius notes and required follow-up tasks.
|
||||
|
||||
**2026-06-30:** Added `lifecycle-plan`, `lifecycle-event`, and
|
||||
`import-inventory` commands plus Make targets. Lifecycle plans render
|
||||
deactivation, rotation, and compromise guidance, including access-front-door
|
||||
state changes and OpenBao metadata disable commands for deactivation or
|
||||
compromise. Lifecycle events update CCR status/front-door readiness, append
|
||||
non-secret lifecycle evidence, and optionally post State Hub progress.
|
||||
Compromise events accept non-secret blast-radius and follow-up references.
|
||||
`import-inventory` can create a CCR-backed inventory file and matching read
|
||||
policy artifact for an existing lane without asking for or storing secret
|
||||
values.
|
||||
|
||||
## T09 - Add decision templates and guided review actions
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0007-T09
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c436fd8b-cd82-4600-81b0-87ec069d7ae6"
|
||||
```
|
||||
|
||||
Remove the current friction where reviewers must know magic rationale prefixes
|
||||
for State Hub decisions to sync back into CCR status.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Each CCR review page or chat handoff shows explicit approve, deny, and needs
|
||||
changes templates.
|
||||
- Generated templates include the accepted prefixes (`APPROVE:`, `DENY:`, and
|
||||
`NEEDS_CHANGES:`) and pre-fill the CCR id, corrected path, policy, auth role,
|
||||
and non-secret rationale prompt.
|
||||
- The dashboard or agent response links directly to the decision and states what
|
||||
phrase or button will be recognized.
|
||||
- The sync tooling refuses ambiguous free-text approvals with a friendly message
|
||||
that shows the valid templates.
|
||||
- Future UI work can replace prefix parsing with structured decision outcomes
|
||||
without changing the CCR audit trail.
|
||||
|
||||
**2026-06-30:** Added `scripts/credential-change.py decision-templates <CCR>`
|
||||
and Make target `credential-change-decision-templates`. The generated templates
|
||||
include accepted prefixes, CCR id, KV path, policy, auth-role path, and the
|
||||
linked State Hub decision. Ambiguous State Hub rationale text now fails with the
|
||||
valid templates in the error message.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- A human can review and approve or deny a credential/security change without
|
||||
writing raw OpenBao commands.
|
||||
- An approved request can be applied by an operator-controlled helper or
|
||||
interactive runbook.
|
||||
- State Hub and repo artifacts contain non-secret lifecycle, decision, and
|
||||
evidence records.
|
||||
- OpenBao remains the enforcement and audit source for actual secret access.
|
||||
- The whynot-design npm token lane can complete through this workflow.
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
id: RAILIANCE-WP-0008
|
||||
type: workplan
|
||||
title: "OpenBao Approved Automation Delegation"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
planning_priority: high
|
||||
planning_order: 8
|
||||
created: "2026-06-28"
|
||||
updated: "2026-06-30"
|
||||
depends_on_workplans:
|
||||
- RAIL-PL-WP-0002
|
||||
- RAILIANCE-WP-0005
|
||||
- RAILIANCE-WP-0006
|
||||
- RAILIANCE-WP-0007
|
||||
state_hub_workstream_id: "671898ef-2378-4814-b8f6-066148cdad46"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0008 - OpenBao Approved Automation Delegation
|
||||
|
||||
## Goal
|
||||
|
||||
Create a narrow OpenBao automation delegation path so approved credential and
|
||||
it-sec changes can be applied without handing broad `platform-admin` power to an
|
||||
agent, while still preserving different build, test, and production security
|
||||
requirements.
|
||||
|
||||
This closes the current gap where reviewed CCRs can be approved and rendered,
|
||||
but live application still fails with `403 permission denied` on
|
||||
`sys/policies/acl/...` unless a human operator uses broad OpenBao admin
|
||||
privileges.
|
||||
|
||||
## Problem
|
||||
|
||||
The current model has two bad modes:
|
||||
|
||||
- fully manual OpenBao UI work, which can unblock one credential but is slow,
|
||||
hard to review, and easy to drift from the CCR;
|
||||
- broad `platform-admin`, which is too powerful for routine automation and too
|
||||
risky to hand to agents.
|
||||
|
||||
The issue appears in multiple workstreams:
|
||||
|
||||
- `RAILIANCE-WP-0005` token-grant apply failed on
|
||||
`sys/policies/acl/credential-broker-warden-sign-issuer`;
|
||||
- `RAILIANCE-WP-0007` whynot-design CCR apply failed on
|
||||
`sys/policies/acl/workload-kv-read-whynot-design-npm-publish`.
|
||||
|
||||
Both are approved non-secret metadata changes. They should not require a human
|
||||
to hand-type OpenBao policy and auth-role writes once review is complete.
|
||||
|
||||
## Direction
|
||||
|
||||
Add one or more constrained OpenBao applier identities that can apply only
|
||||
reviewed, generated metadata for approved change classes.
|
||||
|
||||
The applier must not be a general `platform-admin` replacement. It should:
|
||||
|
||||
- verify a resolved State Hub decision or CCR status before live mutation;
|
||||
- write only allowed policy names and auth role prefixes;
|
||||
- never read or print secret values;
|
||||
- record non-secret apply evidence;
|
||||
- keep production secret value provisioning under approved operator custody
|
||||
until a separate wrapped or dual-control flow is approved.
|
||||
|
||||
## Environment Model
|
||||
|
||||
Build and development:
|
||||
|
||||
- automation may apply approved sandbox metadata;
|
||||
- generated test secrets may be allowed only in non-production mounts;
|
||||
- approvals can be lightweight but still recorded.
|
||||
|
||||
Test and staging:
|
||||
|
||||
- automation may apply approved metadata after validation and owner review;
|
||||
- verification must include positive and negative access checks;
|
||||
- secret values should use wrapped delivery or operator custody depending on
|
||||
risk classification.
|
||||
|
||||
Production:
|
||||
|
||||
- automation may apply only approved non-secret metadata such as narrow ACL
|
||||
policies and auth roles;
|
||||
- production secret values remain out-of-band operator custody unless a later
|
||||
workplan creates a stronger wrapped or dual-control provisioning path;
|
||||
- activation requires verification evidence before ops-warden or another front
|
||||
door becomes resolvable.
|
||||
|
||||
## Proposed Policy Shape
|
||||
|
||||
Start with one production candidate policy, for example
|
||||
`credential-change-prod-applier`, and keep it intentionally narrow:
|
||||
|
||||
- allow `create`, `update`, and `read` on `sys/policies/acl/workload-kv-read-*`;
|
||||
- allow `create`, `update`, and `read` on approved credential-broker issuer
|
||||
policy names such as `credential-broker-*-issuer`;
|
||||
- allow `create`, `update`, and `read` on selected auth role prefixes such as
|
||||
`auth/netkingdom/role/*-workload-kv-read`,
|
||||
`auth/kubernetes/role/*`, and `auth/token/roles/credential-broker-*`;
|
||||
- allow read/list only where needed for idempotent verification;
|
||||
- deny broad `sys/*`, `auth/*`, `platform/*`, `identity/*`, `root`, and
|
||||
`platform-admin` semantics.
|
||||
|
||||
Policy-name and role-name restrictions should be enforced in both OpenBao ACLs
|
||||
and the local applier script.
|
||||
|
||||
## Tasks
|
||||
|
||||
## T01 - Specify delegated applier policy boundaries
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0008-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "d19fdfc5-addb-4813-8086-3aca2e948cea"
|
||||
```
|
||||
|
||||
Define build, test, and production applier capabilities, including exact
|
||||
OpenBao paths, allowed name prefixes, denied paths, and required audit evidence.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A human reviewer can see which OpenBao paths each environment can mutate.
|
||||
- Production applier policy excludes secret value reads and broad admin paths.
|
||||
- The proposal covers both workload KV read lanes and credential broker issuer
|
||||
policies.
|
||||
|
||||
**2026-06-29:** Added `docs/openbao-approved-automation-delegation.md` and
|
||||
`openbao/policies/credential-change-prod-applier.hcl`. The document defines
|
||||
build/development, test/staging, and production boundaries, the allowed
|
||||
production metadata mutation surface, denied secret/admin paths, and required
|
||||
non-secret evidence. The production policy candidate allows only reviewed
|
||||
metadata writes for workload KV read policies, credential-broker issuer
|
||||
policies, approved auth-role prefixes, and self capability checks; it does not
|
||||
grant secret value reads or writes.
|
||||
|
||||
## T02 - Implement a CCR-aware applier dry-run
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0008-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2613f40d-fbd9-44f3-a864-85ec1d54e8f7"
|
||||
```
|
||||
|
||||
Extend the credential-change tooling so a proposed applier can validate a CCR,
|
||||
check approval state, render the exact mutations, and refuse any out-of-policy
|
||||
policy name, auth role, mount, path, or environment.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Dry-run succeeds for `CCR-2026-0001`.
|
||||
- Dry-run refuses unapproved CCRs.
|
||||
- Dry-run refuses attempts to create `root`, `platform-admin`, wildcard, or
|
||||
unrelated policy names.
|
||||
|
||||
**2026-06-29:** Added `scripts/credential-change.py applier-dry-run <CCR>` and
|
||||
Make target `credential-change-applier-dry-run`. The dry-run validates the CCR,
|
||||
requires approved/applied/verified/active status, requires confirmed auth
|
||||
bindings, verifies the OpenBao mount/path/policy/role stay inside the delegated
|
||||
metadata surface, compares the policy artifact to the generated CCR policy body,
|
||||
and renders only policy/auth-role mutations. It explicitly leaves secret value
|
||||
writes, secret reads, and front-door activation out of scope. Unit tests cover
|
||||
the active whynot-design CCR success path, unapproved CCR refusal, and rejection
|
||||
of `platform-admin`/out-of-scope mount and path attempts. `make
|
||||
credential-tests` passed with 28 tests.
|
||||
|
||||
## T03 - Add non-production applier role first
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0008-T03
|
||||
status: progress
|
||||
priority: medium
|
||||
state_hub_task_id: "ff927a19-50fb-4351-8db1-c60a0cce0995"
|
||||
```
|
||||
|
||||
Create a build/test applier identity and prove it can apply approved metadata in
|
||||
a non-production lane without gaining unrelated OpenBao permissions.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Apply succeeds in a non-production mount or namespace.
|
||||
- Negative checks prove unrelated policy/auth/secret paths are denied.
|
||||
- Evidence is recorded without secret values.
|
||||
|
||||
**2026-06-30:** Added the non-production metadata-only policy candidate
|
||||
`openbao/policies/credential-change-nonprod-applier.hcl` and documented that
|
||||
generated test-secret paths require separate CCR-backed approval. Live non-prod
|
||||
identity creation and positive/negative OpenBao evidence remain to close this
|
||||
task.
|
||||
|
||||
**2026-06-30:** Added the guarded `applier-apply` execution path that reuses the
|
||||
CCR dry-run guardrails, requires exact `DELEGATED APPLY <CCR-ID>` confirmation,
|
||||
uses the local `bao` CLI with ambient delegated applier authority, writes only
|
||||
policy/auth-role metadata, and records non-secret `delegated_metadata_apply`
|
||||
evidence. Non-production task closure still needs a live build/test applier
|
||||
identity plus positive and negative capability evidence.
|
||||
|
||||
**2026-06-30:** Added `scripts/openbao-apply-credential-change-appliers.py` and
|
||||
Make target `openbao-credential-change-appliers-dry-run` to install/dry-run the
|
||||
non-production applier policy plus bounded `auth/token/roles/credential-change-
|
||||
nonprod-applier` role. The token role allows only the matching applier policy,
|
||||
disallows `root` and `platform-admin`, disables the default policy, and does not
|
||||
issue tokens by itself. Live non-production apply and denial evidence remains
|
||||
the closeout gate.
|
||||
|
||||
## T04 - Add production metadata applier with human approval gate
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0008-T04
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "414abd65-22d3-420f-994d-f7fdd1302db5"
|
||||
```
|
||||
|
||||
Create the production metadata applier path and require a resolved CCR/State Hub
|
||||
approval before mutation.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Approved `CCR-2026-0001` metadata can be applied without `platform-admin`.
|
||||
- Unapproved CCRs fail closed.
|
||||
- Secret value provisioning is still not automated in production.
|
||||
|
||||
**2026-06-30:** Strengthened the production gate by adding source-artifact
|
||||
checks to the CCR applier dry-run and documenting that unapproved CCRs fail
|
||||
closed before OpenBao mutation rendering. The production policy candidate exists
|
||||
and remains metadata-only; live delegated identity creation/application evidence
|
||||
still needs an operator-held OpenBao step.
|
||||
|
||||
**2026-06-30:** Added `applier-apply` and Make targets
|
||||
`credential-change-applier-apply-plan` / `credential-change-applier-apply`. The
|
||||
command fails closed for unapproved CCRs, renders the dry-run payload before
|
||||
mutation, requires exact confirmation, does not accept tokens in argv, leaves
|
||||
secret values out of scope, and appends State Hub/file-backed non-secret apply
|
||||
evidence when requested. Production closure still requires live execution using
|
||||
the constrained applier identity rather than broad `platform-admin`.
|
||||
|
||||
**2026-06-30:** Added `scripts/openbao-apply-credential-change-appliers.py` and
|
||||
Make targets `openbao-credential-change-appliers-dry-run` /
|
||||
`openbao-configure-credential-change-appliers` to configure the production
|
||||
`credential-change-prod-applier` policy and bounded token role. The role allows
|
||||
only `credential-change-prod-applier`, disallows `root` and `platform-admin`,
|
||||
uses service tokens, disables default policy attachment, and keeps token issuance
|
||||
outside the setup script. Production closure still needs a live run and
|
||||
capability evidence using this constrained identity.
|
||||
|
||||
## T05 - Close the whynot-design pilot
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0008-T05
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "18f34c95-4d2b-4a08-a5ad-5ab700ff9dfe"
|
||||
```
|
||||
|
||||
Use the delegated production metadata applier to finish the whynot-design npm
|
||||
publish token lane after the actual token is provisioned through approved
|
||||
custody.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Policy and auth role are applied by the delegated applier.
|
||||
- `NPM_AUTH_TOKEN` is provisioned through approved custody.
|
||||
- Positive and negative verification pass without printing the token.
|
||||
- `CCR-2026-0001` can move to `active`.
|
||||
- ops-warden can mark `whynot-design-npm-publish` ready/resolvable.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- Routine approved OpenBao metadata changes no longer require broad
|
||||
`platform-admin`.
|
||||
- Production automation cannot read or exfiltrate managed secret values.
|
||||
- Build, test, and production each have distinct, documented security
|
||||
requirements.
|
||||
- CCR approval, apply, verification, and front-door activation form one
|
||||
reviewable chain.
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
id: RAILIANCE-WP-0009
|
||||
type: workplan
|
||||
title: "Issue-Core Runtime Ingestion Credential Lane"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
planning_priority: high
|
||||
planning_order: 9
|
||||
created: "2026-06-29"
|
||||
updated: "2026-06-30"
|
||||
depends_on_workplans:
|
||||
- RAIL-PL-WP-0002
|
||||
- RAILIANCE-WP-0004
|
||||
- RAILIANCE-WP-0007
|
||||
- RAILIANCE-WP-0008
|
||||
related_state_hub_messages:
|
||||
- "f76d3a9e-a98f-4081-885d-b79d94312699"
|
||||
related_ccrs:
|
||||
- CCR-2026-0002
|
||||
state_hub_workstream_id: "b059c81d-96f1-451f-896f-a05cd73744a1"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0009 - Issue-Core Runtime Ingestion Credential Lane
|
||||
|
||||
## Goal
|
||||
|
||||
Promote the draft `issue-core-ingestion-api-key` access lane from a proposed
|
||||
CCR to a reviewed, least-privilege, verified OpenBao workload KV lane that
|
||||
`issue-core` can consume through External Secrets and that `ops-warden` can
|
||||
reference without holding secret values.
|
||||
|
||||
This follows the same platform pattern proven by `RAILIANCE-WP-0006`, but keeps
|
||||
the issue-core lane independent so the field set, service-account binding,
|
||||
verification evidence, and activation decision can be reviewed on their own.
|
||||
|
||||
No task in this workplan may paste, commit, log, or send secret values through
|
||||
Git, State Hub, chat, prompts, shell history, or workplan text.
|
||||
|
||||
## Suggestion Reviewed
|
||||
|
||||
Ops-warden confirmed the whynot-design lane in State Hub message
|
||||
`f76d3a9e-a98f-4081-885d-b79d94312699` and noted that
|
||||
`issue-core-ingestion-api-key` remains draft on its side. The repo already has
|
||||
the proposed non-secret CCR:
|
||||
|
||||
- `credential-change-requests/CCR-2026-0002-issue-core-ingestion-api-key.yaml`
|
||||
|
||||
## INTENT Fit
|
||||
|
||||
This work belongs in railiance-platform because it provides shared, secure,
|
||||
operable secret custody and delivery behind stable interfaces. It does not add
|
||||
issue-core application behavior. The platform-owned output is the OpenBao path,
|
||||
read policy, Kubernetes auth role, External Secrets contract, verification
|
||||
evidence, and ops-warden handoff.
|
||||
|
||||
The plan supports these `INTENT.md` principles:
|
||||
|
||||
- secure custody: secret values stay in OpenBao/operator custody;
|
||||
- stable interfaces: issue-core consumes a documented path, fields, role, and
|
||||
External Secrets target rather than internal OpenBao topology;
|
||||
- operable and observable: activation requires positive and negative checks
|
||||
plus non-secret audit evidence;
|
||||
- independently evolvable: the credential store, auth role, and front door can
|
||||
change underneath the consumer contract.
|
||||
|
||||
## Proposed Contract
|
||||
|
||||
| Item | Proposed value |
|
||||
| --- | --- |
|
||||
| CCR | `CCR-2026-0002` |
|
||||
| ops-warden catalog id | `issue-core-ingestion-api-key` |
|
||||
| Tenant/org | `issue-core` |
|
||||
| Workload/project | `issue-core` |
|
||||
| KV mount | `platform` |
|
||||
| OpenBao CLI path | `platform/workloads/issue-core/issue-core/issue-core-runtime` |
|
||||
| Secret fields | `ISSUE_CORE_API_KEY`, pending decision on `GITEA_BACKEND_TOKEN` |
|
||||
| Read policy | `workload-kv-read-issue-core-runtime` |
|
||||
| Policy file | `openbao/policies/workload-kv-read-issue-core-runtime.hcl` |
|
||||
| Auth method | Kubernetes auth |
|
||||
| Auth role | `external-secrets-issue-core` |
|
||||
| OpenBao auth service account | `external-secrets` |
|
||||
| OpenBao auth namespace | `external-secrets` |
|
||||
| Delivery surface | `ExternalSecret issue-core/issue-core-runtime` to Secret `issue-core-runtime` |
|
||||
| ops-warden command | `warden access issue-core-ingestion-api-key --fetch ISSUE_CORE_API_KEY` |
|
||||
|
||||
The `GITEA_BACKEND_TOKEN` field remains an explicit review point. Remove it
|
||||
from the CCR before approval if issue-core no longer needs it in this lane.
|
||||
|
||||
## Tasks
|
||||
|
||||
## T01 - Review CCR scope and field set
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0009-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "64d85288-38fb-4374-b889-fd0d136d3bdf"
|
||||
```
|
||||
|
||||
Review `CCR-2026-0002` with the platform operator and issue-core owner before
|
||||
any live OpenBao apply.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The issue-core owner confirms whether `GITEA_BACKEND_TOKEN` belongs in this
|
||||
lane or should be removed before approval.
|
||||
- The approved field set is limited to runtime ingestion credentials needed by
|
||||
issue-core.
|
||||
- Review comments and approval state are recorded in the CCR without secret
|
||||
values.
|
||||
- The lane remains clearly platform-owned secret custody, not issue-core
|
||||
application logic.
|
||||
|
||||
**2026-06-30:** Live cluster metadata confirms
|
||||
`ExternalSecret issue-core/issue-core-runtime` is `Ready=True` with reason
|
||||
`SecretSynced` and maps both `ISSUE_CORE_API_KEY` and `GITEA_BACKEND_TOKEN` from
|
||||
`platform/workloads/issue-core/issue-core/issue-core-runtime`. Retain both
|
||||
fields in `CCR-2026-0002` unless the issue-core owner later removes one through
|
||||
review. The CCR remains `proposed`; this task records non-secret scope review,
|
||||
not approval to apply.
|
||||
|
||||
## T02 - Confirm Kubernetes auth and External Secrets binding
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0009-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "7f4a8317-13f0-4be3-948c-a2e2f90447cf"
|
||||
```
|
||||
|
||||
Confirm the exact Kubernetes service account, namespace, and External Secrets
|
||||
target that should consume this lane.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The service account and namespace are confirmed as `issue-core`/`issue-core`
|
||||
or the CCR is updated with the approved alternative.
|
||||
- The auth role binds only the approved service account and namespace.
|
||||
- The External Secrets target and expected field names are documented.
|
||||
- No direct human or agent read path is activated unless separately approved.
|
||||
|
||||
**2026-06-30:** Confirmed the current delivery path uses the platform External
|
||||
Secrets operator, not a workload pod service account. The `issue-core`
|
||||
Deployment uses the `default` service account, and no `issue-core` service
|
||||
account exists. `ClusterSecretStore/openbao` authenticates to OpenBao as
|
||||
`external-secrets/external-secrets` with role `external-secrets-issue-core` and
|
||||
is limited to the `issue-core` namespace. Updated `CCR-2026-0002` to this
|
||||
confirmed auth subject while keeping the exact
|
||||
`workload-kv-read-issue-core-runtime` policy. `credential-change.py
|
||||
applier-dry-run CCR-2026-0002` now blocks only because the CCR is still
|
||||
`proposed`.
|
||||
|
||||
## T03 - Apply or confirm least-privilege OpenBao metadata
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0009-T03
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "e8566cf4-bb74-4515-b434-7cbf60f9f684"
|
||||
```
|
||||
|
||||
Apply the read policy and Kubernetes auth role only after review and binding
|
||||
confirmation.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `openbao/policies/workload-kv-read-issue-core-runtime.hcl` grants only read
|
||||
access to the exact KV-v2 data and metadata paths.
|
||||
- The Kubernetes auth role attaches only
|
||||
`workload-kv-read-issue-core-runtime`.
|
||||
- Live apply uses an approved operator path or the delegated applier from
|
||||
`RAILIANCE-WP-0008`; broad `platform-admin` handoffs are avoided where
|
||||
possible.
|
||||
- Apply evidence records only policy name, role name, actor, timestamp, and
|
||||
non-secret OpenBao request ids.
|
||||
|
||||
## T04 - Provision values through approved custody
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0009-T04
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "4990fe6a-ae84-4720-bc8d-e026d73a304b"
|
||||
```
|
||||
|
||||
Have an approved operator create or confirm the OpenBao KV entry and fields.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The path exists at
|
||||
`platform/workloads/issue-core/issue-core/issue-core-runtime`.
|
||||
- Approved fields are present with the exact reviewed names.
|
||||
- Values are entered directly through OpenBao/operator custody, never through
|
||||
Git, State Hub, chat, prompts, workplans, or shell history.
|
||||
- Non-secret evidence records only path, field names, actor, timestamp, and
|
||||
verification result.
|
||||
|
||||
## T05 - Verify positive and negative access
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0009-T05
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "65e83572-2e46-4196-8f4d-4ab35ba8d1a6"
|
||||
```
|
||||
|
||||
Prove that the approved issue-core identity can consume the lane and that other
|
||||
identities cannot.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Positive verification shows the approved issue-core service account can read
|
||||
the configured fields through OpenBao or External Secrets without printing
|
||||
values.
|
||||
- Negative verification shows an unapproved service account cannot read the
|
||||
path.
|
||||
- OpenBao audit evidence exists for allowed and denied attempts, recorded only
|
||||
as non-secret request ids or timestamps.
|
||||
- Verification includes the External Secrets delivery path if that is the
|
||||
production consumer interface.
|
||||
|
||||
## T06 - Activate ops-warden catalog front door
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0009-T06
|
||||
status: wait
|
||||
priority: medium
|
||||
state_hub_task_id: "0d9a02da-c032-43d5-8019-61ab4d87b40b"
|
||||
```
|
||||
|
||||
Send ops-warden the non-secret pointers needed to promote
|
||||
`issue-core-ingestion-api-key` from draft to active.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The handoff includes only catalog id, mount, path, field names, auth role,
|
||||
policy name/path, External Secrets target, optional flex-auth ref, and
|
||||
runbook/workplan links.
|
||||
- ops-warden confirms the catalog entry has no unresolved placeholders.
|
||||
- ops-warden confirms it proxies access as the caller and holds no secret
|
||||
value.
|
||||
- The CCR front-door readiness becomes active/resolvable only after positive
|
||||
and negative verification.
|
||||
|
||||
## T07 - Record lifecycle operations
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0009-T07
|
||||
status: wait
|
||||
priority: medium
|
||||
state_hub_task_id: "c85d1139-1f7d-4ed4-a2fc-5ea4ecbdf0c6"
|
||||
```
|
||||
|
||||
Document how to deactivate, rotate, and respond to compromise for this lane.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Deactivation disables the ops-warden front door and removes or detaches the
|
||||
auth role policy without deleting required audit evidence.
|
||||
- Rotation keeps values inside OpenBao/operator custody and records only
|
||||
non-secret evidence.
|
||||
- Compromise response names the immediate front-door disable, affected field
|
||||
rotation, and follow-up incident workplan path.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- `CCR-2026-0002` is reviewed, approved, applied, verified, and active.
|
||||
- issue-core can consume the required runtime ingestion credential through the
|
||||
approved platform interface.
|
||||
- Unauthorized access is denied and recorded.
|
||||
- ops-warden can resolve `issue-core-ingestion-api-key` without storing the
|
||||
value.
|
||||
- No secret values appear in Git, State Hub, chat, prompts, logs, or workplans.
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
id: RAILIANCE-WP-0010
|
||||
type: workplan
|
||||
title: "llm-connect OpenRouter Provider Key Lane"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
planning_priority: high
|
||||
planning_order: 10
|
||||
created: "2026-06-29"
|
||||
updated: "2026-06-30"
|
||||
depends_on_workplans:
|
||||
- RAIL-PL-WP-0002
|
||||
- RAILIANCE-WP-0004
|
||||
- RAILIANCE-WP-0007
|
||||
- RAILIANCE-WP-0008
|
||||
related_state_hub_messages:
|
||||
- "f76d3a9e-a98f-4081-885d-b79d94312699"
|
||||
related_ccrs:
|
||||
- CCR-2026-0003
|
||||
state_hub_workstream_id: "f364d405-a85d-4b89-b600-1964ab436cad"
|
||||
---
|
||||
|
||||
# RAILIANCE-WP-0010 - llm-connect OpenRouter Provider Key Lane
|
||||
|
||||
## Goal
|
||||
|
||||
Promote the draft llm-connect OpenRouter provider-key access lane from a
|
||||
proposed CCR to a reviewed, least-privilege, verified OpenBao workload KV lane
|
||||
that the live llm-connect runtime can consume through External Secrets and that
|
||||
`ops-warden` can reference without holding the provider key.
|
||||
|
||||
This keeps provider credential custody in the shared platform layer while
|
||||
leaving llm-connect behavior, model routing, and provider-specific runtime
|
||||
logic with the owning application/service.
|
||||
|
||||
No task in this workplan may paste, commit, log, or send secret values through
|
||||
Git, State Hub, chat, prompts, shell history, or workplan text.
|
||||
|
||||
## Suggestion Reviewed
|
||||
|
||||
Ops-warden confirmed the whynot-design lane in State Hub message
|
||||
`f76d3a9e-a98f-4081-885d-b79d94312699` and noted that the OpenRouter/llm-connect
|
||||
sibling lane remains draft on its side. The repo already has the proposed
|
||||
non-secret CCR:
|
||||
|
||||
- `credential-change-requests/CCR-2026-0003-llm-connect-openrouter-api-key.yaml`
|
||||
|
||||
The message called the sibling lane `openrouter-llm-connect`; the CCR uses
|
||||
catalog id `llm-connect-openrouter-api-key`. Resolve that naming with
|
||||
ops-warden before activation so automated callers have one stable selector.
|
||||
|
||||
## INTENT Fit
|
||||
|
||||
This work belongs in railiance-platform because it provides the dependable
|
||||
secret custody and delivery substrate for a shared runtime service. It does not
|
||||
decide which model or provider llm-connect should use. The platform-owned
|
||||
output is the OpenBao path, read policy, Kubernetes auth role, External Secrets
|
||||
target, verification evidence, and ops-warden handoff.
|
||||
|
||||
The plan supports these `INTENT.md` principles:
|
||||
|
||||
- secure custody: the provider key stays in OpenBao/operator custody;
|
||||
- stable interfaces: llm-connect consumes a documented path, field, role, and
|
||||
External Secrets target;
|
||||
- operable and observable: activation requires positive and negative checks
|
||||
plus non-secret audit evidence;
|
||||
- independently evolvable: the provider key storage and front-door routing can
|
||||
change without forcing runtime consumers to know internal topology.
|
||||
|
||||
## Proposed Contract
|
||||
|
||||
| Item | Proposed value |
|
||||
| --- | --- |
|
||||
| CCR | `CCR-2026-0003` |
|
||||
| ops-warden catalog id | `llm-connect-openrouter-api-key` pending naming confirmation |
|
||||
| Tenant/org | `activity-core` |
|
||||
| Workload/project | `llm-connect` |
|
||||
| KV mount | `platform` |
|
||||
| OpenBao CLI path | `platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets` |
|
||||
| Secret field | `OPENROUTER_API_KEY` |
|
||||
| Read policy | `workload-kv-read-llm-connect-provider-secrets` |
|
||||
| Policy file | `openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl` |
|
||||
| Auth method | Kubernetes auth |
|
||||
| Auth role | `external-secrets-activity-core` |
|
||||
| OpenBao auth service account | `external-secrets` |
|
||||
| OpenBao auth namespace | `external-secrets` |
|
||||
| Delivery surface | Future activity-core ExternalSecret to Secret `llm-connect-provider-secrets` |
|
||||
| ops-warden command | `warden access llm-connect-openrouter-api-key --fetch OPENROUTER_API_KEY` |
|
||||
|
||||
## Tasks
|
||||
|
||||
## T01 - Review CCR scope and selector naming
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0010-T01
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "307b75a6-a3a8-473b-b171-7379d2848698"
|
||||
```
|
||||
|
||||
Review `CCR-2026-0003` with the platform operator and activity-core owner
|
||||
before any live OpenBao apply.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The activity-core owner confirms that llm-connect should receive
|
||||
`OPENROUTER_API_KEY` through this platform lane.
|
||||
- ops-warden and railiance-platform agree on one stable catalog id/selector,
|
||||
reconciling `openrouter-llm-connect` with
|
||||
`llm-connect-openrouter-api-key`.
|
||||
- Review comments and approval state are recorded in the CCR without secret
|
||||
values.
|
||||
- The lane remains clearly platform-owned secret custody, not llm-connect model
|
||||
routing or provider selection logic.
|
||||
|
||||
**2026-06-30:** Confirmed `activity-core` namespace exists and Kubernetes Secret
|
||||
`activity-core/llm-connect-provider-secrets` exists, but no activity-core
|
||||
`ExternalSecret` exists yet. Kept canonical CCR catalog id
|
||||
`llm-connect-openrouter-api-key`; ops-warden previously mentioned
|
||||
`openrouter-llm-connect`, so selector agreement remains open and this task stays
|
||||
`progress`. OpenBao public seal status now reports `sealed=false`; the prior
|
||||
sealed message is no longer the active blocker.
|
||||
|
||||
## T02 - Confirm Kubernetes auth and External Secrets binding
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0010-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "829192f5-4502-44e0-8020-656d74d5282a"
|
||||
```
|
||||
|
||||
Confirm the exact Kubernetes service account, namespace, and External Secrets
|
||||
target that should consume this lane.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The service account and namespace are confirmed as
|
||||
`llm-connect`/`activity-core` or the CCR is updated with the approved
|
||||
alternative.
|
||||
- The auth role binds only the approved service account and namespace.
|
||||
- The External Secrets target is confirmed as
|
||||
`llm-connect-provider-secrets` or updated with the approved alternative.
|
||||
- No direct human or agent read path is activated unless separately approved.
|
||||
|
||||
**2026-06-30:** Confirmed the proposed `llm-connect` service account does not
|
||||
exist and the current `llm-connect` Deployment uses the namespace `default`
|
||||
service account. Updated `CCR-2026-0003` to the approved platform ESO pattern:
|
||||
OpenBao Kubernetes auth role `external-secrets-activity-core` bound to
|
||||
`external-secrets/external-secrets`. Added
|
||||
`argocd/platform-addons/openbao-secretstore/openbao-activity-core.clustersecretstore.yaml`,
|
||||
limited to the `activity-core` namespace, and Make target
|
||||
`openbao-configure-external-secrets-activity-core` for the matching OpenBao
|
||||
role/policy apply. `kubectl kustomize argocd/platform-addons/openbao-secretstore`
|
||||
renders both the existing issue-core store and the new activity-core store.
|
||||
`credential-change.py applier-dry-run CCR-2026-0003` now blocks only because the
|
||||
CCR is still `proposed`.
|
||||
|
||||
## T03 - Apply or confirm least-privilege OpenBao metadata
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0010-T03
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "42796ef5-c4a0-45a7-ae41-0ebdeccdb01d"
|
||||
```
|
||||
|
||||
Apply the read policy and Kubernetes auth role only after review and binding
|
||||
confirmation.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `openbao/policies/workload-kv-read-llm-connect-provider-secrets.hcl` grants
|
||||
only read access to the exact KV-v2 data and metadata paths.
|
||||
- The Kubernetes auth role attaches only
|
||||
`workload-kv-read-llm-connect-provider-secrets`.
|
||||
- Live apply uses an approved operator path or the delegated applier from
|
||||
`RAILIANCE-WP-0008`; broad `platform-admin` handoffs are avoided where
|
||||
possible.
|
||||
- Apply evidence records only policy name, role name, actor, timestamp, and
|
||||
non-secret OpenBao request ids.
|
||||
|
||||
## T04 - Provision the provider key through approved custody
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0010-T04
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "651f6ec8-b7d6-45e6-9fef-08646ff737c2"
|
||||
```
|
||||
|
||||
Have an approved operator create or confirm the OpenBao KV entry and
|
||||
`OPENROUTER_API_KEY` field.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The path exists at
|
||||
`platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets`.
|
||||
- Field `OPENROUTER_API_KEY` is present.
|
||||
- The value is entered directly through OpenBao/operator custody, never through
|
||||
Git, State Hub, chat, prompts, workplans, or shell history.
|
||||
- Non-secret evidence records only path, field name, actor, timestamp, and
|
||||
verification result.
|
||||
|
||||
## T05 - Verify positive and negative access
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0010-T05
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "d538cfc0-bf68-4889-a5b3-ed94c1679856"
|
||||
```
|
||||
|
||||
Prove that the approved llm-connect identity can consume the lane and that
|
||||
other identities cannot.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Positive verification shows the approved llm-connect service account can read
|
||||
`OPENROUTER_API_KEY` through OpenBao or External Secrets without printing the
|
||||
value.
|
||||
- Negative verification shows an unapproved service account cannot read the
|
||||
path.
|
||||
- OpenBao audit evidence exists for allowed and denied attempts, recorded only
|
||||
as non-secret request ids or timestamps.
|
||||
- Verification includes the External Secrets delivery path because that is the
|
||||
intended production consumer interface.
|
||||
|
||||
## T06 - Activate ops-warden catalog front door
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0010-T06
|
||||
status: wait
|
||||
priority: medium
|
||||
state_hub_task_id: "376de3fe-ef9c-4b57-b238-1ba21ac8bb1c"
|
||||
```
|
||||
|
||||
Send ops-warden the non-secret pointers needed to promote the agreed
|
||||
OpenRouter/llm-connect selector from draft to active.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The handoff includes only catalog id, mount, path, field name, auth role,
|
||||
policy name/path, External Secrets target, optional flex-auth ref, and
|
||||
runbook/workplan links.
|
||||
- ops-warden confirms the catalog entry has no unresolved placeholders.
|
||||
- ops-warden confirms it proxies access as the caller and holds no provider key
|
||||
value.
|
||||
- The CCR front-door readiness becomes active/resolvable only after positive
|
||||
and negative verification.
|
||||
|
||||
## T07 - Record lifecycle operations
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0010-T07
|
||||
status: wait
|
||||
priority: medium
|
||||
state_hub_task_id: "130155a5-e0f9-49f8-ba27-b48098746f02"
|
||||
```
|
||||
|
||||
Document how to deactivate, rotate, and respond to compromise for this lane.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Deactivation disables the ops-warden front door and removes or detaches the
|
||||
auth role policy without deleting required audit evidence.
|
||||
- Rotation keeps the provider key inside OpenBao/operator custody and records
|
||||
only non-secret evidence.
|
||||
- Compromise response names the immediate front-door disable, provider-key
|
||||
rotation, and follow-up incident workplan path.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- `CCR-2026-0003` is reviewed, approved, applied, verified, and active.
|
||||
- llm-connect can consume `OPENROUTER_API_KEY` through the approved platform
|
||||
interface.
|
||||
- Unauthorized access is denied and recorded.
|
||||
- ops-warden can resolve the agreed OpenRouter/llm-connect selector without
|
||||
storing the value.
|
||||
- No secret values appear in Git, State Hub, chat, prompts, logs, or workplans.
|
||||
Reference in New Issue
Block a user