feat: add auth-capability lanes and pilot closeout

Add the warden-sign auth-capability lane, AppRole handoff, verification guards, docs, and tests.

Point the whynot-design pilot at the canonical decision and add the real publish closeout preflight/runbook.
This commit is contained in:
tegwick 2026-06-29 16:58:16 +02:00
parent a621fbaffd
commit 6382139890
27 changed files with 1455 additions and 107 deletions

View file

@ -0,0 +1,16 @@
# Offline fallback mirror for the canonical State Hub decision.
#
# Normal production use should resolve this UUID from State Hub with source: hub.
# This file exists only so throwaway offline demos can run when
# SECRETS_ENGINE_HUB_URL is empty or State Hub is unreachable. NON-SECRET:
# contains no token value.
id: e6381a56-6b04-4fd5-b2de-f3ef59cde888
title: "Review CCR-2026-0001 corrected whynot-design npm publish token lane"
status: resolved
superseded_by: null
decided_by: "human"
review_url: "http://127.0.0.1:8000/decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888"
rationale: >-
APPROVE: We fixed the path using coulomb as the org/tenant. Approval covers
establishing the whynot-design npm publish lane metadata; it does not expose or
store the package token value.

View file

@ -1,15 +0,0 @@
# Local approval fixture for the whynot-design npm publish lane.
#
# Stand-in for the canonical State Hub decision object, which does not yet exist
# (see PRD open question). When a real decision is recorded in State Hub, set the
# catalog's approval.decision_ref to that decision's UUID and this fixture becomes
# unnecessary. NON-SECRET: contains no token value.
id: whynot-design-npm-publish
title: "Approve whynot-design npm publish lane (prod)"
status: resolved # resolved => approved for apply
superseded_by: null
decided_by: "Tegwick"
review_url: ""
rationale: >-
Pilot lane for secrets-engine MVP. Production npm automation token for
whynot-design, delivered exec-time only via temporary npm config.

View file

@ -50,6 +50,8 @@ SECRETS_ENGINE_HUB_URL="" bash scripts/demo-e2e.sh
- CLI reference: [docs/cli.md](docs/cli.md)
- Publication-scope policy (maturity → token scope): [docs/publication-scope-policy.md](docs/publication-scope-policy.md)
- Stage roles & bootstrap tokens: [docs/openbao-stage-roles.md](docs/openbao-stage-roles.md)
- warden-sign auth-capability lane: [docs/warden-sign-auth-capability.md](docs/warden-sign-auth-capability.md)
- whynot-design real publish closeout: [docs/whynot-design-real-publish-closeout.md](docs/whynot-design-real-publish-closeout.md)
- ops-warden routing contract: [docs/ops-warden-routing-contract.md](docs/ops-warden-routing-contract.md)
- Hardening backlog (exit bootstrap mode): [docs/hardening-backlog.md](docs/hardening-backlog.md)

86
catalog/warden-sign.yaml Normal file
View file

@ -0,0 +1,86 @@
# warden-sign auth-capability lane.
# NON-SECRET: this file contains only OpenBao policy/AppRole metadata and
# operational pointers. AppRole secret_id values and tokens must never appear in
# Git, State Hub, chat, prompts, workplans, or normal logs.
id: warden-sign
kind: auth-capability
org: netkingdom
repo: ops-warden
stage: prod
description: >-
Scoped OpenBao AppRole used by ops-warden production smoke tests to call the
SSH secrets engine signing API over HTTP. This lane grants update only on the
exact SSH signing roles needed for FLEX-WP-0007 T4; it stores no KV value.
# Existing production OpenBao SSH secrets-engine mount and logical operation.
mount: ssh
path: sign
consumers:
- name: ops-warden-policy-smoke
auth: approle
claim: "agent:agt-state-hub-bridge"
purpose: >-
Run warden sign and the flex-auth policy-gate production smoke without
giving ops-warden standing custody of an OpenBao token.
allowed_actors:
- agt-state-hub-bridge
- agt-codex-interhub-bootstrap
- adm-example
- atm-backup-daily
# The deliverable is a freshly minted AppRole secret_id plus role_id handed to
# the operator out-of-band. There is no KV value and no from-file provisioning.
delivery_modes:
- approle-login
approval:
model: decision
decision_ref: "SECRETS-WP-0004"
notes: >-
Production auth-capability lane. Live apply and handoff require an approved
decision/workplan plus a mode-0600 bootstrap token outside any repo.
verification:
positive: "the warden-sign AppRole token has update on every allowlisted ssh/sign role"
negative: "the warden-sign AppRole token lacks update on ssh/admin/sys/auth/identity probes"
auth_capability:
policy_name: warden-sign
role_name: warden-sign
token_ttl: 15m
token_max_ttl: 15m
token_num_uses: 0
secret_id_ttl: 30m
secret_id_num_uses: 1
allowed_paths:
- path: ssh/sign/agt-role
capabilities: [update]
- path: ssh/sign/adm-role
capabilities: [update]
- path: ssh/sign/atm-role
capabilities: [update]
denied_probe_paths:
- ssh/sign/unlisted-role
- ssh/roles/agt-role
- ssh/config/ca
- sys/policies/acl/warden-sign
- auth/token/create
- identity/entity/id
rotation:
expectation: >-
Mint a fresh single-use secret_id for each attended handoff; revoke the
resulting token by accessor after the FLEX-WP-0007 T4 smoke or when the 15m
TTL expires, whichever comes first.
ttl: 15m
deactivation:
expectation: >-
Delete the warden-sign AppRole and ACL policy, revoke outstanding token
accessors, and record non-secret evidence.
audit:
evidence: >-
policy name, AppRole name, mount, allowed paths, token TTL, role_id file path,
secret_id file path, timestamp, result — no token value or secret_id.

View file

@ -55,7 +55,7 @@ delivery_config:
# Privileged actions on this lane require an approved decision/CCR.
approval:
model: decision
decision_ref: "whynot-design-npm-publish" # State Hub decision/CCR id or slug
decision_ref: "e6381a56-6b04-4fd5-b2de-f3ef59cde888" # Canonical State Hub decision UUID; local fixture is offline fallback
notes: "Production lane: apply requires an approved decision."
# Verification expectations (no value is ever printed).

View file

@ -42,7 +42,8 @@ secrets-engine decision inspect <decision-or-ccr-id>
secrets-engine plan <ref> --stage <build|test|prod>
secrets-engine apply <ref> --stage <stage> [--dry-run] [--bootstrap-token-file F]
secrets-engine provision <catalog-id> --stage <stage> --field NAME (--from-file F | --generate)
secrets-engine verify <catalog-id> --field NAME [--positive] [--negative]
secrets-engine verify <catalog-id> [--field NAME] [--positive] [--negative]
secrets-engine handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
secrets-engine exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
secrets-engine policy publication <catalog-id>
secrets-engine route <catalog-id> [--json]
@ -55,6 +56,13 @@ var the token is injected under, per the netkingdom publication-scope policy
`<ref>` is a catalog id or a decision/CCR ref (matched against
`approval.decision_ref`). `plan` and `apply --dry-run` never mutate OpenBao.
For decision-gated lanes they may render with `decision: <none>` when the
approval object is not reachable; non-dry-run `apply` remains decision-gated.
`handoff` is for `kind: auth-capability` lanes such as `warden-sign`. It mints a
fresh AppRole `secret_id` and writes `role_id` plus `secret_id` to caller-chosen
mode-0600 files outside Git worktrees. It never prints the `secret_id`; use the
resulting files only for attended out-of-band delivery.
## Exit codes
@ -92,6 +100,32 @@ scratch package. Proves npm in the child resolves its auth token from the temp
step — while the parent shell never holds the token. For a real publish,
provision a real npm automation token the same way and drop `--dry-run`.
## Auth-capability: warden-sign
```bash
# Preview the non-KV policy/AppRole lane; no mutation.
SECRETS_ENGINE_HUB_URL="" secrets-engine apply warden-sign --stage prod --dry-run
# Live apply and handoff require an approved decision and an out-of-repo
# bootstrap token file. Values are written to files, not stdout.
BAO_ADDR=https://bao.coulomb.social \
secrets-engine handoff warden-sign --stage prod \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-warden-sign.token \
--role-id-file ~/.secrets-engine/handoff/warden-sign.role_id \
--secret-id-file ~/.secrets-engine/handoff/warden-sign.secret_id
```
See [warden-sign-auth-capability.md](warden-sign-auth-capability.md) for the full
runbook and State Hub pointer payload.
## Pilot closeout: whynot-design real publish
```bash
scripts/whynot-real-publish-preflight.sh
```
The real publish path is documented in [whynot-design-real-publish-closeout.md](whynot-design-real-publish-closeout.md).
## Pilot: whynot-design npm publish
```bash

View file

@ -14,8 +14,19 @@ Every minted bootstrap token has a revocation task. Track each here:
| Token file | Stage | Minted | TTL | Revoked? |
| --- | --- | --- | --- | --- |
| `~/.secrets-engine/bootstrap/prod.token` | prod | (n/a — demo uses dev server) | 1h | n/a |
| `~/.secrets-engine/bootstrap/prod-warden-sign.token` | prod | pending SECRETS-WP-0004 live apply | 1h | pending |
| `~/.secrets-engine/bootstrap/prod-whynot-design.token` | prod | pending SECRETS-WP-0003 live apply/provision | 1h | pending |
Revoke: `bao token revoke -accessor <accessor>` then `shred -u <file>`.
For SECRETS-WP-0004, revoke the scoped `warden-sign` token/accessor after the
FLEX-WP-0007 T4 smoke or when its `15m` TTL expires, and remove the handoff
files under `~/.secrets-engine/handoff/`.
## H0a — Revoke pilot workload tokens
| Credential | Custody path | Minted | Revocation task | Revoked? |
| --- | --- | --- | --- | --- |
| whynot-design Gitea bot npm/package token | OpenBao lane `whynot-design-npm-publish`, source handoff file `~/.secrets-engine/handoff/whynot-design-npm.token` | pending SECRETS-WP-0003 bot/token gate | Revoke or rotate the bot token in Gitea, delete the source handoff file with `shred -u`, and record non-secret package/version evidence after publish | pending |
## H1 — Replace bootstrap token files with OIDC / service auth

View file

@ -8,7 +8,7 @@ explicit capability set.
| --- | --- | --- | --- | --- |
| `secrets-engine-build` | `policies/secrets-engine-build.hcl` | `secret/.../build/` | manage build metadata + generated test values; own `se-build-*` policies/roles | touch test/prod, `sys/*`, `auth/token/*`, `identity/*`, act as root |
| `secrets-engine-test` | `policies/secrets-engine-test.hcl` | `secret/.../test/` | manage test metadata + values; run positive/negative checks; own `se-test-*` | touch build/prod, `sys/*`, `auth/token/*`, `identity/*`, act as root |
| `secrets-engine-prod` | `policies/secrets-engine-prod.hcl` | owner-scoped prod lanes | apply approved prod ACL policies + approle roles; write approved values; own `se-prod-*` | reach build/test, edit the stage roles themselves, admin `sys/auth`, `sys/mounts`, `identity/*`, `auth/token/*`, act as root |
| `secrets-engine-prod` | `policies/secrets-engine-prod.hcl` | owner-scoped prod lanes | apply approved prod ACL policies + approle roles; write approved values; own `se-prod-*`; exact SECRETS-WP-0004 `warden-sign` policy/AppRole exception | reach build/test, edit the stage roles themselves, admin `sys/auth`, `sys/mounts`, `identity/*`, `auth/token/*`, act as root |
The **product requirement** is the stage *distinction* and the *denials*, not the
exact policy names — those may evolve as info-tech-canon hardens.
@ -20,8 +20,11 @@ exact policy names — those may evolve as info-tech-canon hardens.
2. **In secrets-engine**`roles.assert_path_in_stage()` and
`roles.assert_policy_safe()` reject any *plan* that would touch another
stage's prefix, use a wildcard, name itself like an admin policy, or carry a
capability outside `create/read/update/delete/list`. A bad plan fails closed
**before** any OpenBao call.
capability outside `create/read/update/delete/list`. Auth-capability lanes use
`roles.assert_auth_capability_safe()`, which refuses broad `ssh/*`, wildcard,
`sys/`, `auth/token/`, `identity/`, root-like, or non-`update` grants and, for
the SSH mount, renders only exact `ssh/sign/<role>` paths. A bad plan fails
closed **before** any OpenBao call.
Run the negative checks:

View file

@ -1,9 +1,10 @@
# ops-warden → secrets-engine Routing Contract
ops-warden issues **SSH certificates only**. Every other credential need (API
keys, provider tokens, DB passwords, npm publish tokens) routes to
**secrets-engine**, which is OpenBao-backed. ops-warden must never request, hold,
cache, or vend a raw secret value. A route result is a **pointer**, not a key.
keys, provider tokens, DB passwords, npm publish tokens, and OpenBao API token
lanes) routes to **secrets-engine**, which is OpenBao-backed. ops-warden must
never request, hold, cache, or vend a raw secret value. A route result is a
**pointer**, not a key.
## What ops-warden calls
@ -16,10 +17,11 @@ secrets-engine route <catalog-id> --json
```json
{
"catalog_id": "whynot-design-npm-publish",
"kind": "kv",
"owner": "coulomb/whynot-design",
"stage": "prod",
"decision_status": "resolved",
"decision_ref": "whynot-design-npm-publish",
"decision_ref": "e6381a56-6b04-4fd5-b2de-f3ef59cde888",
"review_url": "http://127.0.0.1:8000/decisions/<id>",
"metadata_applied": true,
"value_present": true,
@ -32,16 +34,18 @@ secrets-engine route <catalog-id> --json
| Field | Meaning |
| --- | --- |
| `decision_status` | `resolved`/`approved` => approved; `missing`/`pending` => not yet |
| `kind` | `kv` for stored-value lanes, `auth-capability` for policy/AppRole capability lanes |
| `metadata_applied` | OpenBao ACL policy + approle exist for the lane |
| `value_present` | the secret value has been provisioned (boolean only — value never read) |
| `ready` | approved **and** applied **and** provisioned |
| `value_present` | for `kv`, the secret value has been provisioned (boolean only — value never read); for `auth-capability`, there is no KV value and this is true once metadata is handoff-ready |
| `ready` | approved **and** applied **and** provisioned or handoff-ready |
| `next_command` | the single safe command the caller should run next |
| `missing` | the one human/provisioning step still outstanding |
## Guarantees
- **No value crosses this boundary.** `route` reports a boolean `value_present`,
derived from a metadata/presence check — it never reads the secret.
derived from a metadata/presence check for KV lanes or from policy/AppRole
readiness for auth-capability lanes — it never reads or mints a secret.
- **Actionable when not ready.** If a lane is unapproved, unapplied, or
unprovisioned, `next_command` + `missing` tell the caller exactly what to do.
- **Idempotent / read-only.** `route` performs no mutation.
@ -55,8 +59,36 @@ secrets-engine route <catalog-id> --json
4. Once `ready=true`, the workload runs
`secrets-engine exec --catalog whynot-design-npm-publish -- npm publish`.
Safe whynot-design pointer payload for ops-warden:
```json
{
"catalog_id": "whynot-design-npm-publish",
"kind": "kv",
"decision_ref": "e6381a56-6b04-4fd5-b2de-f3ef59cde888",
"owner": "coulomb/whynot-design",
"stage": "prod",
"next_command": "secrets-engine exec --catalog whynot-design-npm-publish -- npm publish"
}
```
## warden-sign auth-capability flow
1. ops-warden needs a scoped `VAULT_TOKEN` for the FLEX-WP-0007 T4 smoke.
2. ops-warden runs `secrets-engine route warden-sign --json`.
3. If `ready=false`, it surfaces `missing` + `next_command` to the human.
4. Once `ready=true`, `next_command` points to `secrets-engine handoff ...`; the
operator receives `role_id` and `secret_id` out-of-band and performs AppRole
login on CoulombCore.
5. State Hub receives only non-secret pointers: addr, mount, policy, AppRole,
TTLs, allowed paths, and status. It must not receive `role_id`, `secret_id`,
`VAULT_TOKEN`, token accessor, or raw smoke output containing token material.
See [warden-sign-auth-capability.md](warden-sign-auth-capability.md).
## Anti-patterns (forbidden)
- ops-warden `POST /messages/` asking for `NPM_TOKEN` / `OPENROUTER_API_KEY`.
- ops-warden `POST /messages/` asking for `NPM_TOKEN` / `OPENROUTER_API_KEY` /
`VAULT_TOKEN`.
- Caching `value_present` as if it were the value.
- Inventing `warden secret` / `warden bao` — they do not exist.

View file

@ -0,0 +1,142 @@
# warden-sign auth-capability lane
`warden-sign` is a non-KV secrets-engine lane for SECRETS-WP-0004. It creates an
OpenBao ACL policy plus AppRole that lets ops-warden run `warden sign` over the
HTTP API for the FLEX-WP-0007 T4 production smoke.
No token value, AppRole `secret_id`, or SSH private material belongs in Git,
State Hub, chat, prompts, workplans, or normal logs. State Hub gets pointers
only; the operator receives `role_id` and `secret_id` out-of-band.
## Non-secret pointers
| Pointer | Value |
| --- | --- |
| OpenBao address | `https://bao.coulomb.social` |
| SSH mount | `ssh` |
| Policy | `warden-sign` |
| AppRole | `warden-sign` |
| Token TTL | `15m` |
| Secret ID TTL | `30m` |
| Secret ID uses | `1` |
| Allowed paths | `ssh/sign/agt-role`, `ssh/sign/adm-role`, `ssh/sign/atm-role` |
| Denied probes | `ssh/sign/unlisted-role`, `ssh/roles/agt-role`, `ssh/config/ca`, `sys/policies/acl/warden-sign`, `auth/token/create`, `identity/entity/id` |
## Plan and apply
Preview without mutation:
```bash
SECRETS_ENGINE_HUB_URL="" secrets-engine apply warden-sign --stage prod --dry-run
```
Live apply requires an approved decision/workplan and a short-lived bootstrap
token file outside any repo:
```bash
BAO_ADDR=https://bao.coulomb.social \
secrets-engine apply warden-sign --stage prod \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-warden-sign.token
```
The bootstrap token file must be mode `0600`, revocable, and tracked in
[hardening-backlog.md](hardening-backlog.md) H0 until revoked and shredded.
## Handoff
Mint a fresh single-use AppRole `secret_id` and write both handoff values to
mode-0600 files outside any Git worktree:
```bash
install -m 700 -d ~/.secrets-engine/handoff
BAO_ADDR=https://bao.coulomb.social \
secrets-engine handoff warden-sign --stage prod \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-warden-sign.token \
--role-id-file ~/.secrets-engine/handoff/warden-sign.role_id \
--secret-id-file ~/.secrets-engine/handoff/warden-sign.secret_id
```
The command prints only file paths and TTL metadata. It does not print the
`secret_id`.
On CoulombCore, the operator can authenticate without the `bao` CLI by posting
the file contents to AppRole login and capturing the returned token into process
state only:
```bash
ROLE_ID="$(cat ~/.secrets-engine/handoff/warden-sign.role_id)"
SECRET_ID="$(cat ~/.secrets-engine/handoff/warden-sign.secret_id)"
VAULT_TOKEN="$(
printf '{"role_id":"%s","secret_id":"%s"}' "${ROLE_ID}" "${SECRET_ID}" \
| curl -fsS \
--request POST \
--header 'Content-Type: application/json' \
--data @- \
https://bao.coulomb.social/v1/auth/approle/login \
| jq -r '.auth.client_token'
)"
unset ROLE_ID SECRET_ID
```
Run the smoke with the scoped token:
```bash
FLEX_AUTH_EXTERNAL=1 SMOKE_VAULT=1 VAULT_TOKEN="${VAULT_TOKEN}" \
~/ops-warden/scripts/policy_gate_production_smoke.sh
```
## Verification
After live apply and handoff, verify the scoped AppRole token shape without
printing the token:
```bash
BAO_ADDR=https://bao.coulomb.social \
secrets-engine verify warden-sign --positive --negative \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-warden-sign.token
```
Positive verification checks `update` on the three allowlisted `ssh/sign` paths.
Negative verification checks the denial probes lack `update`, `sudo`, and `root`.
## State Hub pointer payload
Post only non-secret pointers, for example:
```json
{
"catalog_id": "warden-sign",
"kind": "auth-capability",
"addr": "https://bao.coulomb.social",
"mount": "ssh",
"policy": "warden-sign",
"approle": "warden-sign",
"token_ttl": "15m",
"secret_id_ttl": "30m",
"secret_id_num_uses": 1,
"allowed_paths": ["ssh/sign/agt-role", "ssh/sign/adm-role", "ssh/sign/atm-role"],
"status": "applied-and-handoff-ready"
}
```
Do not include `role_id`, `secret_id`, `VAULT_TOKEN`, token accessors, raw curl
responses, or smoke logs containing token material.
## Revocation and cleanup
After the smoke, revoke the scoped token by accessor if available, then remove
handoff files:
```bash
bao token revoke -accessor <accessor>
shred -u ~/.secrets-engine/handoff/warden-sign.role_id
shred -u ~/.secrets-engine/handoff/warden-sign.secret_id
```
To retire the lane metadata itself:
```bash
BAO_ADDR=https://bao.coulomb.social \
secrets-engine revoke warden-sign \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-warden-sign.token
```

View file

@ -0,0 +1,114 @@
# whynot-design real publish closeout
This runbook is the SECRETS-WP-0003 closeout path for turning the
`whynot-design-npm-publish` pilot from a dry-run/demo lane into a real production
`@whynot/design` publish through `secrets-engine exec`.
No raw npm token, OpenBao token, token accessor, `NPM_AUTH_TOKEN`, package-token
value, or private credential material may be written to Git, State Hub, chat,
prompts, workplans, or normal logs.
## Current non-secret pointers
| Pointer | Value |
| --- | --- |
| Catalog id | `whynot-design-npm-publish` |
| Canonical decision | `e6381a56-6b04-4fd5-b2de-f3ef59cde888` |
| Org/repo | `coulomb/whynot-design` |
| npm package | `@whynot/design` |
| npm registry | `https://gitea.coulomb.social/api/packages/coulomb/npm/` |
| Catalog mount/path | `secret/coulomb/whynot-design/npm/publish` |
| Catalog field | `npm_token` |
| Injected env var | `NPM_AUTH_TOKEN` |
The decision UUID above is resolved from State Hub in normal operation. A
UUID-named local fixture exists only as an offline fallback for throwaway demos.
## Source-side preflight
From this repo, with State Hub reachable:
```bash
scripts/whynot-real-publish-preflight.sh
```
Expected source-side result:
- `decision inspect e6381a56-6b04-4fd5-b2de-f3ef59cde888` shows `source: hub` and `APPROVED`;
- `policy publication whynot-design-npm-publish` resolves to the repo-clamped
`NPM_AUTH_TOKEN` path while netkingdom remains `maturity-build`;
- `route whynot-design-npm-publish --json` returns a pointer only. If OpenBao is
not reachable or the value is not provisioned, `ready=false` is acceptable and
must be handled by the operator steps below.
## Operator live gates
1. Create or confirm a dedicated Gitea bot account such as `se-whynot-design`.
Record only non-secret evidence: username, owning org/repo, package scope,
creation timestamp, and who confirmed it.
2. Confirm the bot can publish `@whynot/design` to the coulomb registry and cannot
publish outside the intended org/scope. Record only pass/fail and package names.
3. Mint the Gitea npm/package token for that bot and place it in a mode-0600 file
outside any Git worktree. Do not print the value.
4. Apply/provision through secrets-engine using approved OpenBao authority:
```bash
BAO_ADDR=https://bao.coulomb.social \
secrets-engine apply whynot-design-npm-publish --stage prod \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-whynot-design.token
BAO_ADDR=https://bao.coulomb.social \
secrets-engine provision whynot-design-npm-publish --stage prod \
--field npm_token \
--from-file ~/.secrets-engine/handoff/whynot-design-npm.token \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-whynot-design.token
```
5. Verify without disclosing the value:
```bash
BAO_ADDR=https://bao.coulomb.social \
secrets-engine verify whynot-design-npm-publish \
--field npm_token --positive --negative \
--bootstrap-token-file ~/.secrets-engine/bootstrap/prod-whynot-design.token
```
6. In the `whynot-design` repo, coordinate a real version bump. Run the publish
through child-only injection:
```bash
BAO_ADDR=https://bao.coulomb.social \
secrets-engine exec --catalog whynot-design-npm-publish -- npm publish
```
7. Confirm the published version appears in the coulomb Gitea npm registry.
Record package name, version, registry URL, timestamp, and result only.
## ops-warden handoff
ops-warden should route npm publish credential requests here and return a pointer,
not a value:
```bash
secrets-engine route whynot-design-npm-publish --json
```
Safe pointer payload:
```json
{
"catalog_id": "whynot-design-npm-publish",
"kind": "kv",
"decision_ref": "e6381a56-6b04-4fd5-b2de-f3ef59cde888",
"owner": "coulomb/whynot-design",
"stage": "prod",
"next_command": "secrets-engine exec --catalog whynot-design-npm-publish -- npm publish"
}
```
## Revocation and cleanup
Track the OpenBao bootstrap token and the Gitea bot package token in
[hardening-backlog.md](hardening-backlog.md). After publish verification, remove
any handoff/source token files with `shred -u` and revoke or rotate the Gitea bot
token according to the recorded lifecycle task.

View file

@ -26,6 +26,11 @@ path "sys/policies/acl/se-prod-*" {
capabilities = ["create", "read", "update", "delete"]
}
# Exact exception for SECRETS-WP-0004 auth-capability lane.
path "sys/policies/acl/warden-sign" {
capabilities = ["create", "read", "update", "delete"]
}
# Manage prod approle roles only.
path "auth/approle/role/se-prod-*" {
capabilities = ["create", "read", "update", "delete"]
@ -37,6 +42,17 @@ path "auth/approle/role/se-prod-*/secret-id" {
capabilities = ["create", "update"]
}
# Exact exception for SECRETS-WP-0004 auth-capability lane.
path "auth/approle/role/warden-sign" {
capabilities = ["create", "read", "update", "delete"]
}
path "auth/approle/role/warden-sign/role-id" {
capabilities = ["read"]
}
path "auth/approle/role/warden-sign/secret-id" {
capabilities = ["create", "update"]
}
# Hard denials production appliers must not become broad admins.
path "secret/data/build/*" { capabilities = ["deny"] }
path "secret/data/test/*" { capabilities = ["deny"] }

View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Non-secret source-side preflight for SECRETS-WP-0003.
set -euo pipefail
DECISION_ID="e6381a56-6b04-4fd5-b2de-f3ef59cde888"
CATALOG_ID="whynot-design-npm-publish"
if [[ -d .venv ]]; then
# shellcheck disable=SC1091
source .venv/bin/activate
fi
if command -v secrets-engine >/dev/null 2>&1; then
SECRETS_ENGINE=(secrets-engine)
else
export PYTHONPATH="src${PYTHONPATH:+:$PYTHONPATH}"
SECRETS_ENGINE=(python3 -m secrets_engine.cli)
fi
export SECRETS_ENGINE_EVIDENCE="${SECRETS_ENGINE_EVIDENCE:-/tmp/secrets-engine-whynot-preflight-evidence}"
echo "### decision"
"${SECRETS_ENGINE[@]}" decision inspect "$DECISION_ID"
echo
echo "### publication policy"
"${SECRETS_ENGINE[@]}" policy publication "$CATALOG_ID"
echo
echo "### route pointer"
"${SECRETS_ENGINE[@]}" route "$CATALOG_ID" --json

View file

@ -35,12 +35,16 @@ def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str
applied: list[str] = []
skipped: list[str] = []
# 1. KV mount.
if client.kv_mount_exists(entry.mount):
skipped.append(f"kv-mount {entry.mount} (already present)")
# 1. KV mount. Auth-capability lanes grant operational access on an existing
# mount (for example ssh/sign/<role>) and never create or store KV values.
if entry.stores_kv_value():
if client.kv_mount_exists(entry.mount):
skipped.append(f"kv-mount {entry.mount} (already present)")
else:
client.ensure_kv_mount(entry.mount)
applied.append(f"kv-mount {entry.mount}")
else:
client.ensure_kv_mount(entry.mount)
applied.append(f"kv-mount {entry.mount}")
skipped.append(f"kv-mount {entry.mount} (not applicable for {entry.kind})")
# 2. Consumer ACL policy (write only if changed).
current = client.read_policy(plan.policy_name)
@ -52,7 +56,18 @@ def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str
# 3. Consumer approle bound to that policy.
client.ensure_approle_enabled()
client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl)
if entry.kind == "auth-capability":
client.write_approle(
plan.role_name,
[plan.policy_name],
ttl=entry.token_ttl,
max_ttl=entry.token_max_ttl,
secret_id_ttl=entry.secret_id_ttl,
secret_id_num_uses=entry.secret_id_num_uses,
token_num_uses=entry.token_num_uses,
)
else:
client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl)
applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]")
return ApplyResult(applied=applied, skipped=skipped)

View file

@ -16,18 +16,26 @@ import yaml
from secrets_engine.errors import CatalogError
from secrets_engine.redact import looks_secret
VALID_KINDS = ("kv", "auth-capability")
VALID_STAGES = ("build", "test", "prod")
VALID_DELIVERY_MODES = ("exec-env", "exec-file", "npm-config", "wrapped", "read-check")
VALID_DELIVERY_MODES = (
"exec-env",
"exec-file",
"npm-config",
"wrapped",
"read-check",
"approle-login",
)
VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
REQUIRED_FIELDS = (
"id",
"kind",
"org",
"repo",
"stage",
"mount",
"path",
"fields",
"consumers",
"delivery_modes",
"approval",
@ -41,6 +49,7 @@ REQUIRED_FIELDS = (
@dataclass(frozen=True)
class CatalogEntry:
id: str
kind: str
# Gitea coordinates, kept explicit to avoid the overloaded word "project".
# org = the Gitea organisation (e.g. "coulomb")
# repo = the Gitea repository / product (e.g. "whynot-design")
@ -58,6 +67,7 @@ class CatalogEntry:
deactivation: dict[str, Any]
audit: dict[str, Any]
delivery_config: dict[str, Any] = field(default_factory=dict)
auth_capability: dict[str, Any] = field(default_factory=dict)
description: str = ""
raw: dict[str, Any] = field(default_factory=dict)
@ -83,22 +93,75 @@ class CatalogEntry:
@property
def policy_name(self) -> str:
if self.kind == "auth-capability":
return self.auth_capability.get("policy_name") or self.id
return f"se-{self.stage}-{self.id}"
@property
def role_name(self) -> str:
if self.kind == "auth-capability":
return self.auth_capability.get("role_name") or self.id
return f"se-{self.stage}-{self.id}"
@property
def auth_allowed_paths(self) -> dict[str, list[str]]:
"""Allowed OpenBao paths/capabilities for an auth-capability lane."""
paths: dict[str, list[str]] = {}
for item in self.auth_capability.get("allowed_paths", []):
paths[item["path"]] = list(item["capabilities"])
return paths
@property
def auth_denied_probe_paths(self) -> list[str]:
"""Non-secret denial probes proving the lane did not get broader."""
return list(self.auth_capability.get("denied_probe_paths", []))
@property
def token_ttl(self) -> str:
return str(self.auth_capability.get("token_ttl", self.rotation.get("ttl", "30m")))
@property
def token_max_ttl(self) -> str:
return str(self.auth_capability.get("token_max_ttl", self.token_ttl))
@property
def secret_id_ttl(self) -> str:
return str(self.auth_capability.get("secret_id_ttl", self.token_ttl))
@property
def secret_id_num_uses(self) -> int:
return int(self.auth_capability.get("secret_id_num_uses", 0))
@property
def token_num_uses(self) -> int:
return int(self.auth_capability.get("token_num_uses", 0))
def approval_required(self) -> bool:
return self.approval.get("model") != "bootstrap-only"
def stores_kv_value(self) -> bool:
return self.kind == "kv"
def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> CatalogEntry:
"""Validate a raw mapping and return a CatalogEntry, or raise CatalogError."""
if not isinstance(data, dict):
raise CatalogError(f"{source}: catalog entry must be a mapping")
data = dict(data)
data.setdefault("kind", "kv")
if data["kind"] not in VALID_KINDS:
raise CatalogError(
f"{source}: kind '{data['kind']}' invalid; must be one of {VALID_KINDS}"
)
if data["kind"] == "auth-capability":
data.setdefault("fields", [])
else:
data.setdefault("auth_capability", {})
missing = [k for k in REQUIRED_FIELDS if k not in data or data[k] in (None, "", [], {})]
if data["kind"] == "kv" and not data.get("fields"):
missing.append("fields")
if missing:
raise CatalogError(f"{source}: missing required fields: {', '.join(missing)}")
@ -120,6 +183,8 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
fields = data["fields"]
if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
raise CatalogError(f"{source}: fields must be a list of strings")
if data["kind"] == "auth-capability" and fields:
raise CatalogError(f"{source}: auth-capability lanes must not declare KV fields")
consumers = data["consumers"]
if not isinstance(consumers, list) or not consumers:
@ -153,6 +218,35 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
f"{source}: delivery_config.npm.registry must be an http(s) URL"
)
if data["kind"] == "auth-capability":
cfg = data.get("auth_capability")
if not isinstance(cfg, dict) or not cfg.get("allowed_paths"):
raise CatalogError(
f"{source}: auth-capability lanes require auth_capability.allowed_paths"
)
if "approle-login" not in modes:
raise CatalogError(
f"{source}: auth-capability lanes require approle-login delivery mode"
)
allowed = cfg["allowed_paths"]
if not isinstance(allowed, list) or not allowed:
raise CatalogError(f"{source}: auth_capability.allowed_paths must be a list")
for item in allowed:
if not isinstance(item, dict) or "path" not in item or "capabilities" not in item:
raise CatalogError(
f"{source}: each allowed path needs path and capabilities"
)
caps = item["capabilities"]
if not isinstance(caps, list) or not all(isinstance(c, str) for c in caps):
raise CatalogError(
f"{source}: auth_capability.allowed_paths capabilities must be strings"
)
denied = cfg.get("denied_probe_paths", [])
if not isinstance(denied, list) or not all(isinstance(p, str) for p in denied):
raise CatalogError(
f"{source}: auth_capability.denied_probe_paths must be a list of strings"
)
# A path must never leak a value through a field name suggesting inline secrets.
if any(looks_secret(k) and data.get(k) for k in ("value", "secret", "token", "password")):
raise CatalogError(f"{source}: catalog entries must not contain secret values")

View file

@ -7,7 +7,8 @@ Command surface (FR7):
plan <decision-or-ref> --stage <stage>
apply <decision-or-ref> --stage <stage> [--dry-run] [--bootstrap-token-file F]
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
verify <catalog-id> [--positive] [--negative] --field NAME
verify <catalog-id> [--positive] [--negative] [--field NAME]
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
route <catalog-id> [--json]
revoke <catalog-id>
@ -26,7 +27,7 @@ from secrets_engine.apply import apply_plan
from secrets_engine.catalog import get_entry, load_catalog
from secrets_engine.config import Config, repo_root
from secrets_engine.decisions import require_approved, resolve_decision
from secrets_engine.errors import SecretsEngineError
from secrets_engine.errors import DecisionError, SecretsEngineError
from secrets_engine.evidence import EvidenceWriter
from secrets_engine.openbao import OpenBaoClient
from secrets_engine.plan import build_plan
@ -71,16 +72,24 @@ def cmd_catalog_list(cfg: Config, args) -> int:
print("(no catalog entries)")
return 0
for e in entries.values():
print(f"{e.id:32s} stage={e.stage:5s} owner={e.owner:18s} {e.mount}/{e.path}")
print(
f"{e.id:32s} kind={e.kind:15s} stage={e.stage:5s} "
f"owner={e.owner:18s} {e.mount}/{e.path}"
)
return 0
def cmd_catalog_show(cfg: Config, args) -> int:
e = get_entry(cfg.catalog_dir, args.catalog_id)
print(f"id: {e.id}")
print(f"kind: {e.kind}")
print(f"owner: {e.owner}")
print(f"stage: {e.stage}")
print(f"openbao: {e.mount}/{e.path} fields={e.fields}")
if e.kind == "auth-capability":
print(f"openbao: mount={e.mount} allowed={sorted(e.auth_allowed_paths)}")
print(f"approle: {e.role_name} policy={e.policy_name}")
else:
print(f"openbao: {e.mount}/{e.path} fields={e.fields}")
print(f"consumers: {[c['name'] for c in e.consumers]}")
print(f"delivery: {e.delivery_modes}")
print(f"approval: {e.approval.get('model')} ref={e.approval.get('decision_ref','')}")
@ -111,11 +120,13 @@ def cmd_plan(cfg: Config, args) -> int:
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
decision = None
if entry.approval_required():
decision = resolve_decision(
hub_url=cfg.hub_url, repo_root=repo_root(),
decision_ref=entry.approval.get("decision_ref", args.ref),
)
require_approved(entry, decision)
try:
decision = resolve_decision(
hub_url=cfg.hub_url, repo_root=repo_root(),
decision_ref=entry.approval.get("decision_ref", args.ref),
)
except DecisionError:
decision = None
plan = build_plan(entry, args.stage, decision_id=decision.id if decision else "")
print(plan.render())
_writer(cfg).record(
@ -129,11 +140,16 @@ def cmd_apply(cfg: Config, args) -> int:
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
decision = None
if entry.approval_required():
decision = resolve_decision(
hub_url=cfg.hub_url, repo_root=repo_root(),
decision_ref=entry.approval.get("decision_ref", args.ref),
)
require_approved(entry, decision)
try:
decision = resolve_decision(
hub_url=cfg.hub_url, repo_root=repo_root(),
decision_ref=entry.approval.get("decision_ref", args.ref),
)
except DecisionError:
if not args.dry_run:
raise
if not args.dry_run:
require_approved(entry, decision)
plan = build_plan(entry, args.stage, decision_id=decision.id if decision else "")
w = _writer(cfg)
if args.dry_run:
@ -174,6 +190,9 @@ def cmd_verify(cfg: Config, args) -> int:
entry = get_entry(cfg.catalog_dir, args.catalog_id)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
field = args.field or (entry.fields[0] if entry.fields else "")
if entry.stores_kv_value() and not field:
from secrets_engine.errors import VerificationError
raise VerificationError(f"lane '{entry.id}' has no field to verify")
positive = args.positive or not args.negative
negative = args.negative or not args.positive
results = run_verification(client, entry, field, positive=positive, negative=negative)
@ -187,6 +206,53 @@ def cmd_verify(cfg: Config, args) -> int:
return rc
def cmd_handoff(cfg: Config, args) -> int:
from secrets_engine.errors import ProvisioningError
from secrets_engine.handoff import write_approle_handoff
entry = get_entry(cfg.catalog_dir, args.catalog_id)
if args.stage != entry.stage:
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
if entry.kind != "auth-capability":
raise ProvisioningError(f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability")
decision = None
if entry.approval_required():
decision = resolve_decision(
hub_url=cfg.hub_url, repo_root=repo_root(),
decision_ref=entry.approval.get("decision_ref", entry.id),
)
require_approved(entry, decision)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
result = write_approle_handoff(
client,
entry,
role_id_file=Path(args.role_id_file),
secret_id_file=Path(args.secret_id_file),
)
print(f"wrote AppRole handoff material for lane '{entry.id}' — secret_id not displayed")
print(f" role: {result.role_name}")
print(f" role_id_file: {result.role_id_file}")
print(f" secret_id_file: {result.secret_id_file}")
print(f" token_ttl: {result.token_ttl}")
print(f" secret_id_ttl: {result.secret_id_ttl}")
_writer(cfg).record(
"handoff",
result="secret-id-written",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"role": result.role_name,
"role_id_file": result.role_id_file,
"secret_id_file": result.secret_id_file,
"token_ttl": result.token_ttl,
"secret_id_ttl": result.secret_id_ttl,
"secret_id_num_uses": result.secret_id_num_uses,
},
)
return 0
def cmd_exec(cfg: Config, args) -> int:
from secrets_engine.exec_delivery import exec_with_secret
entry = get_entry(cfg.catalog_dir, args.catalog)
@ -240,9 +306,13 @@ def cmd_route(cfg: Config, args) -> int:
import json
print(json.dumps(result.to_json(), indent=2))
else:
print(f"lane: {result.catalog_id} (owner={result.owner}, stage={result.stage})")
print(
f"lane: {result.catalog_id} "
f"(kind={result.kind}, owner={result.owner}, stage={result.stage})"
)
print(f"decision: {result.decision_status} ref={result.decision_ref}")
print(f"applied: {result.metadata_applied} value_present: {result.value_present}")
material_label = "handoff_ready" if result.kind == "auth-capability" else "value_present"
print(f"applied: {result.metadata_applied} {material_label}: {result.value_present}")
print(f"ready: {result.ready}")
if result.missing:
print(f"missing: {result.missing}")
@ -253,6 +323,21 @@ def cmd_route(cfg: Config, args) -> int:
def cmd_revoke(cfg: Config, args) -> int:
entry = get_entry(cfg.catalog_dir, args.catalog_id)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
if entry.kind == "auth-capability":
if args.dry_run:
print(
f"(dry-run) would delete approle {entry.role_name} "
f"and policy {entry.policy_name}"
)
return 0
client.delete_approle(entry.role_name)
client.delete_policy(entry.policy_name)
print(
f"revoked lane '{entry.id}': deleted approle {entry.role_name} "
f"and policy {entry.policy_name}"
)
_writer(cfg).record("revoke", result="auth-capability-deactivated", catalog_id=entry.id, stage=entry.stage)
return 0
if args.dry_run:
print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} "
f"and approle {entry.role_name}")
@ -318,6 +403,14 @@ def build_parser() -> argparse.ArgumentParser:
add_token_arg(ve)
ve.set_defaults(func=cmd_verify)
ha = sub.add_parser("handoff", help="write AppRole role_id/secret_id handoff files")
ha.add_argument("catalog_id")
ha.add_argument("--stage", required=True, choices=("build", "test", "prod"))
ha.add_argument("--role-id-file", required=True)
ha.add_argument("--secret-id-file", required=True)
add_token_arg(ha)
ha.set_defaults(func=cmd_handoff)
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only")
ex.add_argument("--catalog", required=True)
ex.add_argument("--field", default=None)

View file

@ -0,0 +1,96 @@
"""Out-of-band AppRole handoff helpers for auth-capability lanes.
The secret_id is secret material. It is minted only after output paths have been
validated, written to a mode-0600 file outside any Git worktree, and never
printed or recorded in evidence.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import ProvisioningError
from secrets_engine.openbao import OpenBaoClient
@dataclass(frozen=True)
class HandoffResult:
role_name: str
role_id_file: str
secret_id_file: str
token_ttl: str
secret_id_ttl: str
secret_id_num_uses: int
def _assert_outside_git_worktree(path: Path) -> Path:
resolved = path.expanduser().resolve()
for parent in (resolved.parent, *resolved.parent.parents):
if (parent / ".git").exists():
raise ProvisioningError(
f"handoff file {resolved} is inside a Git worktree ({parent}); "
"keep AppRole material outside repos"
)
return resolved
def _validate_output_path(path: Path) -> Path:
resolved = _assert_outside_git_worktree(path)
if resolved.exists() and resolved.stat().st_mode & 0o077:
raise ProvisioningError(
f"handoff file {resolved} is group/other-accessible "
f"(mode {oct(resolved.stat().st_mode & 0o777)}); must be 0600"
)
return resolved
def _write_mode_0600(path: Path, value: str) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd: int | None = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fd = None
fh.write(value)
fh.write("\n")
finally:
if fd is not None:
os.close(fd)
def write_approle_handoff(
client: OpenBaoClient,
entry: CatalogEntry,
*,
role_id_file: Path,
secret_id_file: Path,
) -> HandoffResult:
"""Mint and write AppRole handoff material without printing the secret_id."""
if entry.kind != "auth-capability":
raise ProvisioningError(
f"lane '{entry.id}' is {entry.kind}; handoff is only for auth-capability lanes"
)
role_path = _validate_output_path(role_id_file)
secret_path = _validate_output_path(secret_id_file)
if role_path == secret_path:
raise ProvisioningError("role_id_file and secret_id_file must be different")
role_id = client.read_approle_role_id(entry.role_name)
secret_id = client.create_approle_secret_id(entry.role_name)
try:
_write_mode_0600(role_path, role_id)
_write_mode_0600(secret_path, secret_id)
finally:
del secret_id
return HandoffResult(
role_name=entry.role_name,
role_id_file=str(role_path),
secret_id_file=str(secret_path),
token_ttl=entry.token_ttl,
secret_id_ttl=entry.secret_id_ttl,
secret_id_num_uses=entry.secret_id_num_uses,
)

View file

@ -143,28 +143,49 @@ class OpenBaoClient:
if enable.returncode != 0 and "already in use" not in enable.stderr:
raise BackendError(f"could not enable approle: {enable.stderr.strip()}")
def write_approle(self, role_name: str, policies: list[str], ttl: str = "30m") -> None:
self._run_ok(
[
"write",
f"auth/approle/role/{role_name}",
f"token_policies={','.join(policies)}",
f"token_ttl={ttl}",
f"token_max_ttl={ttl}",
"secret_id_num_uses=0",
"token_num_uses=0",
]
)
def write_approle(
self,
role_name: str,
policies: list[str],
ttl: str = "30m",
*,
max_ttl: str | None = None,
secret_id_ttl: str | None = None,
secret_id_num_uses: int = 0,
token_num_uses: int = 0,
) -> None:
args = [
"write",
f"auth/approle/role/{role_name}",
f"token_policies={','.join(policies)}",
f"token_ttl={ttl}",
f"token_max_ttl={max_ttl or ttl}",
f"secret_id_num_uses={secret_id_num_uses}",
f"token_num_uses={token_num_uses}",
]
if secret_id_ttl:
args.append(f"secret_id_ttl={secret_id_ttl}")
self._run_ok(args)
def approle_exists(self, role_name: str) -> bool:
proc = self._run(["read", f"auth/approle/role/{role_name}"])
return proc.returncode == 0
def read_approle_role_id(self, role_name: str) -> str:
return self._run_ok(
["read", "-field=role_id", f"auth/approle/role/{role_name}/role-id"]
).strip()
def create_approle_secret_id(self, role_name: str) -> str:
return self._run_ok(
["write", "-field=secret_id", "-f", f"auth/approle/role/{role_name}/secret-id"]
).strip()
def approle_login_token(self, role_name: str) -> str:
"""Login as the approle and return a scoped child token. Used only for
verification / exec delivery; never logged."""
role_id = self._run_ok(
["read", "-field=role_id", f"auth/approle/role/{role_name}/role-id"]
).strip()
secret_id = self._run_ok(
["write", "-field=secret_id", "-f", f"auth/approle/role/{role_name}/secret-id"]
).strip()
role_id = self.read_approle_role_id(role_name)
secret_id = self.create_approle_secret_id(role_name)
token = self._run_ok(
[
"write",
@ -176,6 +197,28 @@ class OpenBaoClient:
).strip()
return token
def token_capabilities(self, path: str, *, token: str) -> list[str]:
"""Return token capabilities for a path without returning any secret value."""
client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
out = client._run_ok(["token", "capabilities", "-format=json", path])
try:
data = json.loads(out)
except json.JSONDecodeError:
return [line.strip() for line in out.splitlines() if line.strip()]
if isinstance(data, list):
return [str(item) for item in data]
if isinstance(data, dict):
caps = data.get("capabilities", [])
if isinstance(caps, list):
return [str(item) for item in caps]
return []
def delete_policy(self, name: str) -> None:
self._run_ok(["policy", "delete", name])
def delete_approle(self, role_name: str) -> None:
self._run_ok(["delete", f"auth/approle/role/{role_name}"])
# -- KV v2 -------------------------------------------------------------
def kv_mount_exists(self, mount: str) -> bool:

View file

@ -14,6 +14,7 @@ from secrets_engine.errors import PolicyGuardError
from secrets_engine.roles import (
StageRole,
assert_path_in_stage,
auth_capability_policy_for,
consumer_policy_for,
)
@ -63,22 +64,42 @@ def build_plan(entry: CatalogEntry, stage: str, *, decision_id: str = "") -> Pla
f"refusing to apply as '{stage}'"
)
StageRole.for_stage(stage) # validates stage name
assert_path_in_stage(entry) # path must be in-stage, no wildcards
policy_name, policy_hcl = consumer_policy_for(entry) # runs assert_policy_safe
if entry.kind == "auth-capability":
policy_name, policy_hcl = auth_capability_policy_for(entry)
actions = [
PlanAction(
"policy",
policy_name,
{"paths": ",".join(entry.auth_allowed_paths)},
),
PlanAction(
"approle",
entry.role_name,
{
"token_policies": policy_name,
"auth": "approle",
"token_ttl": entry.token_ttl,
"secret_id_num_uses": entry.secret_id_num_uses,
},
),
]
else:
assert_path_in_stage(entry) # path must be in-stage, no wildcards
policy_name, policy_hcl = consumer_policy_for(entry) # runs assert_policy_safe
actions = [
PlanAction("kv-mount", entry.mount, {"type": "kv-v2"}),
PlanAction(
"policy",
policy_name,
{"paths": f"{entry.mount}/data/{entry.path}"},
),
PlanAction(
"approle",
entry.role_name,
{"token_policies": policy_name, "auth": "approle"},
),
]
actions = [
PlanAction("kv-mount", entry.mount, {"type": "kv-v2"}),
PlanAction(
"policy",
policy_name,
{"paths": f"{entry.mount}/data/{entry.path}"},
),
PlanAction(
"approle",
entry.role_name,
{"token_policies": policy_name, "auth": "approle"},
),
]
return Plan(
catalog_id=entry.id,
stage=stage,

View file

@ -45,6 +45,10 @@ def provision_from_file(
client: OpenBaoClient, entry: CatalogEntry, field: str, file_path: Path
) -> str:
"""Import a value from a strict-permission file. Returns the field name only."""
if not entry.stores_kv_value():
raise ProvisioningError(
f"lane '{entry.id}' is {entry.kind}; it has no KV value to provision"
)
if field not in entry.fields:
raise ProvisioningError(
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
@ -58,6 +62,10 @@ def provision_from_file(
def provision_generated(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str:
"""Generate a random NON-PRODUCTION value for build/test lanes only."""
if not entry.stores_kv_value():
raise ProvisioningError(
f"lane '{entry.id}' is {entry.kind}; it has no KV value to provision"
)
if entry.stage == "prod":
raise ProvisioningError(
f"refusing to generate a value for prod lane '{entry.id}'; "

View file

@ -26,6 +26,10 @@ STAGE_PREFIX = {
# Capabilities a stage role's *own* policy may carry. Anything else is broad.
ALLOWED_CAPABILITIES = {"create", "read", "update", "delete", "list"}
# Auth-capability lanes grant an operational action, not KV value access. Keep
# this intentionally smaller than the stage-role capability set.
AUTH_CAPABILITY_ALLOWED_CAPABILITIES = {"update"}
# Substrings that, if they appear in a policy path, mean the plan is too broad.
FORBIDDEN_PATH_MARKERS = (
"sys/",
@ -110,6 +114,63 @@ def assert_policy_safe(policy_name: str, paths: dict[str, list[str]]) -> None:
)
def assert_auth_capability_safe(
policy_name: str,
mount: str,
paths: dict[str, list[str]],
denied_probe_paths: list[str] | None = None,
) -> None:
"""Reject auth-capability grants that are broader than exact operations."""
lowered = policy_name.lower()
for marker in FORBIDDEN_NAME_MARKERS:
if marker in lowered:
raise PolicyGuardError(
f"policy name '{policy_name}' resembles broad admin (marker '{marker}')"
)
if not paths:
raise PolicyGuardError(f"policy '{policy_name}': no auth-capability paths")
mount_prefix = f"{mount}/"
for path, caps in paths.items():
if "*" in path or "+" in path or path.strip() in ("*", "/", "+"):
raise PolicyGuardError(
f"policy '{policy_name}': wildcard auth path '{path}' not allowed"
)
for marker in FORBIDDEN_PATH_MARKERS:
if marker in path:
raise PolicyGuardError(
f"policy '{policy_name}': path '{path}' is out of bounds "
f"(marker '{marker}')"
)
if not path.startswith(mount_prefix):
raise PolicyGuardError(
f"policy '{policy_name}': auth path '{path}' is outside mount '{mount}'"
)
if mount == "ssh":
parts = path.split("/")
if len(parts) != 3 or parts[1] != "sign" or not parts[2]:
raise PolicyGuardError(
f"policy '{policy_name}': ssh auth-capability path '{path}' "
"must be exactly ssh/sign/<role>"
)
bad_caps = set(caps) - AUTH_CAPABILITY_ALLOWED_CAPABILITIES
if bad_caps:
raise PolicyGuardError(
f"policy '{policy_name}': capabilities {sorted(bad_caps)} not allowed "
f"for auth-capability (allowed {sorted(AUTH_CAPABILITY_ALLOWED_CAPABILITIES)})"
)
for path in denied_probe_paths or []:
if path in paths:
raise PolicyGuardError(
f"policy '{policy_name}': denied probe '{path}' is also allowed"
)
if "*" in path or "+" in path:
raise PolicyGuardError(
f"policy '{policy_name}': denied probe '{path}' must be exact"
)
def lane_policy_paths(entry: CatalogEntry) -> dict[str, list[str]]:
"""The minimal KV v2 paths + capabilities a consumer policy needs for a lane."""
data_path = f"{entry.mount}/data/{entry.path}"
@ -136,3 +197,43 @@ def consumer_policy_for(entry: CatalogEntry) -> tuple[str, str]:
paths = lane_policy_paths(entry)
name = entry.policy_name
return name, render_policy_hcl(name, paths)
def auth_capability_policy_paths(entry: CatalogEntry) -> dict[str, list[str]]:
"""The exact operational paths an auth-capability lane may exercise."""
paths = entry.auth_allowed_paths
assert_auth_capability_safe(
entry.policy_name, entry.mount, paths, entry.auth_denied_probe_paths
)
return paths
def render_auth_capability_policy_hcl(
policy_name: str,
mount: str,
paths: dict[str, list[str]],
denied_probe_paths: list[str] | None = None,
) -> str:
"""Render a narrow auth-capability ACL policy in HCL."""
denied_probe_paths = denied_probe_paths or []
assert_auth_capability_safe(policy_name, mount, paths, denied_probe_paths)
blocks = [
f'# Generated by secrets-engine for auth-capability policy "{policy_name}"',
"# Exact allowlist below. Every path outside it is denied by OpenBao default.",
]
if denied_probe_paths:
blocks.append("# Denial probes expected to lack update capability:")
blocks.extend(f"# - {path}" for path in denied_probe_paths)
for path, caps in paths.items():
cap_list = ", ".join(f'"{c}"' for c in caps)
blocks.append(f'path "{path}" {{\n capabilities = [{cap_list}]\n}}')
return "\n\n".join(blocks) + "\n"
def auth_capability_policy_for(entry: CatalogEntry) -> tuple[str, str]:
"""Return (policy_name, hcl) for a non-KV auth-capability lane."""
paths = auth_capability_policy_paths(entry)
name = entry.policy_name
return name, render_auth_capability_policy_hcl(
name, entry.mount, paths, entry.auth_denied_probe_paths
)

View file

@ -19,6 +19,7 @@ from secrets_engine.openbao import OpenBaoClient
@dataclass
class RouteResult:
catalog_id: str
kind: str
owner: str
stage: str
decision_status: str
@ -59,13 +60,20 @@ def route_lane(
metadata_applied = False
value_present = False
if client is not None and client.is_reachable():
metadata_applied = client.read_policy(entry.policy_name) is not None
# Presence check uses the engine's own token; reports boolean only.
field = entry.fields[0] if entry.fields else ""
if field:
value_present = client.kv_field_present(entry.mount, entry.path, field)
policy_applied = client.read_policy(entry.policy_name) is not None
role_applied = client.approle_exists(entry.role_name)
metadata_applied = policy_applied and role_applied
if entry.stores_kv_value():
# Presence check uses the engine's own token; reports boolean only.
field = entry.fields[0] if entry.fields else ""
if field:
value_present = client.kv_field_present(entry.mount, entry.path, field)
else:
# Auth-capability lanes have no stored value; a fresh secret_id is minted
# on demand through the handoff command once metadata exists.
value_present = metadata_applied
approved = decision is None or decision.is_approved()
approved = not entry.approval_required() or (decision is not None and decision.is_approved())
ready = approved and metadata_applied and value_present
if not approved:
@ -74,6 +82,12 @@ def route_lane(
elif not metadata_applied:
missing = "OpenBao policy/role apply"
next_command = f"secrets-engine apply {decision_ref or entry.id} --stage {entry.stage}"
elif entry.kind == "auth-capability":
missing = ""
next_command = (
f"secrets-engine handoff {entry.id} --stage {entry.stage} "
"--role-id-file <path> --secret-id-file <path>"
)
elif not value_present:
missing = "provisioned secret value"
next_command = (
@ -86,6 +100,7 @@ def route_lane(
return RouteResult(
catalog_id=entry.id,
kind=entry.kind,
owner=entry.owner,
stage=entry.stage,
decision_status=decision_status,

View file

@ -68,14 +68,94 @@ def verify_negative(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
)
def verify_auth_capability_positive(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
"""Approved AppRole token must carry update on every exact allowed path."""
try:
token = client.approle_login_token(entry.role_name)
except Exception as e:
return VerifyResult(
"positive",
False,
{"reason": f"could not obtain approle token: {e}", "role": entry.role_name},
)
missing: list[str] = []
for path in entry.auth_allowed_paths:
try:
caps = client.token_capabilities(path, token=token)
except Exception as e:
return VerifyResult(
"positive",
False,
{"reason": f"could not inspect capabilities: {e}", "path": path},
)
if "update" not in caps:
missing.append(path)
passed = not missing
return VerifyResult(
"positive",
passed,
{
"reason": "approle token can update every allowlisted path"
if passed
else "approle token lacks update on allowlisted paths",
"role": entry.role_name,
"allowed_paths": sorted(entry.auth_allowed_paths),
"missing_update": missing,
},
)
def verify_auth_capability_negative(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
"""Approved AppRole token must not gain update outside denial probes."""
try:
token = client.approle_login_token(entry.role_name)
except Exception as e:
return VerifyResult(
"negative",
False,
{"reason": f"could not obtain approle token: {e}", "role": entry.role_name},
)
leaks: list[str] = []
for path in entry.auth_denied_probe_paths:
try:
caps = client.token_capabilities(path, token=token)
except Exception as e:
return VerifyResult(
"negative",
False,
{"reason": f"could not inspect capabilities: {e}", "path": path},
)
if "update" in caps or "sudo" in caps or "root" in caps:
leaks.append(path)
passed = not leaks
return VerifyResult(
"negative",
passed,
{
"reason": "denial probes lack update capability"
if passed
else "approle token can update outside the allowlist",
"role": entry.role_name,
"denied_probe_paths": entry.auth_denied_probe_paths,
"leaks": leaks,
},
)
def run_verification(
client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool
) -> list[VerifyResult]:
results: list[VerifyResult] = []
if positive:
results.append(verify_positive(client, entry, field))
if negative:
results.append(verify_negative(client, entry))
if entry.kind == "auth-capability":
if positive:
results.append(verify_auth_capability_positive(client, entry))
if negative:
results.append(verify_auth_capability_negative(client, entry))
else:
if positive:
results.append(verify_positive(client, entry, field))
if negative:
results.append(verify_negative(client, entry))
if not results:
raise VerificationError("no verification check selected (use --positive/--negative)")
return results

View file

@ -0,0 +1,235 @@
import copy
import os
from pathlib import Path
import pytest
from secrets_engine.apply import apply_plan
from secrets_engine.catalog import load_catalog, validate_entry
from secrets_engine.config import repo_root
from secrets_engine.errors import PolicyGuardError, ProvisioningError
from secrets_engine.handoff import write_approle_handoff
from secrets_engine.plan import build_plan
from secrets_engine.provision import provision_from_file
from secrets_engine.routing import route_lane
from secrets_engine.verify import run_verification
AUTH = {
"id": "warden-sign",
"kind": "auth-capability",
"org": "netkingdom",
"repo": "ops-warden",
"stage": "prod",
"mount": "ssh",
"path": "sign",
"consumers": [
{"name": "ops-warden-policy-smoke", "auth": "approle", "claim": "agent:agt"}
],
"delivery_modes": ["approle-login"],
"approval": {"model": "decision", "decision_ref": "SECRETS-WP-0004"},
"verification": {"positive": "allowlisted sign", "negative": "deny probes"},
"auth_capability": {
"policy_name": "warden-sign",
"role_name": "warden-sign",
"token_ttl": "15m",
"token_max_ttl": "15m",
"secret_id_ttl": "30m",
"secret_id_num_uses": 1,
"token_num_uses": 0,
"allowed_paths": [
{"path": "ssh/sign/agt-role", "capabilities": ["update"]},
{"path": "ssh/sign/adm-role", "capabilities": ["update"]},
{"path": "ssh/sign/atm-role", "capabilities": ["update"]},
],
"denied_probe_paths": ["ssh/sign/unlisted-role", "ssh/roles/agt-role"],
},
"rotation": {"expectation": "single-use handoff", "ttl": "15m"},
"deactivation": {"expectation": "delete approle and policy"},
"audit": {"evidence": "non-secret pointers only"},
}
def _auth_entry(**over):
data = copy.deepcopy(AUTH)
for key, value in over.items():
if key == "auth_capability":
data[key].update(value)
else:
data[key] = value
return validate_entry(data)
def test_auth_capability_entry_parses_without_fields():
entry = _auth_entry()
assert entry.kind == "auth-capability"
assert entry.fields == []
assert entry.policy_name == "warden-sign"
assert entry.role_name == "warden-sign"
assert sorted(entry.auth_allowed_paths) == [
"ssh/sign/adm-role",
"ssh/sign/agt-role",
"ssh/sign/atm-role",
]
def test_repo_catalog_loads_warden_sign_lane():
entries = load_catalog(repo_root() / "catalog")
entry = entries["warden-sign"]
assert entry.kind == "auth-capability"
assert entry.mount == "ssh"
assert entry.token_ttl == "15m"
assert entry.secret_id_num_uses == 1
def test_auth_capability_rejects_kv_fields():
data = copy.deepcopy(AUTH)
data["fields"] = ["token"]
with pytest.raises(Exception):
validate_entry(data)
def test_auth_capability_plan_has_no_kv_mount():
entry = _auth_entry()
plan = build_plan(entry, "prod", decision_id="d1")
assert not any(action.kind == "kv-mount" for action in plan.actions)
assert any(action.kind == "policy" and action.target == "warden-sign" for action in plan.actions)
assert "ssh/sign/agt-role" in plan.policy_hcl
assert "ssh/sign/adm-role" in plan.policy_hcl
assert "ssh/sign/atm-role" in plan.policy_hcl
assert "secret/data" not in plan.policy_hcl
@pytest.mark.parametrize(
"path",
[
"ssh/*",
"ssh/sign/*",
"ssh/roles/agt-role",
"sys/policies/acl/warden-sign",
"auth/token/create",
"identity/entity/id",
],
)
def test_auth_capability_broad_or_admin_paths_refused(path):
entry = _auth_entry(
auth_capability={
"allowed_paths": [{"path": path, "capabilities": ["update"]}],
}
)
with pytest.raises(PolicyGuardError):
build_plan(entry, "prod")
def test_auth_capability_root_like_policy_name_refused():
entry = _auth_entry(auth_capability={"policy_name": "root-warden-sign"})
with pytest.raises(PolicyGuardError):
build_plan(entry, "prod")
def test_auth_capability_provision_rejected(tmp_path):
value_file = tmp_path / "value"
value_file.write_text("not-real-secret")
os.chmod(value_file, 0o600)
with pytest.raises(ProvisioningError):
provision_from_file(object(), _auth_entry(), "", value_file)
class FakeApplyClient:
def __init__(self):
self.policy = ""
self.approle = None
def kv_mount_exists(self, mount):
raise AssertionError("auth-capability apply must not inspect KV mounts")
def ensure_kv_mount(self, mount):
raise AssertionError("auth-capability apply must not create KV mounts")
def read_policy(self, name):
return None
def write_policy(self, name, hcl):
self.policy = hcl
def ensure_approle_enabled(self):
pass
def write_approle(self, role_name, policies, ttl="30m", **kwargs):
self.approle = (role_name, policies, ttl, kwargs)
def test_apply_auth_capability_bypasses_kv_and_writes_ttl_options():
entry = _auth_entry()
plan = build_plan(entry, "prod", decision_id="d1")
client = FakeApplyClient()
result = apply_plan(client, entry, plan)
assert any("not applicable" in item for item in result.skipped)
assert "ssh/sign/agt-role" in client.policy
assert client.approle[0] == "warden-sign"
assert client.approle[2] == "15m"
assert client.approle[3]["secret_id_num_uses"] == 1
assert client.approle[3]["token_num_uses"] == 0
class FakeVerifyClient:
def approle_login_token(self, role_name):
assert role_name == "warden-sign"
return "test-token"
def token_capabilities(self, path, *, token):
assert token == "test-token"
if path.startswith("ssh/sign/") and path != "ssh/sign/unlisted-role":
return ["update"]
return ["deny"]
def test_auth_capability_verification_uses_capability_probes():
results = run_verification(
FakeVerifyClient(), _auth_entry(), "", positive=True, negative=True
)
assert [result.passed for result in results] == [True, True]
class FakeHandoffClient:
def read_approle_role_id(self, role_name):
assert role_name == "warden-sign"
return "role-id-test"
def create_approle_secret_id(self, role_name):
assert role_name == "warden-sign"
return "secret-id-test"
def test_handoff_rejects_repo_paths():
with pytest.raises(ProvisioningError):
write_approle_handoff(
FakeHandoffClient(),
_auth_entry(),
role_id_file=Path("role-id.out"),
secret_id_file=Path("secret-id.out"),
)
def test_handoff_writes_mode_0600_files(tmp_path):
role_file = tmp_path / "role-id"
secret_file = tmp_path / "secret-id"
result = write_approle_handoff(
FakeHandoffClient(),
_auth_entry(),
role_id_file=role_file,
secret_id_file=secret_file,
)
assert result.role_name == "warden-sign"
assert role_file.read_text().strip() == "role-id-test"
assert secret_file.read_text().strip() == "secret-id-test"
assert (role_file.stat().st_mode & 0o077) == 0
assert (secret_file.stat().st_mode & 0o077) == 0
def test_route_missing_decision_blocks_auth_capability(tmp_path):
result = route_lane(_auth_entry(), hub_url="", repo_root=tmp_path, client=None)
assert result.kind == "auth-capability"
assert result.decision_status == "missing"
assert result.ready is False
assert result.missing == "approved decision for 'SECRETS-WP-0004'"
assert result.next_command == "secrets-engine decision inspect SECRETS-WP-0004"

View file

@ -1,4 +1,5 @@
import copy
import uuid
import pytest
@ -98,6 +99,10 @@ def test_repo_catalog_loads_and_has_pilot():
assert pilot.repo == "whynot-design"
assert pilot.npm["registry"].startswith("https://gitea.coulomb.social/")
assert pilot.npm["scope"] == "@whynot"
# SECRETS-WP-0003 closeout uses the canonical State Hub decision UUID;
# the slug-named fixture is retired to offline fallback only.
uuid.UUID(pilot.approval["decision_ref"])
assert pilot.approval["decision_ref"] == "e6381a56-6b04-4fd5-b2de-f3ef59cde888"
# build/test/prod stage separation is representable
stages = {e.stage for e in entries.values()}
assert {"build", "prod"} <= stages

View file

@ -4,7 +4,7 @@ type: workplan
title: "Close out the whynot-design npm publish pilot (real)"
domain: infotech
repo: secrets-engine
status: proposed
status: active
owner: codex
topic_slug: custodian
created: "2026-06-29"
@ -52,17 +52,40 @@ not change that gate.
auth replaces bootstrap files (hardening backlog H1).
- Every minted token gets a revocation task in `docs/hardening-backlog.md` (H0).
## 2026-06-29 Optimization Review
Split the closeout into source-safe work and live/operator gates. Source-safe work
can complete in this repo: point the lane at the canonical State Hub decision,
keep only a UUID-named offline fallback fixture for demos, document the real
publish runbook, provide a non-secret preflight, and hand ops-warden a route
pointer. Live work remains gated because it requires Gitea admin/package rights,
an operator-minted bot token, OpenBao bootstrap authority, a coordinated
`whynot-design` version bump, and a real publish.
Existing State Hub evidence for CCR-2026-0001 confirms the corrected approval
`e6381a56-6b04-4fd5-b2de-f3ef59cde888` and related OpenBao/OIDC verification,
but that CCR path is not the same as this repo's original MVP catalog path, so
it is approval context rather than proof that `secrets-engine exec` has already
published `@whynot/design`.
## Tasks
## T01 - Record the canonical State Hub decision for the lane
```task
id: SECRETS-WP-0003-T01
status: todo
status: done
priority: high
state_hub_task_id: "11586d9d-c6bb-4455-8560-32da75cb06d4"
```
2026-06-29: Reused the canonical approved State Hub decision
`e6381a56-6b04-4fd5-b2de-f3ef59cde888`, updated
`catalog/whynot-design-npm-publish.yaml` to reference that UUID, and replaced
the slug-named local decision fixture with a UUID-named offline fallback only.
`decision inspect` resolves from `source: hub` when State Hub is reachable.
Record a real State Hub decision approving establishment of the whynot-design npm
publish lane, then point `catalog/whynot-design-npm-publish.yaml`
`approval.decision_ref` at that decision's UUID. Retire the local fixture (keep it
@ -79,11 +102,16 @@ Acceptance:
```task
id: SECRETS-WP-0003-T02
status: todo
status: wait
priority: high
state_hub_task_id: "13a34d32-ab4b-4cf5-a1fb-e3e920649a23"
```
2026-06-29: Added bot-account evidence requirements to
`docs/whynot-design-real-publish-closeout.md`. This remains an operator/Gitea
admin gate; no repo-side command can create or prove the scoped bot without
Gitea admin credentials and package-permission evidence.
Create a dedicated Gitea bot account (e.g. `se-whynot-design`) whose package
publish rights are limited to `coulomb/whynot-design` / the `@whynot` scope, so
the repo-scope grant is enforced at the backend rather than only signalled by the
@ -101,11 +129,17 @@ Acceptance:
```task
id: SECRETS-WP-0003-T03
status: todo
status: wait
priority: high
state_hub_task_id: "746b5e7f-cc10-43e4-b6d3-7d792d95dfeb"
```
2026-06-29: Documented the safe apply/provision/verify sequence and added
revocation tracking rows for the production OpenBao bootstrap token and
whynot-design Gitea bot package token. Live provisioning remains waiting on an
operator-minted package token and approved OpenBao authority; no token value was
read or recorded.
Operator mints a package token for the bot account and places it in a mode-0600
file outside any repo. Provision it with
`secrets-engine provision whynot-design-npm-publish --stage prod --field npm_token
@ -123,11 +157,16 @@ Acceptance:
```task
id: SECRETS-WP-0003-T04
status: todo
status: wait
priority: high
state_hub_task_id: "36b925c2-0670-4481-95d9-1f23dcc96575"
```
2026-06-29: Added the real publish runbook and non-secret source-side preflight.
A real publish is still waiting on T02/T03 plus a coordinated version bump in the
external `whynot-design` repo and operator confirmation of the published package
version.
Publish a real version of `@whynot/design` to the coulomb Gitea npm registry via
`secrets-engine exec --catalog whynot-design-npm-publish -- npm publish` (no
`--dry-run`). Coordinate the version bump with the whynot-design repo.
@ -144,11 +183,17 @@ Acceptance:
```task
id: SECRETS-WP-0003-T05
status: todo
status: progress
priority: medium
state_hub_task_id: "461a7854-6229-4bc1-8d94-f6e2c4e5fa79"
```
2026-06-29: Updated `docs/ops-warden-routing-contract.md` with the canonical
whynot-design pointer payload and sent State Hub message
`765a03f0-0b1a-4da4-a244-04de468cadba` to `ops-warden`. Completion remains
waiting on ops-warden updating/confirming its own routing catalog so
`warden route find "npm publish whynot-design"` resolves here.
Coordinate with the ops-warden repo so `warden route find` points npm publish
credential needs at secrets-engine, returning the `secrets-engine route` pointer
rather than a value. This is a handoff/coordination task; the route catalog entry

View file

@ -4,7 +4,7 @@ type: workplan
title: "Provision a scoped warden-sign token lane (ops-warden / FLEX-WP-0007 T4)"
domain: infotech
repo: secrets-engine
status: proposed
status: active
owner: codex
topic_slug: custodian
created: "2026-06-29"
@ -59,11 +59,15 @@ OpenBao, so it also exercises parts of the engine the npm pilot did not.
```task
id: SECRETS-WP-0004-T01
status: todo
status: done
priority: high
state_hub_task_id: "278e9a20-eaa6-47a8-9dd7-62952961d520"
```
2026-06-29: Implemented `kind: auth-capability` catalog support, fieldless
non-KV validation, KV provisioning bypass/rejection, route semantics, and dry-run
plan/apply handling while preserving the existing KV/npm lane behavior.
Add a lane kind that represents an OpenBao auth credential (AppRole + policy)
granting a capability on a mount, rather than a KV value. The current schema
assumes a KV `mount/path/fields` value and `provision --from-file`; an
@ -81,11 +85,17 @@ Acceptance:
```task
id: SECRETS-WP-0004-T02
status: todo
status: done
priority: high
state_hub_task_id: "f4b209e5-c900-4f5e-bb0c-f0c5b27b70c2"
```
2026-06-29: Added `catalog/warden-sign.yaml`, exact `warden-sign` policy/AppRole
planning, guard coverage rejecting broad `ssh/*`, `sys/`, `auth/token/`,
`identity/`, wildcard, root-like, and non-`update` grants, plus tests for the
allowlist and denial probes. Production stage policy now has exact
`warden-sign` policy/AppRole management exceptions only.
Author the `warden-sign` ACL policy and AppRole. Policy: `update` on
`ssh/sign/agt-role` (plus `adm-role`, `atm-role`) on the `ssh` mount; nothing
else. AppRole `warden-sign` bound to that policy with a short token TTL, usable
@ -103,11 +113,16 @@ Acceptance:
```task
id: SECRETS-WP-0004-T03
status: todo
status: wait
priority: high
state_hub_task_id: "4b414788-d670-496b-9c57-074464a012c4"
```
2026-06-29: Source apply path is implemented and dry-run verified. Live apply is
waiting on an approved SECRETS-WP-0004 decision/workplan gate plus a mode-0600
production bootstrap token outside any repo. The pending token path/revocation
row is recorded in `docs/hardening-backlog.md` H0.
Apply the policy + AppRole on `https://bao.coulomb.social` using a mode-0600
bootstrap token stored outside any repo. Idempotent re-apply.
@ -122,11 +137,17 @@ Acceptance:
```task
id: SECRETS-WP-0004-T04
status: todo
status: wait
priority: high
state_hub_task_id: "52b5cf88-029f-4f4b-8fe9-0a8dc30378b5"
```
2026-06-29: Added `secrets-engine handoff` and
`docs/warden-sign-auth-capability.md`. Handoff writes `role_id` and a fresh
single-use `secret_id` to caller-chosen mode-0600 files outside Git worktrees and
records only non-secret file paths/TTL metadata. Execution is waiting on live
apply and attended out-of-band operator delivery.
Define and execute the handoff: mint a fresh `secret_id`, deliver it (with the
`role_id`) to the operator out-of-band; warden does `approle login` to obtain a
`VAULT_TOKEN`. Post the non-secret pointers on the ops-warden thread (policy name,
@ -143,11 +164,15 @@ Acceptance:
```task
id: SECRETS-WP-0004-T05
status: todo
status: wait
priority: medium
state_hub_task_id: "aa0f281e-976d-4fcd-b8a3-78582117f86f"
```
2026-06-29: Offline tests pass (`59 passed, 2 skipped`) and route/dry-run CLI
checks produce non-secret pointers. Joint production smoke and ops-warden signal
remain waiting on live OpenBao apply, handoff, and operator-run smoke evidence.
Confirm the unblock end to end, then reply to ops-warden (msg 077ac90d) with the
pointers and runbook alignment. The reply is the explicit "we will signal
ops-warden when done" step.