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:
parent
a621fbaffd
commit
6382139890
27 changed files with 1455 additions and 107 deletions
16
.decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888.yaml
Normal file
16
.decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Offline fallback mirror for the canonical State Hub decision.
|
||||
#
|
||||
# Normal production use should resolve this UUID from State Hub with source: hub.
|
||||
# This file exists only so throwaway offline demos can run when
|
||||
# SECRETS_ENGINE_HUB_URL is empty or State Hub is unreachable. NON-SECRET:
|
||||
# contains no token value.
|
||||
id: e6381a56-6b04-4fd5-b2de-f3ef59cde888
|
||||
title: "Review CCR-2026-0001 corrected whynot-design npm publish token lane"
|
||||
status: resolved
|
||||
superseded_by: null
|
||||
decided_by: "human"
|
||||
review_url: "http://127.0.0.1:8000/decisions/e6381a56-6b04-4fd5-b2de-f3ef59cde888"
|
||||
rationale: >-
|
||||
APPROVE: We fixed the path using coulomb as the org/tenant. Approval covers
|
||||
establishing the whynot-design npm publish lane metadata; it does not expose or
|
||||
store the package token value.
|
||||
|
|
@ -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.
|
||||
|
|
@ -50,6 +50,8 @@ SECRETS_ENGINE_HUB_URL="" bash scripts/demo-e2e.sh
|
|||
- CLI reference: [docs/cli.md](docs/cli.md)
|
||||
- Publication-scope policy (maturity → token scope): [docs/publication-scope-policy.md](docs/publication-scope-policy.md)
|
||||
- Stage roles & bootstrap tokens: [docs/openbao-stage-roles.md](docs/openbao-stage-roles.md)
|
||||
- warden-sign auth-capability lane: [docs/warden-sign-auth-capability.md](docs/warden-sign-auth-capability.md)
|
||||
- whynot-design real publish closeout: [docs/whynot-design-real-publish-closeout.md](docs/whynot-design-real-publish-closeout.md)
|
||||
- ops-warden routing contract: [docs/ops-warden-routing-contract.md](docs/ops-warden-routing-contract.md)
|
||||
- Hardening backlog (exit bootstrap mode): [docs/hardening-backlog.md](docs/hardening-backlog.md)
|
||||
|
||||
|
|
|
|||
86
catalog/warden-sign.yaml
Normal file
86
catalog/warden-sign.yaml
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# warden-sign auth-capability lane.
|
||||
# NON-SECRET: this file contains only OpenBao policy/AppRole metadata and
|
||||
# operational pointers. AppRole secret_id values and tokens must never appear in
|
||||
# Git, State Hub, chat, prompts, workplans, or normal logs.
|
||||
id: warden-sign
|
||||
kind: auth-capability
|
||||
org: netkingdom
|
||||
repo: ops-warden
|
||||
stage: prod
|
||||
description: >-
|
||||
Scoped OpenBao AppRole used by ops-warden production smoke tests to call the
|
||||
SSH secrets engine signing API over HTTP. This lane grants update only on the
|
||||
exact SSH signing roles needed for FLEX-WP-0007 T4; it stores no KV value.
|
||||
|
||||
# Existing production OpenBao SSH secrets-engine mount and logical operation.
|
||||
mount: ssh
|
||||
path: sign
|
||||
|
||||
consumers:
|
||||
- name: ops-warden-policy-smoke
|
||||
auth: approle
|
||||
claim: "agent:agt-state-hub-bridge"
|
||||
purpose: >-
|
||||
Run warden sign and the flex-auth policy-gate production smoke without
|
||||
giving ops-warden standing custody of an OpenBao token.
|
||||
allowed_actors:
|
||||
- agt-state-hub-bridge
|
||||
- agt-codex-interhub-bootstrap
|
||||
- adm-example
|
||||
- atm-backup-daily
|
||||
|
||||
# The deliverable is a freshly minted AppRole secret_id plus role_id handed to
|
||||
# the operator out-of-band. There is no KV value and no from-file provisioning.
|
||||
delivery_modes:
|
||||
- approle-login
|
||||
|
||||
approval:
|
||||
model: decision
|
||||
decision_ref: "SECRETS-WP-0004"
|
||||
notes: >-
|
||||
Production auth-capability lane. Live apply and handoff require an approved
|
||||
decision/workplan plus a mode-0600 bootstrap token outside any repo.
|
||||
|
||||
verification:
|
||||
positive: "the warden-sign AppRole token has update on every allowlisted ssh/sign role"
|
||||
negative: "the warden-sign AppRole token lacks update on ssh/admin/sys/auth/identity probes"
|
||||
|
||||
auth_capability:
|
||||
policy_name: warden-sign
|
||||
role_name: warden-sign
|
||||
token_ttl: 15m
|
||||
token_max_ttl: 15m
|
||||
token_num_uses: 0
|
||||
secret_id_ttl: 30m
|
||||
secret_id_num_uses: 1
|
||||
allowed_paths:
|
||||
- path: ssh/sign/agt-role
|
||||
capabilities: [update]
|
||||
- path: ssh/sign/adm-role
|
||||
capabilities: [update]
|
||||
- path: ssh/sign/atm-role
|
||||
capabilities: [update]
|
||||
denied_probe_paths:
|
||||
- ssh/sign/unlisted-role
|
||||
- ssh/roles/agt-role
|
||||
- ssh/config/ca
|
||||
- sys/policies/acl/warden-sign
|
||||
- auth/token/create
|
||||
- identity/entity/id
|
||||
|
||||
rotation:
|
||||
expectation: >-
|
||||
Mint a fresh single-use secret_id for each attended handoff; revoke the
|
||||
resulting token by accessor after the FLEX-WP-0007 T4 smoke or when the 15m
|
||||
TTL expires, whichever comes first.
|
||||
ttl: 15m
|
||||
|
||||
deactivation:
|
||||
expectation: >-
|
||||
Delete the warden-sign AppRole and ACL policy, revoke outstanding token
|
||||
accessors, and record non-secret evidence.
|
||||
|
||||
audit:
|
||||
evidence: >-
|
||||
policy name, AppRole name, mount, allowed paths, token TTL, role_id file path,
|
||||
secret_id file path, timestamp, result — no token value or secret_id.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
36
docs/cli.md
36
docs/cli.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
142
docs/warden-sign-auth-capability.md
Normal file
142
docs/warden-sign-auth-capability.md
Normal 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
|
||||
```
|
||||
114
docs/whynot-design-real-publish-closeout.md
Normal file
114
docs/whynot-design-real-publish-closeout.md
Normal 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.
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
31
scripts/whynot-real-publish-preflight.sh
Executable file
31
scripts/whynot-real-publish-preflight.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env bash
|
||||
# Non-secret source-side preflight for SECRETS-WP-0003.
|
||||
set -euo pipefail
|
||||
|
||||
DECISION_ID="e6381a56-6b04-4fd5-b2de-f3ef59cde888"
|
||||
CATALOG_ID="whynot-design-npm-publish"
|
||||
|
||||
if [[ -d .venv ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
|
||||
if command -v secrets-engine >/dev/null 2>&1; then
|
||||
SECRETS_ENGINE=(secrets-engine)
|
||||
else
|
||||
export PYTHONPATH="src${PYTHONPATH:+:$PYTHONPATH}"
|
||||
SECRETS_ENGINE=(python3 -m secrets_engine.cli)
|
||||
fi
|
||||
|
||||
export SECRETS_ENGINE_EVIDENCE="${SECRETS_ENGINE_EVIDENCE:-/tmp/secrets-engine-whynot-preflight-evidence}"
|
||||
|
||||
echo "### decision"
|
||||
"${SECRETS_ENGINE[@]}" decision inspect "$DECISION_ID"
|
||||
|
||||
echo
|
||||
echo "### publication policy"
|
||||
"${SECRETS_ENGINE[@]}" policy publication "$CATALOG_ID"
|
||||
|
||||
echo
|
||||
echo "### route pointer"
|
||||
"${SECRETS_ENGINE[@]}" route "$CATALOG_ID" --json
|
||||
|
|
@ -35,12 +35,16 @@ def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str
|
|||
applied: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
# 1. KV mount.
|
||||
if client.kv_mount_exists(entry.mount):
|
||||
skipped.append(f"kv-mount {entry.mount} (already present)")
|
||||
# 1. KV mount. Auth-capability lanes grant operational access on an existing
|
||||
# mount (for example ssh/sign/<role>) and never create or store KV values.
|
||||
if entry.stores_kv_value():
|
||||
if client.kv_mount_exists(entry.mount):
|
||||
skipped.append(f"kv-mount {entry.mount} (already present)")
|
||||
else:
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
applied.append(f"kv-mount {entry.mount}")
|
||||
else:
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
applied.append(f"kv-mount {entry.mount}")
|
||||
skipped.append(f"kv-mount {entry.mount} (not applicable for {entry.kind})")
|
||||
|
||||
# 2. Consumer ACL policy (write only if changed).
|
||||
current = client.read_policy(plan.policy_name)
|
||||
|
|
@ -52,7 +56,18 @@ def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str
|
|||
|
||||
# 3. Consumer approle bound to that policy.
|
||||
client.ensure_approle_enabled()
|
||||
client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl)
|
||||
if entry.kind == "auth-capability":
|
||||
client.write_approle(
|
||||
plan.role_name,
|
||||
[plan.policy_name],
|
||||
ttl=entry.token_ttl,
|
||||
max_ttl=entry.token_max_ttl,
|
||||
secret_id_ttl=entry.secret_id_ttl,
|
||||
secret_id_num_uses=entry.secret_id_num_uses,
|
||||
token_num_uses=entry.token_num_uses,
|
||||
)
|
||||
else:
|
||||
client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl)
|
||||
applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]")
|
||||
|
||||
return ApplyResult(applied=applied, skipped=skipped)
|
||||
|
|
|
|||
|
|
@ -16,18 +16,26 @@ import yaml
|
|||
from secrets_engine.errors import CatalogError
|
||||
from secrets_engine.redact import looks_secret
|
||||
|
||||
VALID_KINDS = ("kv", "auth-capability")
|
||||
VALID_STAGES = ("build", "test", "prod")
|
||||
VALID_DELIVERY_MODES = ("exec-env", "exec-file", "npm-config", "wrapped", "read-check")
|
||||
VALID_DELIVERY_MODES = (
|
||||
"exec-env",
|
||||
"exec-file",
|
||||
"npm-config",
|
||||
"wrapped",
|
||||
"read-check",
|
||||
"approle-login",
|
||||
)
|
||||
VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"id",
|
||||
"kind",
|
||||
"org",
|
||||
"repo",
|
||||
"stage",
|
||||
"mount",
|
||||
"path",
|
||||
"fields",
|
||||
"consumers",
|
||||
"delivery_modes",
|
||||
"approval",
|
||||
|
|
@ -41,6 +49,7 @@ REQUIRED_FIELDS = (
|
|||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
id: str
|
||||
kind: str
|
||||
# Gitea coordinates, kept explicit to avoid the overloaded word "project".
|
||||
# org = the Gitea organisation (e.g. "coulomb")
|
||||
# repo = the Gitea repository / product (e.g. "whynot-design")
|
||||
|
|
@ -58,6 +67,7 @@ class CatalogEntry:
|
|||
deactivation: dict[str, Any]
|
||||
audit: dict[str, Any]
|
||||
delivery_config: dict[str, Any] = field(default_factory=dict)
|
||||
auth_capability: dict[str, Any] = field(default_factory=dict)
|
||||
description: str = ""
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
|
@ -83,22 +93,75 @@ class CatalogEntry:
|
|||
|
||||
@property
|
||||
def policy_name(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return self.auth_capability.get("policy_name") or self.id
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
@property
|
||||
def role_name(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return self.auth_capability.get("role_name") or self.id
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
@property
|
||||
def auth_allowed_paths(self) -> dict[str, list[str]]:
|
||||
"""Allowed OpenBao paths/capabilities for an auth-capability lane."""
|
||||
paths: dict[str, list[str]] = {}
|
||||
for item in self.auth_capability.get("allowed_paths", []):
|
||||
paths[item["path"]] = list(item["capabilities"])
|
||||
return paths
|
||||
|
||||
@property
|
||||
def auth_denied_probe_paths(self) -> list[str]:
|
||||
"""Non-secret denial probes proving the lane did not get broader."""
|
||||
return list(self.auth_capability.get("denied_probe_paths", []))
|
||||
|
||||
@property
|
||||
def token_ttl(self) -> str:
|
||||
return str(self.auth_capability.get("token_ttl", self.rotation.get("ttl", "30m")))
|
||||
|
||||
@property
|
||||
def token_max_ttl(self) -> str:
|
||||
return str(self.auth_capability.get("token_max_ttl", self.token_ttl))
|
||||
|
||||
@property
|
||||
def secret_id_ttl(self) -> str:
|
||||
return str(self.auth_capability.get("secret_id_ttl", self.token_ttl))
|
||||
|
||||
@property
|
||||
def secret_id_num_uses(self) -> int:
|
||||
return int(self.auth_capability.get("secret_id_num_uses", 0))
|
||||
|
||||
@property
|
||||
def token_num_uses(self) -> int:
|
||||
return int(self.auth_capability.get("token_num_uses", 0))
|
||||
|
||||
def approval_required(self) -> bool:
|
||||
return self.approval.get("model") != "bootstrap-only"
|
||||
|
||||
def stores_kv_value(self) -> bool:
|
||||
return self.kind == "kv"
|
||||
|
||||
|
||||
def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> CatalogEntry:
|
||||
"""Validate a raw mapping and return a CatalogEntry, or raise CatalogError."""
|
||||
if not isinstance(data, dict):
|
||||
raise CatalogError(f"{source}: catalog entry must be a mapping")
|
||||
|
||||
data = dict(data)
|
||||
data.setdefault("kind", "kv")
|
||||
if data["kind"] not in VALID_KINDS:
|
||||
raise CatalogError(
|
||||
f"{source}: kind '{data['kind']}' invalid; must be one of {VALID_KINDS}"
|
||||
)
|
||||
if data["kind"] == "auth-capability":
|
||||
data.setdefault("fields", [])
|
||||
else:
|
||||
data.setdefault("auth_capability", {})
|
||||
|
||||
missing = [k for k in REQUIRED_FIELDS if k not in data or data[k] in (None, "", [], {})]
|
||||
if data["kind"] == "kv" and not data.get("fields"):
|
||||
missing.append("fields")
|
||||
if missing:
|
||||
raise CatalogError(f"{source}: missing required fields: {', '.join(missing)}")
|
||||
|
||||
|
|
@ -120,6 +183,8 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
fields = data["fields"]
|
||||
if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
|
||||
raise CatalogError(f"{source}: fields must be a list of strings")
|
||||
if data["kind"] == "auth-capability" and fields:
|
||||
raise CatalogError(f"{source}: auth-capability lanes must not declare KV fields")
|
||||
|
||||
consumers = data["consumers"]
|
||||
if not isinstance(consumers, list) or not consumers:
|
||||
|
|
@ -153,6 +218,35 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
f"{source}: delivery_config.npm.registry must be an http(s) URL"
|
||||
)
|
||||
|
||||
if data["kind"] == "auth-capability":
|
||||
cfg = data.get("auth_capability")
|
||||
if not isinstance(cfg, dict) or not cfg.get("allowed_paths"):
|
||||
raise CatalogError(
|
||||
f"{source}: auth-capability lanes require auth_capability.allowed_paths"
|
||||
)
|
||||
if "approle-login" not in modes:
|
||||
raise CatalogError(
|
||||
f"{source}: auth-capability lanes require approle-login delivery mode"
|
||||
)
|
||||
allowed = cfg["allowed_paths"]
|
||||
if not isinstance(allowed, list) or not allowed:
|
||||
raise CatalogError(f"{source}: auth_capability.allowed_paths must be a list")
|
||||
for item in allowed:
|
||||
if not isinstance(item, dict) or "path" not in item or "capabilities" not in item:
|
||||
raise CatalogError(
|
||||
f"{source}: each allowed path needs path and capabilities"
|
||||
)
|
||||
caps = item["capabilities"]
|
||||
if not isinstance(caps, list) or not all(isinstance(c, str) for c in caps):
|
||||
raise CatalogError(
|
||||
f"{source}: auth_capability.allowed_paths capabilities must be strings"
|
||||
)
|
||||
denied = cfg.get("denied_probe_paths", [])
|
||||
if not isinstance(denied, list) or not all(isinstance(p, str) for p in denied):
|
||||
raise CatalogError(
|
||||
f"{source}: auth_capability.denied_probe_paths must be a list of strings"
|
||||
)
|
||||
|
||||
# A path must never leak a value through a field name suggesting inline secrets.
|
||||
if any(looks_secret(k) and data.get(k) for k in ("value", "secret", "token", "password")):
|
||||
raise CatalogError(f"{source}: catalog entries must not contain secret values")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ Command surface (FR7):
|
|||
plan <decision-or-ref> --stage <stage>
|
||||
apply <decision-or-ref> --stage <stage> [--dry-run] [--bootstrap-token-file F]
|
||||
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
|
||||
verify <catalog-id> [--positive] [--negative] --field NAME
|
||||
verify <catalog-id> [--positive] [--negative] [--field NAME]
|
||||
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
||||
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
||||
route <catalog-id> [--json]
|
||||
revoke <catalog-id>
|
||||
|
|
@ -26,7 +27,7 @@ from secrets_engine.apply import apply_plan
|
|||
from secrets_engine.catalog import get_entry, load_catalog
|
||||
from secrets_engine.config import Config, repo_root
|
||||
from secrets_engine.decisions import require_approved, resolve_decision
|
||||
from secrets_engine.errors import SecretsEngineError
|
||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||
from secrets_engine.evidence import EvidenceWriter
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.plan import build_plan
|
||||
|
|
@ -71,16 +72,24 @@ def cmd_catalog_list(cfg: Config, args) -> int:
|
|||
print("(no catalog entries)")
|
||||
return 0
|
||||
for e in entries.values():
|
||||
print(f"{e.id:32s} stage={e.stage:5s} owner={e.owner:18s} {e.mount}/{e.path}")
|
||||
print(
|
||||
f"{e.id:32s} kind={e.kind:15s} stage={e.stage:5s} "
|
||||
f"owner={e.owner:18s} {e.mount}/{e.path}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_catalog_show(cfg: Config, args) -> int:
|
||||
e = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
print(f"id: {e.id}")
|
||||
print(f"kind: {e.kind}")
|
||||
print(f"owner: {e.owner}")
|
||||
print(f"stage: {e.stage}")
|
||||
print(f"openbao: {e.mount}/{e.path} fields={e.fields}")
|
||||
if e.kind == "auth-capability":
|
||||
print(f"openbao: mount={e.mount} allowed={sorted(e.auth_allowed_paths)}")
|
||||
print(f"approle: {e.role_name} policy={e.policy_name}")
|
||||
else:
|
||||
print(f"openbao: {e.mount}/{e.path} fields={e.fields}")
|
||||
print(f"consumers: {[c['name'] for c in e.consumers]}")
|
||||
print(f"delivery: {e.delivery_modes}")
|
||||
print(f"approval: {e.approval.get('model')} ref={e.approval.get('decision_ref','')}")
|
||||
|
|
@ -111,11 +120,13 @@ def cmd_plan(cfg: Config, args) -> int:
|
|||
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
|
||||
decision = None
|
||||
if entry.approval_required():
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", args.ref),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
try:
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", args.ref),
|
||||
)
|
||||
except DecisionError:
|
||||
decision = None
|
||||
plan = build_plan(entry, args.stage, decision_id=decision.id if decision else "")
|
||||
print(plan.render())
|
||||
_writer(cfg).record(
|
||||
|
|
@ -129,11 +140,16 @@ def cmd_apply(cfg: Config, args) -> int:
|
|||
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
|
||||
decision = None
|
||||
if entry.approval_required():
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", args.ref),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
try:
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", args.ref),
|
||||
)
|
||||
except DecisionError:
|
||||
if not args.dry_run:
|
||||
raise
|
||||
if not args.dry_run:
|
||||
require_approved(entry, decision)
|
||||
plan = build_plan(entry, args.stage, decision_id=decision.id if decision else "")
|
||||
w = _writer(cfg)
|
||||
if args.dry_run:
|
||||
|
|
@ -174,6 +190,9 @@ def cmd_verify(cfg: Config, args) -> int:
|
|||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||
if entry.stores_kv_value() and not field:
|
||||
from secrets_engine.errors import VerificationError
|
||||
raise VerificationError(f"lane '{entry.id}' has no field to verify")
|
||||
positive = args.positive or not args.negative
|
||||
negative = args.negative or not args.positive
|
||||
results = run_verification(client, entry, field, positive=positive, negative=negative)
|
||||
|
|
@ -187,6 +206,53 @@ def cmd_verify(cfg: Config, args) -> int:
|
|||
return rc
|
||||
|
||||
|
||||
def cmd_handoff(cfg: Config, args) -> int:
|
||||
from secrets_engine.errors import ProvisioningError
|
||||
from secrets_engine.handoff import write_approle_handoff
|
||||
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
if args.stage != entry.stage:
|
||||
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
|
||||
if entry.kind != "auth-capability":
|
||||
raise ProvisioningError(f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability")
|
||||
decision = None
|
||||
if entry.approval_required():
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
result = write_approle_handoff(
|
||||
client,
|
||||
entry,
|
||||
role_id_file=Path(args.role_id_file),
|
||||
secret_id_file=Path(args.secret_id_file),
|
||||
)
|
||||
print(f"wrote AppRole handoff material for lane '{entry.id}' — secret_id not displayed")
|
||||
print(f" role: {result.role_name}")
|
||||
print(f" role_id_file: {result.role_id_file}")
|
||||
print(f" secret_id_file: {result.secret_id_file}")
|
||||
print(f" token_ttl: {result.token_ttl}")
|
||||
print(f" secret_id_ttl: {result.secret_id_ttl}")
|
||||
_writer(cfg).record(
|
||||
"handoff",
|
||||
result="secret-id-written",
|
||||
catalog_id=entry.id,
|
||||
stage=entry.stage,
|
||||
decision_id=decision.id if decision else "",
|
||||
detail={
|
||||
"role": result.role_name,
|
||||
"role_id_file": result.role_id_file,
|
||||
"secret_id_file": result.secret_id_file,
|
||||
"token_ttl": result.token_ttl,
|
||||
"secret_id_ttl": result.secret_id_ttl,
|
||||
"secret_id_num_uses": result.secret_id_num_uses,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_exec(cfg: Config, args) -> int:
|
||||
from secrets_engine.exec_delivery import exec_with_secret
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog)
|
||||
|
|
@ -240,9 +306,13 @@ def cmd_route(cfg: Config, args) -> int:
|
|||
import json
|
||||
print(json.dumps(result.to_json(), indent=2))
|
||||
else:
|
||||
print(f"lane: {result.catalog_id} (owner={result.owner}, stage={result.stage})")
|
||||
print(
|
||||
f"lane: {result.catalog_id} "
|
||||
f"(kind={result.kind}, owner={result.owner}, stage={result.stage})"
|
||||
)
|
||||
print(f"decision: {result.decision_status} ref={result.decision_ref}")
|
||||
print(f"applied: {result.metadata_applied} value_present: {result.value_present}")
|
||||
material_label = "handoff_ready" if result.kind == "auth-capability" else "value_present"
|
||||
print(f"applied: {result.metadata_applied} {material_label}: {result.value_present}")
|
||||
print(f"ready: {result.ready}")
|
||||
if result.missing:
|
||||
print(f"missing: {result.missing}")
|
||||
|
|
@ -253,6 +323,21 @@ def cmd_route(cfg: Config, args) -> int:
|
|||
def cmd_revoke(cfg: Config, args) -> int:
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
if entry.kind == "auth-capability":
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"(dry-run) would delete approle {entry.role_name} "
|
||||
f"and policy {entry.policy_name}"
|
||||
)
|
||||
return 0
|
||||
client.delete_approle(entry.role_name)
|
||||
client.delete_policy(entry.policy_name)
|
||||
print(
|
||||
f"revoked lane '{entry.id}': deleted approle {entry.role_name} "
|
||||
f"and policy {entry.policy_name}"
|
||||
)
|
||||
_writer(cfg).record("revoke", result="auth-capability-deactivated", catalog_id=entry.id, stage=entry.stage)
|
||||
return 0
|
||||
if args.dry_run:
|
||||
print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} "
|
||||
f"and approle {entry.role_name}")
|
||||
|
|
@ -318,6 +403,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
add_token_arg(ve)
|
||||
ve.set_defaults(func=cmd_verify)
|
||||
|
||||
ha = sub.add_parser("handoff", help="write AppRole role_id/secret_id handoff files")
|
||||
ha.add_argument("catalog_id")
|
||||
ha.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
||||
ha.add_argument("--role-id-file", required=True)
|
||||
ha.add_argument("--secret-id-file", required=True)
|
||||
add_token_arg(ha)
|
||||
ha.set_defaults(func=cmd_handoff)
|
||||
|
||||
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only")
|
||||
ex.add_argument("--catalog", required=True)
|
||||
ex.add_argument("--field", default=None)
|
||||
|
|
|
|||
96
src/secrets_engine/handoff.py
Normal file
96
src/secrets_engine/handoff.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Out-of-band AppRole handoff helpers for auth-capability lanes.
|
||||
|
||||
The secret_id is secret material. It is minted only after output paths have been
|
||||
validated, written to a mode-0600 file outside any Git worktree, and never
|
||||
printed or recorded in evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from secrets_engine.catalog import CatalogEntry
|
||||
from secrets_engine.errors import ProvisioningError
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HandoffResult:
|
||||
role_name: str
|
||||
role_id_file: str
|
||||
secret_id_file: str
|
||||
token_ttl: str
|
||||
secret_id_ttl: str
|
||||
secret_id_num_uses: int
|
||||
|
||||
|
||||
def _assert_outside_git_worktree(path: Path) -> Path:
|
||||
resolved = path.expanduser().resolve()
|
||||
for parent in (resolved.parent, *resolved.parent.parents):
|
||||
if (parent / ".git").exists():
|
||||
raise ProvisioningError(
|
||||
f"handoff file {resolved} is inside a Git worktree ({parent}); "
|
||||
"keep AppRole material outside repos"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _validate_output_path(path: Path) -> Path:
|
||||
resolved = _assert_outside_git_worktree(path)
|
||||
if resolved.exists() and resolved.stat().st_mode & 0o077:
|
||||
raise ProvisioningError(
|
||||
f"handoff file {resolved} is group/other-accessible "
|
||||
f"(mode {oct(resolved.stat().st_mode & 0o777)}); must be 0600"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _write_mode_0600(path: Path, value: str) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd: int | None = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fd = None
|
||||
fh.write(value)
|
||||
fh.write("\n")
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def write_approle_handoff(
|
||||
client: OpenBaoClient,
|
||||
entry: CatalogEntry,
|
||||
*,
|
||||
role_id_file: Path,
|
||||
secret_id_file: Path,
|
||||
) -> HandoffResult:
|
||||
"""Mint and write AppRole handoff material without printing the secret_id."""
|
||||
if entry.kind != "auth-capability":
|
||||
raise ProvisioningError(
|
||||
f"lane '{entry.id}' is {entry.kind}; handoff is only for auth-capability lanes"
|
||||
)
|
||||
|
||||
role_path = _validate_output_path(role_id_file)
|
||||
secret_path = _validate_output_path(secret_id_file)
|
||||
if role_path == secret_path:
|
||||
raise ProvisioningError("role_id_file and secret_id_file must be different")
|
||||
|
||||
role_id = client.read_approle_role_id(entry.role_name)
|
||||
secret_id = client.create_approle_secret_id(entry.role_name)
|
||||
try:
|
||||
_write_mode_0600(role_path, role_id)
|
||||
_write_mode_0600(secret_path, secret_id)
|
||||
finally:
|
||||
del secret_id
|
||||
|
||||
return HandoffResult(
|
||||
role_name=entry.role_name,
|
||||
role_id_file=str(role_path),
|
||||
secret_id_file=str(secret_path),
|
||||
token_ttl=entry.token_ttl,
|
||||
secret_id_ttl=entry.secret_id_ttl,
|
||||
secret_id_num_uses=entry.secret_id_num_uses,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}'; "
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ STAGE_PREFIX = {
|
|||
# Capabilities a stage role's *own* policy may carry. Anything else is broad.
|
||||
ALLOWED_CAPABILITIES = {"create", "read", "update", "delete", "list"}
|
||||
|
||||
# Auth-capability lanes grant an operational action, not KV value access. Keep
|
||||
# this intentionally smaller than the stage-role capability set.
|
||||
AUTH_CAPABILITY_ALLOWED_CAPABILITIES = {"update"}
|
||||
|
||||
# Substrings that, if they appear in a policy path, mean the plan is too broad.
|
||||
FORBIDDEN_PATH_MARKERS = (
|
||||
"sys/",
|
||||
|
|
@ -110,6 +114,63 @@ def assert_policy_safe(policy_name: str, paths: dict[str, list[str]]) -> None:
|
|||
)
|
||||
|
||||
|
||||
def assert_auth_capability_safe(
|
||||
policy_name: str,
|
||||
mount: str,
|
||||
paths: dict[str, list[str]],
|
||||
denied_probe_paths: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Reject auth-capability grants that are broader than exact operations."""
|
||||
lowered = policy_name.lower()
|
||||
for marker in FORBIDDEN_NAME_MARKERS:
|
||||
if marker in lowered:
|
||||
raise PolicyGuardError(
|
||||
f"policy name '{policy_name}' resembles broad admin (marker '{marker}')"
|
||||
)
|
||||
if not paths:
|
||||
raise PolicyGuardError(f"policy '{policy_name}': no auth-capability paths")
|
||||
|
||||
mount_prefix = f"{mount}/"
|
||||
for path, caps in paths.items():
|
||||
if "*" in path or "+" in path or path.strip() in ("*", "/", "+"):
|
||||
raise PolicyGuardError(
|
||||
f"policy '{policy_name}': wildcard auth path '{path}' not allowed"
|
||||
)
|
||||
for marker in FORBIDDEN_PATH_MARKERS:
|
||||
if marker in path:
|
||||
raise PolicyGuardError(
|
||||
f"policy '{policy_name}': path '{path}' is out of bounds "
|
||||
f"(marker '{marker}')"
|
||||
)
|
||||
if not path.startswith(mount_prefix):
|
||||
raise PolicyGuardError(
|
||||
f"policy '{policy_name}': auth path '{path}' is outside mount '{mount}'"
|
||||
)
|
||||
if mount == "ssh":
|
||||
parts = path.split("/")
|
||||
if len(parts) != 3 or parts[1] != "sign" or not parts[2]:
|
||||
raise PolicyGuardError(
|
||||
f"policy '{policy_name}': ssh auth-capability path '{path}' "
|
||||
"must be exactly ssh/sign/<role>"
|
||||
)
|
||||
bad_caps = set(caps) - AUTH_CAPABILITY_ALLOWED_CAPABILITIES
|
||||
if bad_caps:
|
||||
raise PolicyGuardError(
|
||||
f"policy '{policy_name}': capabilities {sorted(bad_caps)} not allowed "
|
||||
f"for auth-capability (allowed {sorted(AUTH_CAPABILITY_ALLOWED_CAPABILITIES)})"
|
||||
)
|
||||
|
||||
for path in denied_probe_paths or []:
|
||||
if path in paths:
|
||||
raise PolicyGuardError(
|
||||
f"policy '{policy_name}': denied probe '{path}' is also allowed"
|
||||
)
|
||||
if "*" in path or "+" in path:
|
||||
raise PolicyGuardError(
|
||||
f"policy '{policy_name}': denied probe '{path}' must be exact"
|
||||
)
|
||||
|
||||
|
||||
def lane_policy_paths(entry: CatalogEntry) -> dict[str, list[str]]:
|
||||
"""The minimal KV v2 paths + capabilities a consumer policy needs for a lane."""
|
||||
data_path = f"{entry.mount}/data/{entry.path}"
|
||||
|
|
@ -136,3 +197,43 @@ def consumer_policy_for(entry: CatalogEntry) -> tuple[str, str]:
|
|||
paths = lane_policy_paths(entry)
|
||||
name = entry.policy_name
|
||||
return name, render_policy_hcl(name, paths)
|
||||
|
||||
|
||||
def auth_capability_policy_paths(entry: CatalogEntry) -> dict[str, list[str]]:
|
||||
"""The exact operational paths an auth-capability lane may exercise."""
|
||||
paths = entry.auth_allowed_paths
|
||||
assert_auth_capability_safe(
|
||||
entry.policy_name, entry.mount, paths, entry.auth_denied_probe_paths
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def render_auth_capability_policy_hcl(
|
||||
policy_name: str,
|
||||
mount: str,
|
||||
paths: dict[str, list[str]],
|
||||
denied_probe_paths: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Render a narrow auth-capability ACL policy in HCL."""
|
||||
denied_probe_paths = denied_probe_paths or []
|
||||
assert_auth_capability_safe(policy_name, mount, paths, denied_probe_paths)
|
||||
blocks = [
|
||||
f'# Generated by secrets-engine for auth-capability policy "{policy_name}"',
|
||||
"# Exact allowlist below. Every path outside it is denied by OpenBao default.",
|
||||
]
|
||||
if denied_probe_paths:
|
||||
blocks.append("# Denial probes expected to lack update capability:")
|
||||
blocks.extend(f"# - {path}" for path in denied_probe_paths)
|
||||
for path, caps in paths.items():
|
||||
cap_list = ", ".join(f'"{c}"' for c in caps)
|
||||
blocks.append(f'path "{path}" {{\n capabilities = [{cap_list}]\n}}')
|
||||
return "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
def auth_capability_policy_for(entry: CatalogEntry) -> tuple[str, str]:
|
||||
"""Return (policy_name, hcl) for a non-KV auth-capability lane."""
|
||||
paths = auth_capability_policy_paths(entry)
|
||||
name = entry.policy_name
|
||||
return name, render_auth_capability_policy_hcl(
|
||||
name, entry.mount, paths, entry.auth_denied_probe_paths
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from secrets_engine.openbao import OpenBaoClient
|
|||
@dataclass
|
||||
class RouteResult:
|
||||
catalog_id: str
|
||||
kind: str
|
||||
owner: str
|
||||
stage: str
|
||||
decision_status: str
|
||||
|
|
@ -59,13 +60,20 @@ def route_lane(
|
|||
metadata_applied = False
|
||||
value_present = False
|
||||
if client is not None and client.is_reachable():
|
||||
metadata_applied = client.read_policy(entry.policy_name) is not None
|
||||
# Presence check uses the engine's own token; reports boolean only.
|
||||
field = entry.fields[0] if entry.fields else ""
|
||||
if field:
|
||||
value_present = client.kv_field_present(entry.mount, entry.path, field)
|
||||
policy_applied = client.read_policy(entry.policy_name) is not None
|
||||
role_applied = client.approle_exists(entry.role_name)
|
||||
metadata_applied = policy_applied and role_applied
|
||||
if entry.stores_kv_value():
|
||||
# Presence check uses the engine's own token; reports boolean only.
|
||||
field = entry.fields[0] if entry.fields else ""
|
||||
if field:
|
||||
value_present = client.kv_field_present(entry.mount, entry.path, field)
|
||||
else:
|
||||
# Auth-capability lanes have no stored value; a fresh secret_id is minted
|
||||
# on demand through the handoff command once metadata exists.
|
||||
value_present = metadata_applied
|
||||
|
||||
approved = decision is None or decision.is_approved()
|
||||
approved = not entry.approval_required() or (decision is not None and decision.is_approved())
|
||||
ready = approved and metadata_applied and value_present
|
||||
|
||||
if not approved:
|
||||
|
|
@ -74,6 +82,12 @@ def route_lane(
|
|||
elif not metadata_applied:
|
||||
missing = "OpenBao policy/role apply"
|
||||
next_command = f"secrets-engine apply {decision_ref or entry.id} --stage {entry.stage}"
|
||||
elif entry.kind == "auth-capability":
|
||||
missing = ""
|
||||
next_command = (
|
||||
f"secrets-engine handoff {entry.id} --stage {entry.stage} "
|
||||
"--role-id-file <path> --secret-id-file <path>"
|
||||
)
|
||||
elif not value_present:
|
||||
missing = "provisioned secret value"
|
||||
next_command = (
|
||||
|
|
@ -86,6 +100,7 @@ def route_lane(
|
|||
|
||||
return RouteResult(
|
||||
catalog_id=entry.id,
|
||||
kind=entry.kind,
|
||||
owner=entry.owner,
|
||||
stage=entry.stage,
|
||||
decision_status=decision_status,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
235
tests/test_auth_capability.py
Normal file
235
tests/test_auth_capability.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.apply import apply_plan
|
||||
from secrets_engine.catalog import load_catalog, validate_entry
|
||||
from secrets_engine.config import repo_root
|
||||
from secrets_engine.errors import PolicyGuardError, ProvisioningError
|
||||
from secrets_engine.handoff import write_approle_handoff
|
||||
from secrets_engine.plan import build_plan
|
||||
from secrets_engine.provision import provision_from_file
|
||||
from secrets_engine.routing import route_lane
|
||||
from secrets_engine.verify import run_verification
|
||||
|
||||
AUTH = {
|
||||
"id": "warden-sign",
|
||||
"kind": "auth-capability",
|
||||
"org": "netkingdom",
|
||||
"repo": "ops-warden",
|
||||
"stage": "prod",
|
||||
"mount": "ssh",
|
||||
"path": "sign",
|
||||
"consumers": [
|
||||
{"name": "ops-warden-policy-smoke", "auth": "approle", "claim": "agent:agt"}
|
||||
],
|
||||
"delivery_modes": ["approle-login"],
|
||||
"approval": {"model": "decision", "decision_ref": "SECRETS-WP-0004"},
|
||||
"verification": {"positive": "allowlisted sign", "negative": "deny probes"},
|
||||
"auth_capability": {
|
||||
"policy_name": "warden-sign",
|
||||
"role_name": "warden-sign",
|
||||
"token_ttl": "15m",
|
||||
"token_max_ttl": "15m",
|
||||
"secret_id_ttl": "30m",
|
||||
"secret_id_num_uses": 1,
|
||||
"token_num_uses": 0,
|
||||
"allowed_paths": [
|
||||
{"path": "ssh/sign/agt-role", "capabilities": ["update"]},
|
||||
{"path": "ssh/sign/adm-role", "capabilities": ["update"]},
|
||||
{"path": "ssh/sign/atm-role", "capabilities": ["update"]},
|
||||
],
|
||||
"denied_probe_paths": ["ssh/sign/unlisted-role", "ssh/roles/agt-role"],
|
||||
},
|
||||
"rotation": {"expectation": "single-use handoff", "ttl": "15m"},
|
||||
"deactivation": {"expectation": "delete approle and policy"},
|
||||
"audit": {"evidence": "non-secret pointers only"},
|
||||
}
|
||||
|
||||
|
||||
def _auth_entry(**over):
|
||||
data = copy.deepcopy(AUTH)
|
||||
for key, value in over.items():
|
||||
if key == "auth_capability":
|
||||
data[key].update(value)
|
||||
else:
|
||||
data[key] = value
|
||||
return validate_entry(data)
|
||||
|
||||
|
||||
def test_auth_capability_entry_parses_without_fields():
|
||||
entry = _auth_entry()
|
||||
assert entry.kind == "auth-capability"
|
||||
assert entry.fields == []
|
||||
assert entry.policy_name == "warden-sign"
|
||||
assert entry.role_name == "warden-sign"
|
||||
assert sorted(entry.auth_allowed_paths) == [
|
||||
"ssh/sign/adm-role",
|
||||
"ssh/sign/agt-role",
|
||||
"ssh/sign/atm-role",
|
||||
]
|
||||
|
||||
|
||||
def test_repo_catalog_loads_warden_sign_lane():
|
||||
entries = load_catalog(repo_root() / "catalog")
|
||||
entry = entries["warden-sign"]
|
||||
assert entry.kind == "auth-capability"
|
||||
assert entry.mount == "ssh"
|
||||
assert entry.token_ttl == "15m"
|
||||
assert entry.secret_id_num_uses == 1
|
||||
|
||||
|
||||
def test_auth_capability_rejects_kv_fields():
|
||||
data = copy.deepcopy(AUTH)
|
||||
data["fields"] = ["token"]
|
||||
with pytest.raises(Exception):
|
||||
validate_entry(data)
|
||||
|
||||
|
||||
def test_auth_capability_plan_has_no_kv_mount():
|
||||
entry = _auth_entry()
|
||||
plan = build_plan(entry, "prod", decision_id="d1")
|
||||
assert not any(action.kind == "kv-mount" for action in plan.actions)
|
||||
assert any(action.kind == "policy" and action.target == "warden-sign" for action in plan.actions)
|
||||
assert "ssh/sign/agt-role" in plan.policy_hcl
|
||||
assert "ssh/sign/adm-role" in plan.policy_hcl
|
||||
assert "ssh/sign/atm-role" in plan.policy_hcl
|
||||
assert "secret/data" not in plan.policy_hcl
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"ssh/*",
|
||||
"ssh/sign/*",
|
||||
"ssh/roles/agt-role",
|
||||
"sys/policies/acl/warden-sign",
|
||||
"auth/token/create",
|
||||
"identity/entity/id",
|
||||
],
|
||||
)
|
||||
def test_auth_capability_broad_or_admin_paths_refused(path):
|
||||
entry = _auth_entry(
|
||||
auth_capability={
|
||||
"allowed_paths": [{"path": path, "capabilities": ["update"]}],
|
||||
}
|
||||
)
|
||||
with pytest.raises(PolicyGuardError):
|
||||
build_plan(entry, "prod")
|
||||
|
||||
|
||||
def test_auth_capability_root_like_policy_name_refused():
|
||||
entry = _auth_entry(auth_capability={"policy_name": "root-warden-sign"})
|
||||
with pytest.raises(PolicyGuardError):
|
||||
build_plan(entry, "prod")
|
||||
|
||||
|
||||
def test_auth_capability_provision_rejected(tmp_path):
|
||||
value_file = tmp_path / "value"
|
||||
value_file.write_text("not-real-secret")
|
||||
os.chmod(value_file, 0o600)
|
||||
with pytest.raises(ProvisioningError):
|
||||
provision_from_file(object(), _auth_entry(), "", value_file)
|
||||
|
||||
|
||||
class FakeApplyClient:
|
||||
def __init__(self):
|
||||
self.policy = ""
|
||||
self.approle = None
|
||||
|
||||
def kv_mount_exists(self, mount):
|
||||
raise AssertionError("auth-capability apply must not inspect KV mounts")
|
||||
|
||||
def ensure_kv_mount(self, mount):
|
||||
raise AssertionError("auth-capability apply must not create KV mounts")
|
||||
|
||||
def read_policy(self, name):
|
||||
return None
|
||||
|
||||
def write_policy(self, name, hcl):
|
||||
self.policy = hcl
|
||||
|
||||
def ensure_approle_enabled(self):
|
||||
pass
|
||||
|
||||
def write_approle(self, role_name, policies, ttl="30m", **kwargs):
|
||||
self.approle = (role_name, policies, ttl, kwargs)
|
||||
|
||||
|
||||
def test_apply_auth_capability_bypasses_kv_and_writes_ttl_options():
|
||||
entry = _auth_entry()
|
||||
plan = build_plan(entry, "prod", decision_id="d1")
|
||||
client = FakeApplyClient()
|
||||
result = apply_plan(client, entry, plan)
|
||||
assert any("not applicable" in item for item in result.skipped)
|
||||
assert "ssh/sign/agt-role" in client.policy
|
||||
assert client.approle[0] == "warden-sign"
|
||||
assert client.approle[2] == "15m"
|
||||
assert client.approle[3]["secret_id_num_uses"] == 1
|
||||
assert client.approle[3]["token_num_uses"] == 0
|
||||
|
||||
|
||||
class FakeVerifyClient:
|
||||
def approle_login_token(self, role_name):
|
||||
assert role_name == "warden-sign"
|
||||
return "test-token"
|
||||
|
||||
def token_capabilities(self, path, *, token):
|
||||
assert token == "test-token"
|
||||
if path.startswith("ssh/sign/") and path != "ssh/sign/unlisted-role":
|
||||
return ["update"]
|
||||
return ["deny"]
|
||||
|
||||
|
||||
def test_auth_capability_verification_uses_capability_probes():
|
||||
results = run_verification(
|
||||
FakeVerifyClient(), _auth_entry(), "", positive=True, negative=True
|
||||
)
|
||||
assert [result.passed for result in results] == [True, True]
|
||||
|
||||
|
||||
class FakeHandoffClient:
|
||||
def read_approle_role_id(self, role_name):
|
||||
assert role_name == "warden-sign"
|
||||
return "role-id-test"
|
||||
|
||||
def create_approle_secret_id(self, role_name):
|
||||
assert role_name == "warden-sign"
|
||||
return "secret-id-test"
|
||||
|
||||
|
||||
def test_handoff_rejects_repo_paths():
|
||||
with pytest.raises(ProvisioningError):
|
||||
write_approle_handoff(
|
||||
FakeHandoffClient(),
|
||||
_auth_entry(),
|
||||
role_id_file=Path("role-id.out"),
|
||||
secret_id_file=Path("secret-id.out"),
|
||||
)
|
||||
|
||||
|
||||
def test_handoff_writes_mode_0600_files(tmp_path):
|
||||
role_file = tmp_path / "role-id"
|
||||
secret_file = tmp_path / "secret-id"
|
||||
result = write_approle_handoff(
|
||||
FakeHandoffClient(),
|
||||
_auth_entry(),
|
||||
role_id_file=role_file,
|
||||
secret_id_file=secret_file,
|
||||
)
|
||||
assert result.role_name == "warden-sign"
|
||||
assert role_file.read_text().strip() == "role-id-test"
|
||||
assert secret_file.read_text().strip() == "secret-id-test"
|
||||
assert (role_file.stat().st_mode & 0o077) == 0
|
||||
assert (secret_file.stat().st_mode & 0o077) == 0
|
||||
|
||||
|
||||
def test_route_missing_decision_blocks_auth_capability(tmp_path):
|
||||
result = route_lane(_auth_entry(), hub_url="", repo_root=tmp_path, client=None)
|
||||
assert result.kind == "auth-capability"
|
||||
assert result.decision_status == "missing"
|
||||
assert result.ready is False
|
||||
assert result.missing == "approved decision for 'SECRETS-WP-0004'"
|
||||
assert result.next_command == "secrets-engine decision inspect SECRETS-WP-0004"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue