From 6382139890c061e3adc1fdaab94034204c8c4255 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 29 Jun 2026 16:58:16 +0200 Subject: [PATCH] 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. --- .../e6381a56-6b04-4fd5-b2de-f3ef59cde888.yaml | 16 ++ .decisions/whynot-design-npm-publish.yaml | 15 -- README.md | 2 + catalog/warden-sign.yaml | 86 +++++++ catalog/whynot-design-npm-publish.yaml | 2 +- docs/cli.md | 36 ++- docs/hardening-backlog.md | 11 + docs/openbao-stage-roles.md | 9 +- docs/ops-warden-routing-contract.md | 48 +++- docs/warden-sign-auth-capability.md | 142 +++++++++++ docs/whynot-design-real-publish-closeout.md | 114 +++++++++ policies/secrets-engine-prod.hcl | 16 ++ scripts/whynot-real-publish-preflight.sh | 31 +++ src/secrets_engine/apply.py | 27 +- src/secrets_engine/catalog.py | 98 +++++++- src/secrets_engine/cli.py | 125 ++++++++-- src/secrets_engine/handoff.py | 96 +++++++ src/secrets_engine/openbao.py | 79 ++++-- src/secrets_engine/plan.py | 51 ++-- src/secrets_engine/provision.py | 8 + src/secrets_engine/roles.py | 101 ++++++++ src/secrets_engine/routing.py | 27 +- src/secrets_engine/verify.py | 88 ++++++- tests/test_auth_capability.py | 235 ++++++++++++++++++ tests/test_catalog.py | 5 + workplans/SECRETS-WP-0003-pilot-closeout.md | 57 ++++- .../SECRETS-WP-0004-warden-sign-token-lane.md | 37 ++- 27 files changed, 1455 insertions(+), 107 deletions(-) create mode 100644 .decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888.yaml delete mode 100644 .decisions/whynot-design-npm-publish.yaml create mode 100644 catalog/warden-sign.yaml create mode 100644 docs/warden-sign-auth-capability.md create mode 100644 docs/whynot-design-real-publish-closeout.md create mode 100755 scripts/whynot-real-publish-preflight.sh create mode 100644 src/secrets_engine/handoff.py create mode 100644 tests/test_auth_capability.py diff --git a/.decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888.yaml b/.decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888.yaml new file mode 100644 index 0000000..31ff452 --- /dev/null +++ b/.decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888.yaml @@ -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. diff --git a/.decisions/whynot-design-npm-publish.yaml b/.decisions/whynot-design-npm-publish.yaml deleted file mode 100644 index 19c169e..0000000 --- a/.decisions/whynot-design-npm-publish.yaml +++ /dev/null @@ -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. diff --git a/README.md b/README.md index 42fca30..f3581dd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/catalog/warden-sign.yaml b/catalog/warden-sign.yaml new file mode 100644 index 0000000..c5bbabe --- /dev/null +++ b/catalog/warden-sign.yaml @@ -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. diff --git a/catalog/whynot-design-npm-publish.yaml b/catalog/whynot-design-npm-publish.yaml index 30a5c04..f594073 100644 --- a/catalog/whynot-design-npm-publish.yaml +++ b/catalog/whynot-design-npm-publish.yaml @@ -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). diff --git a/docs/cli.md b/docs/cli.md index a7e5c66..89100ab 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -42,7 +42,8 @@ secrets-engine decision inspect secrets-engine plan --stage secrets-engine apply --stage [--dry-run] [--bootstrap-token-file F] secrets-engine provision --stage --field NAME (--from-file F | --generate) -secrets-engine verify --field NAME [--positive] [--negative] +secrets-engine verify [--field NAME] [--positive] [--negative] +secrets-engine handoff --stage --role-id-file F --secret-id-file F secrets-engine exec --catalog [--field NAME] [--mode auto|npm-config|exec-env] -- CMD... secrets-engine policy publication secrets-engine route [--json] @@ -55,6 +56,13 @@ var the token is injected under, per the netkingdom publication-scope policy `` 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: ` 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 diff --git a/docs/hardening-backlog.md b/docs/hardening-backlog.md index 7662f09..5cc83b0 100644 --- a/docs/hardening-backlog.md +++ b/docs/hardening-backlog.md @@ -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 ` then `shred -u `. +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 diff --git a/docs/openbao-stage-roles.md b/docs/openbao-stage-roles.md index 5413b62..f6dcfac 100644 --- a/docs/openbao-stage-roles.md +++ b/docs/openbao-stage-roles.md @@ -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/` paths. A bad plan fails + closed **before** any OpenBao call. Run the negative checks: diff --git a/docs/ops-warden-routing-contract.md b/docs/ops-warden-routing-contract.md index f7e0231..e8f0e57 100644 --- a/docs/ops-warden-routing-contract.md +++ b/docs/ops-warden-routing-contract.md @@ -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 --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/", "metadata_applied": true, "value_present": true, @@ -32,16 +34,18 @@ secrets-engine route --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 --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. diff --git a/docs/warden-sign-auth-capability.md b/docs/warden-sign-auth-capability.md new file mode 100644 index 0000000..89c9b7d --- /dev/null +++ b/docs/warden-sign-auth-capability.md @@ -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 +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 +``` diff --git a/docs/whynot-design-real-publish-closeout.md b/docs/whynot-design-real-publish-closeout.md new file mode 100644 index 0000000..db5a80e --- /dev/null +++ b/docs/whynot-design-real-publish-closeout.md @@ -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. diff --git a/policies/secrets-engine-prod.hcl b/policies/secrets-engine-prod.hcl index 446a461..ddaff5b 100644 --- a/policies/secrets-engine-prod.hcl +++ b/policies/secrets-engine-prod.hcl @@ -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"] } diff --git a/scripts/whynot-real-publish-preflight.sh b/scripts/whynot-real-publish-preflight.sh new file mode 100755 index 0000000..ef62eae --- /dev/null +++ b/scripts/whynot-real-publish-preflight.sh @@ -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 diff --git a/src/secrets_engine/apply.py b/src/secrets_engine/apply.py index 1e72a87..dfe667b 100644 --- a/src/secrets_engine/apply.py +++ b/src/secrets_engine/apply.py @@ -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/) 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) diff --git a/src/secrets_engine/catalog.py b/src/secrets_engine/catalog.py index 1a9a118..532e79d 100644 --- a/src/secrets_engine/catalog.py +++ b/src/secrets_engine/catalog.py @@ -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 = "") -> 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 = "") -> 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 = "") -> 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") diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index e569cc3..94f370c 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -7,7 +7,8 @@ Command surface (FR7): plan --stage apply --stage [--dry-run] [--bootstrap-token-file F] provision --stage (--from-file F | --generate) --field NAME - verify [--positive] [--negative] --field NAME + verify [--positive] [--negative] [--field NAME] + handoff --stage --role-id-file F --secret-id-file F exec --catalog [--field NAME] [--mode auto|npm-config|exec-env] -- CMD... route [--json] revoke @@ -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) diff --git a/src/secrets_engine/handoff.py b/src/secrets_engine/handoff.py new file mode 100644 index 0000000..febee85 --- /dev/null +++ b/src/secrets_engine/handoff.py @@ -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, + ) diff --git a/src/secrets_engine/openbao.py b/src/secrets_engine/openbao.py index 6b97eaa..600be32 100644 --- a/src/secrets_engine/openbao.py +++ b/src/secrets_engine/openbao.py @@ -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: diff --git a/src/secrets_engine/plan.py b/src/secrets_engine/plan.py index 3b1c887..dddfc2a 100644 --- a/src/secrets_engine/plan.py +++ b/src/secrets_engine/plan.py @@ -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, diff --git a/src/secrets_engine/provision.py b/src/secrets_engine/provision.py index 0bbfc8a..7f77846 100644 --- a/src/secrets_engine/provision.py +++ b/src/secrets_engine/provision.py @@ -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}'; " diff --git a/src/secrets_engine/roles.py b/src/secrets_engine/roles.py index dde13ef..e4a3a02 100644 --- a/src/secrets_engine/roles.py +++ b/src/secrets_engine/roles.py @@ -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/" + ) + 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 + ) diff --git a/src/secrets_engine/routing.py b/src/secrets_engine/routing.py index e998113..ede77be 100644 --- a/src/secrets_engine/routing.py +++ b/src/secrets_engine/routing.py @@ -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 --secret-id-file " + ) 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, diff --git a/src/secrets_engine/verify.py b/src/secrets_engine/verify.py index 445e872..2a19217 100644 --- a/src/secrets_engine/verify.py +++ b/src/secrets_engine/verify.py @@ -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 diff --git a/tests/test_auth_capability.py b/tests/test_auth_capability.py new file mode 100644 index 0000000..2b8481f --- /dev/null +++ b/tests/test_auth_capability.py @@ -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" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index d475c6f..176bdd0 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -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 diff --git a/workplans/SECRETS-WP-0003-pilot-closeout.md b/workplans/SECRETS-WP-0003-pilot-closeout.md index 1c27a41..03dd574 100644 --- a/workplans/SECRETS-WP-0003-pilot-closeout.md +++ b/workplans/SECRETS-WP-0003-pilot-closeout.md @@ -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 diff --git a/workplans/SECRETS-WP-0004-warden-sign-token-lane.md b/workplans/SECRETS-WP-0004-warden-sign-token-lane.md index c3b4ce3..8418a71 100644 --- a/workplans/SECRETS-WP-0004-warden-sign-token-lane.md +++ b/workplans/SECRETS-WP-0004-warden-sign-token-lane.md @@ -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.