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

@ -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.