From 784be978bf6f5f00f6a4fd7d11f59e335cdfd976 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 21 Aug 2026 08:20:33 +0200 Subject: [PATCH] feat: admit existing OpenBao catalog lanes --- README.md | 1 + WORK-RECORDS.md | 9 + catalog/email-connect-transactional.yaml | 78 ++++++ catalog/forgejo-admin-api-token.yaml | 77 ++++++ catalog/issue-core-ingestion-api-key.yaml | 78 ++++++ catalog/openrouter-llm-connect.yaml | 72 ++++++ catalog/reuse-surface-hub-write-token.yaml | 77 ++++++ docs/catalog-admission.md | 121 ++++++++++ docs/cli.md | 6 + src/secrets_engine/apply.py | 56 +++-- src/secrets_engine/catalog.py | 154 ++++++++++++ src/secrets_engine/cli.py | 60 +++-- src/secrets_engine/exec_delivery.py | 8 + src/secrets_engine/handoff.py | 13 +- src/secrets_engine/openbao.py | 13 +- src/secrets_engine/plan.py | 69 ++++-- src/secrets_engine/provision.py | 19 +- src/secrets_engine/routing.py | 27 ++- src/secrets_engine/safe_paths.py | 20 ++ src/secrets_engine/verify.py | 10 + tests/test_catalog.py | 80 ++++++ tests/test_decisions.py | 22 ++ tests/test_exec_delivery.py | 50 +++- tests/test_existing_lane_admission.py | 117 +++++++++ tests/test_guards.py | 49 ++++ tests/test_integration_bao.py | 2 +- tests/test_safe_paths.py | 21 ++ workplans/ADHOC-2026-08-21.md | 32 +++ .../SECRETS-WP-0006-catalog-lane-adoption.md | 228 ++++++++++++++++++ 29 files changed, 1490 insertions(+), 79 deletions(-) create mode 100644 catalog/email-connect-transactional.yaml create mode 100644 catalog/forgejo-admin-api-token.yaml create mode 100644 catalog/issue-core-ingestion-api-key.yaml create mode 100644 catalog/openrouter-llm-connect.yaml create mode 100644 catalog/reuse-surface-hub-write-token.yaml create mode 100644 docs/catalog-admission.md create mode 100644 src/secrets_engine/safe_paths.py create mode 100644 tests/test_existing_lane_admission.py create mode 100644 tests/test_safe_paths.py create mode 100644 workplans/ADHOC-2026-08-21.md create mode 100644 workplans/SECRETS-WP-0006-catalog-lane-adoption.md diff --git a/README.md b/README.md index f3581dd..5b247fc 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ SECRETS_ENGINE_HUB_URL="" bash scripts/demo-e2e.sh - 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) +- Existing-lane catalog admission: [docs/catalog-admission.md](docs/catalog-admission.md) The implementation is a Python package (`src/secrets_engine/`). OpenBao is reached only through the `bao` CLI adapter (`openbao.py`); the rest of the code diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 4b480d6..d80bbe3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,10 +8,13 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | +| workplan | ADHOC-2026-08-21 | finished | — | workplans/ADHOC-2026-08-21.md | | workplan | SECRETS-WP-0001 | finished | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | | workplan | SECRETS-WP-0002 | finished | — | workplans/SECRETS-WP-0002-bootstrap.md | | workplan | SECRETS-WP-0004 | finished | — | workplans/SECRETS-WP-0004-warden-sign-token-lane.md | | workplan | SECRETS-WP-0005 | finished | — | workplans/SECRETS-WP-0005-scope-intent-value-gaps.md | +| workplan | SECRETS-WP-0006 | active | — | workplans/SECRETS-WP-0006-catalog-lane-adoption.md | +| task | ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md | | task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | | task | SECRETS-WP-0001-T02 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | | task | SECRETS-WP-0001-T03 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | @@ -37,3 +40,9 @@ | task | SECRETS-WP-0005-T05 | done | — | workplans/SECRETS-WP-0005-scope-intent-value-gaps.md | | task | SECRETS-WP-0005-T06 | done | — | workplans/SECRETS-WP-0005-scope-intent-value-gaps.md | | task | SECRETS-WP-0005-T07 | done | — | workplans/SECRETS-WP-0005-scope-intent-value-gaps.md | +| task | SECRETS-WP-0006-T01 | done | — | workplans/SECRETS-WP-0006-catalog-lane-adoption.md | +| task | SECRETS-WP-0006-T02 | done | — | workplans/SECRETS-WP-0006-catalog-lane-adoption.md | +| task | SECRETS-WP-0006-T03 | progress | — | workplans/SECRETS-WP-0006-catalog-lane-adoption.md | +| task | SECRETS-WP-0006-T04 | done | — | workplans/SECRETS-WP-0006-catalog-lane-adoption.md | +| task | SECRETS-WP-0006-T05 | wait | — | workplans/SECRETS-WP-0006-catalog-lane-adoption.md | +| task | SECRETS-WP-0006-T06 | wait | — | workplans/SECRETS-WP-0006-catalog-lane-adoption.md | diff --git a/catalog/email-connect-transactional.yaml b/catalog/email-connect-transactional.yaml new file mode 100644 index 0000000..58d41d6 --- /dev/null +++ b/catalog/email-connect-transactional.yaml @@ -0,0 +1,78 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0010. +id: email-connect-transactional +kind: kv +org: coulomb +repo: email-connect +stage: prod +description: >- + IONOS transactional SMTP identity and the shared user-engine ingest bearer for + email-connect. user-engine must never receive the SMTP fields. + +mount: platform +path: workloads/email-connect/transactional +mount_management: existing +fields: + - EMAIL_CONNECT_INGEST_TOKEN + - EMAIL_CONNECT_SMTP_USERNAME + - EMAIL_CONNECT_SMTP_PASSWORD + +consumers: + - name: email-connect-runtime + auth: token + claim: clustersecretstore:openbao-email-connect + purpose: sync email-connect-runtime through the namespace-limited ESO store + - name: user-engine-ingest-caller + auth: shared-bearer-copy + claim: service:user-engine + purpose: consume only EMAIL_CONNECT_INGEST_TOKEN from its separately managed runtime Secret + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:email-connect-transactional + purpose: approved SMTP/ingest smoke in a child process and non-secret verification + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + email-connect + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-email-connect-transactional + role_name: se-prod-email-connect-transactional + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0010 + notes: >- + CCR-2026-0010 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read each declared field without printing it + negative: unrelated token is denied on platform/data/workloads/email-connect/transactional + +risk: + classification: high + notes: SMTP credentials can send as the platform identity and the ingest bearer authorizes transactional requests + +rotation: + owner: railiance-platform + email-connect + user-engine + expectation: >- + Rotate overlap-first, wait for ESO refresh, roll email-connect and user-engine + when the bearer changes, verify health, then revoke the old provider password/bearer. + ttl: provider-defined + +deactivation: + owner: railiance-platform + email-connect + expectation: >- + Disable the front door, detach ESO/native read access, remove materialized + Secrets, stop email-connect, and revoke the IONOS app password if applicable. + +audit: + evidence: CCR id, actor, exact path, field names, ESO refresh timestamp, request ids, and pass/fail only diff --git a/catalog/forgejo-admin-api-token.yaml b/catalog/forgejo-admin-api-token.yaml new file mode 100644 index 0000000..ac6e0fe --- /dev/null +++ b/catalog/forgejo-admin-api-token.yaml @@ -0,0 +1,77 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0006. +id: forgejo-admin-api-token +kind: kv +org: coulomb +repo: railiance-platform +stage: prod +description: >- + Forgejo site-admin PAT and non-secret companion metadata for attended operator + and automation tooling. This lane is distinct from Forgejo SMTP credentials. + +mount: platform +path: workloads/forgejo/forgejo-admin +mount_management: existing +fields: + - API_TOKEN + - API_USER + - API_BASE_URL + - TOKEN_SCOPES + - GENERATED_AT + +consumers: + - name: forgejo-platform-operators + auth: oidc + claim: group:net-kingdom-admins + purpose: existing attended reads through forgejo-admin-workload-kv-read + - name: forgejo-automation + auth: approle + claim: catalog:forgejo-admin-api-token + purpose: approved package, repository, webhook, and bootstrap commands in a child process + +workload_delivery: + - mode: operator-oidc + owner: railiance-platform + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + # Avoid broad-admin name guards while retaining an exact-path read policy. + policy_name: se-prod-forgejo-operator-api-token + role_name: se-prod-forgejo-operator-api-token + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0006 + notes: >- + CCR-2026-0006 approves the existing OIDC/operator lane. A live native + AppRole apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read declared fields and an exec smoke authenticates to Forgejo + negative: unrelated token is denied on platform/data/workloads/forgejo/forgejo-admin + +risk: + classification: high + notes: API_TOKEN is a Forgejo site-admin PAT with package, repository, webhook, and operator impact + +rotation: + owner: railiance-platform + Forgejo operator + expectation: >- + Mint a replacement PAT as the site-admin account, update OpenBao, verify all + consumers through exec delivery, then delete the old PAT in Forgejo. + ttl: provider-defined + +deactivation: + owner: railiance-platform + Forgejo operator + expectation: >- + Disable the front door, detach native/OIDC read access, revoke the PAT in + Forgejo, and preserve package/webhook audit evidence. + +audit: + evidence: CCR id, actor, exact path, field names, Forgejo token identifier/scopes if non-secret, timestamps, and pass/fail only diff --git a/catalog/issue-core-ingestion-api-key.yaml b/catalog/issue-core-ingestion-api-key.yaml new file mode 100644 index 0000000..c2e4bac --- /dev/null +++ b/catalog/issue-core-ingestion-api-key.yaml @@ -0,0 +1,78 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0002. +id: issue-core-ingestion-api-key +kind: kv +org: coulomb +repo: issue-core +stage: prod +description: >- + Runtime credentials for issue-core ingestion and its Forgejo backend. The + existing workload path is delivered by ESO; native secrets-engine exec is a + separate exact-path AppRole proposed for attended operator/agent use. + +mount: platform +path: workloads/issue-core/issue-core/issue-core-runtime +mount_management: existing +fields: + - ISSUE_CORE_API_KEY + - GITEA_BACKEND_TOKEN + +consumers: + - name: issue-core-runtime + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync the issue-core runtime Secret through the namespace-limited ESO store + - name: activity-core-issue-sink + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync the shared ingestion key for activity-core issue emission + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:issue-core-ingestion-api-key + purpose: approved child-process delivery and non-secret verification only + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + issue-core + activity-core + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-issue-core-ingestion-api-key + role_name: se-prod-issue-core-ingestion-api-key + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0002 + notes: >- + CCR-2026-0002 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read both declared fields without printing them + negative: unrelated token is denied on platform/data/workloads/issue-core/issue-core/issue-core-runtime + +risk: + classification: high + notes: path includes the shared ingestion bearer and the issue-core Forgejo backend token + +rotation: + owner: railiance-platform + issue-core + activity-core + expectation: >- + Update OpenBao first, wait for both ESO consumers, roll issue-core and the + activity-core workers, then revoke the replaced provider credential. + ttl: provider-defined + +deactivation: + owner: railiance-platform + issue-core + expectation: >- + Disable the front door, detach read access, remove materialized workload + Secrets, restart consumers, and preserve non-secret audit evidence. + +audit: + evidence: CCR id, actor, exact path, field names, audit request ids, timestamps, and pass/fail only diff --git a/catalog/openrouter-llm-connect.yaml b/catalog/openrouter-llm-connect.yaml new file mode 100644 index 0000000..0a8b7f1 --- /dev/null +++ b/catalog/openrouter-llm-connect.yaml @@ -0,0 +1,72 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0003. +id: openrouter-llm-connect +kind: kv +org: coulomb +repo: llm-connect +stage: prod +description: >- + OpenRouter provider credential used by llm-connect for activity-core. The + catalog does not generalize this key to other OpenRouter consumers. + +mount: platform +path: workloads/activity-core/llm-connect/llm-connect-provider-secrets +mount_management: existing +fields: + - OPENROUTER_API_KEY + +consumers: + - name: llm-connect-activity-core + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync llm-connect-provider-secrets for the activity-core deployment + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:openrouter-llm-connect + purpose: approved llm-connect smoke in a child process and non-secret verification + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + llm-connect + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-openrouter-llm-connect + role_name: se-prod-openrouter-llm-connect + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0003 + notes: >- + CCR-2026-0003 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read OPENROUTER_API_KEY without printing it + negative: unrelated token is denied on platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets + +risk: + classification: high + notes: provider key can incur spend and remains valid outside OpenBao until revoked at OpenRouter + +rotation: + owner: railiance-platform + llm-connect provider operator + expectation: >- + Mint a replacement at OpenRouter, update every live cluster copy, wait for + ESO refresh, restart llm-connect, verify, and revoke the old provider key. + ttl: provider-defined + +deactivation: + owner: railiance-platform + llm-connect + expectation: >- + Disable the front door and workload read path, remove materialized Secrets, + stop consumers, and revoke the provider key at OpenRouter. + +audit: + evidence: CCR id, actor, exact path, field name, provider key identifier if non-secret, timestamps, and pass/fail only diff --git a/catalog/reuse-surface-hub-write-token.yaml b/catalog/reuse-surface-hub-write-token.yaml new file mode 100644 index 0000000..c3e6858 --- /dev/null +++ b/catalog/reuse-surface-hub-write-token.yaml @@ -0,0 +1,77 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0005. +id: reuse-surface-hub-write-token +kind: kv +org: coulomb +repo: reuse-surface +stage: prod +description: >- + Production federation-hub write bearer and Forgejo webhook HMAC. ESO remains + the workload delivery path; native exec is a separate exact-path AppRole. + +mount: platform +path: workloads/reuse/reuse-surface/runtime-secrets +mount_management: existing +fields: + - REUSE_SURFACE_TOKEN + - REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET + +consumers: + - name: reuse-surface-runtime + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync reuse-surface-env through the namespace-limited ESO store + - name: forgejo-coulomb-org-webhook + auth: shared-hmac + claim: forgejo-org:coulomb + purpose: share the webhook HMAC with the reuse-surface receiver + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:reuse-surface-hub-write-token + purpose: approved hub tooling in a child process and non-secret verification + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + reuse-surface + railiance-apps + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-reuse-surface-hub-write-token + role_name: se-prod-reuse-surface-hub-write-token + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0005 + notes: >- + CCR-2026-0005 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read both declared fields without printing them + negative: unrelated token is denied on platform/data/workloads/reuse/reuse-surface/runtime-secrets + +risk: + classification: high + notes: hub writes affect the production federation index and the HMAC has two coordinated consumers + +rotation: + owner: railiance-platform + reuse-surface + railiance-apps + expectation: >- + Patch OpenBao, force ESO refresh, roll reuse-surface, and reconfigure the + Forgejo org webhook whenever its HMAC changes. + ttl: rotate-on-compromise + +deactivation: + owner: railiance-platform + reuse-surface + expectation: >- + Disable the front door, detach ESO access, and remove the materialized + Secret only after confirming decommission or break-glass posture. + +audit: + evidence: CCR id, actor, exact path, field names, ESO refresh timestamp, request ids, and pass/fail only diff --git a/docs/catalog-admission.md b/docs/catalog-admission.md new file mode 100644 index 0000000..a61a0fe --- /dev/null +++ b/docs/catalog-admission.md @@ -0,0 +1,121 @@ +# Catalog Admission for Existing OpenBao Lanes + +Catalog admission and infrastructure ownership are separate decisions. A lane +may already exist on a shared OpenBao mount and already reach its workload +through External Secrets Operator or Kubernetes auth. Adding that lane to the +secrets-engine catalog must not imply that secrets-engine may create the mount, +replace workload delivery, or broaden an existing role. + +## Admission dimensions + +### Mount management + +`mount_management` describes only who creates the OpenBao secrets-engine mount. + +| Value | Plan and apply behavior | +| --- | --- | +| `engine` | The guarded plan may include a `kv-mount` action; apply ensures the mount exists. This is the compatibility default for existing catalog entries. | +| `existing` | The plan emits a non-mutating `kv-mount-check`; apply never calls mount creation. Provisioning may still update the exact cataloged KV path after approval. | + +Shared production mounts such as `platform` must use `existing` unless a +separate infrastructure decision explicitly delegates mount administration. + +### Native delivery authentication + +`delivery_auth` describes how secrets-engine itself obtains a scoped token for +positive verification and exec-time delivery. It does not describe or replace +workload authentication. + +```yaml +delivery_auth: + method: approle + management: engine + # Optional safe overrides when the catalog id contains an admin-like term. + policy_name: se-prod-forgejo-operator-token + role_name: se-prod-forgejo-operator-token +``` + +| Management | Meaning | +| --- | --- | +| `engine` | A reviewed apply may create/update the exact read policy and AppRole for this lane. | +| `existing` | Policy and AppRole are externally managed. `role_name` is mandatory; plan/apply emit checks only and never mutate either object. | +| `none` | No native verification or exec delivery is available. Use only for catalog-only metadata without native delivery modes. | + +The current native implementation supports AppRole. An entry that declares +`exec-env`, `exec-file`, `npm-config`, `read-check`, or `wrapped` must therefore +declare delivery auth. `exec-file` and `wrapped` remain reserved schema modes; +they are not implemented by `secrets-engine exec` yet. + +Engine-managed AppRoles may bound `token_ttl`, `token_max_ttl`, +`secret_id_ttl`, `secret_id_num_uses`, and `token_num_uses`. The admitted +high-risk drafts use 15-minute tokens, a 30-minute maximum, single-use +`secret_id` values, and eight token uses. + +### Existing workload delivery + +`workload_delivery` records non-secret facts about delivery already operated by +another repository. Every item names both the mechanism and its owner. + +```yaml +workload_delivery: + - mode: external-secrets + owner: rapp-example +``` + +This metadata is evidence and review context. `apply`, `provision`, `verify`, +and `exec` do not change ESO objects, Kubernetes auth roles, deployments, or +provider accounts. + +## Example: existing production mount plus native exec adapter + +```yaml +id: example-runtime-api-key +kind: kv +org: coulomb +repo: example-service +stage: prod +mount: platform +path: workloads/example-service/runtime +mount_management: existing +fields: [EXAMPLE_API_KEY] + +consumers: + - name: example-service-runtime + auth: kubernetes + claim: system:serviceaccount:example:example-service + purpose: production runtime access through ESO + +workload_delivery: + - mode: external-secrets + owner: rapp-example-service + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + +# approval, verification, rotation, deactivation, and audit remain required. +``` + +The guarded plan may propose a new exact-path delivery policy and AppRole after +approval, but it emits only a check for the shared `platform` mount. The +existing Kubernetes/ESO delivery remains untouched. + +## Admission checklist + +Before accepting an existing production lane: + +1. Confirm exact mount, path, and field names from the current owner. +2. Set `mount_management: existing` for shared or pre-provisioned mounts. +3. Record existing workload delivery and its repository owner. +4. Decide separately whether native delivery auth is engine-managed, existing, + or absent. +5. Use exact policy/role overrides when the catalog id would trip broad-admin + name guards; never weaken path or capability guards to accommodate a name. +6. Mark high-risk lanes explicitly; they require named rotation and + deactivation owners and cannot use bootstrap-only approval. +7. Link a resolved approval before live apply, provisioning, verification, + rotation, revoke, or exec. Every privileged live command fails closed when + that approval cannot be resolved. +8. Preserve the interim route until native positive and negative verification + passes without exposing a value. diff --git a/docs/cli.md b/docs/cli.md index 88192a5..7d86fc4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,6 +15,12 @@ npm registry/scope live in `delivery_config.npm` as data — the engine never hardcodes a registry. The pilot publishes `@whynot/design` from the `coulomb/whynot-design` repo to `https://forgejo.coulomb.social/api/packages/coulomb/npm/`. +Existing production lanes additionally distinguish mount ownership, native +delivery auth, and workload delivery. See +[catalog-admission.md](catalog-admission.md). In particular, +`mount_management: existing` makes mount handling non-mutating; it does not +authorize secrets-engine to replace an existing ESO/Kubernetes delivery path. + ## Install ```bash diff --git a/src/secrets_engine/apply.py b/src/secrets_engine/apply.py index dfe667b..628d396 100644 --- a/src/secrets_engine/apply.py +++ b/src/secrets_engine/apply.py @@ -37,38 +37,56 @@ def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str # 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 entry.manages_mount: 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}") + elif entry.stores_kv_value(): + skipped.append(f"kv-mount {entry.mount} (externally managed; no mutation)") else: 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) - if current and _normalize(current) == _normalize(plan.policy_hcl): - skipped.append(f"policy {plan.policy_name} (unchanged)") + if entry.manages_delivery_auth: + current = client.read_policy(plan.policy_name) + if current and _normalize(current) == _normalize(plan.policy_hcl): + skipped.append(f"policy {plan.policy_name} (unchanged)") + else: + client.write_policy(plan.policy_name, plan.policy_hcl) + applied.append(f"policy {plan.policy_name}") else: - client.write_policy(plan.policy_name, plan.policy_hcl) - applied.append(f"policy {plan.policy_name}") + skipped.append(f"policy {plan.policy_name} (externally managed; no mutation)") # 3. Consumer approle bound to that policy. - client.ensure_approle_enabled() - 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, - ) + if entry.manages_delivery_auth: + client.ensure_approle_enabled() + 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=entry.delivery_token_ttl or ttl, + max_ttl=entry.delivery_token_max_ttl, + secret_id_ttl=entry.delivery_secret_id_ttl, + secret_id_num_uses=entry.delivery_secret_id_num_uses, + token_num_uses=entry.delivery_token_num_uses, + ) + applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]") + elif entry.has_delivery_auth: + skipped.append(f"approle {plan.role_name} (externally managed; no mutation)") else: - client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl) - applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]") + skipped.append("approle (no native delivery auth declared)") return ApplyResult(applied=applied, skipped=skipped) diff --git a/src/secrets_engine/catalog.py b/src/secrets_engine/catalog.py index 532e79d..736c214 100644 --- a/src/secrets_engine/catalog.py +++ b/src/secrets_engine/catalog.py @@ -27,6 +27,10 @@ VALID_DELIVERY_MODES = ( "approle-login", ) VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only") +VALID_MOUNT_MANAGEMENT = ("engine", "existing") +VALID_DELIVERY_AUTH_MANAGEMENT = ("engine", "existing", "none") +VALID_DELIVERY_AUTH_METHODS = ("approle", "none") +VALID_RISK_CLASSIFICATIONS = ("standard", "high") REQUIRED_FIELDS = ( "id", @@ -66,6 +70,10 @@ class CatalogEntry: rotation: dict[str, Any] deactivation: dict[str, Any] audit: dict[str, Any] + mount_management: str = "engine" + delivery_auth: dict[str, Any] = field(default_factory=dict) + workload_delivery: list[dict[str, Any]] = field(default_factory=list) + risk: dict[str, Any] = field(default_factory=dict) delivery_config: dict[str, Any] = field(default_factory=dict) auth_capability: dict[str, Any] = field(default_factory=dict) description: str = "" @@ -95,14 +103,62 @@ class CatalogEntry: def policy_name(self) -> str: if self.kind == "auth-capability": return self.auth_capability.get("policy_name") or self.id + if self.delivery_auth.get("policy_name"): + return str(self.delivery_auth["policy_name"]) 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 + if self.delivery_auth.get("role_name"): + return str(self.delivery_auth["role_name"]) return f"se-{self.stage}-{self.id}" + @property + def manages_mount(self) -> bool: + return self.kind == "kv" and self.mount_management == "engine" + + @property + def delivery_auth_method(self) -> str: + if self.kind == "auth-capability": + return "approle" + return str(self.delivery_auth.get("method", "approle")) + + @property + def delivery_auth_management(self) -> str: + if self.kind == "auth-capability": + return "engine" + return str(self.delivery_auth.get("management", "engine")) + + @property + def manages_delivery_auth(self) -> bool: + return self.delivery_auth_management == "engine" + + @property + def has_delivery_auth(self) -> bool: + return self.delivery_auth_management != "none" + + @property + def delivery_token_ttl(self) -> str: + return str(self.delivery_auth.get("token_ttl", "30m")) + + @property + def delivery_token_max_ttl(self) -> str: + return str(self.delivery_auth.get("token_max_ttl", self.delivery_token_ttl)) + + @property + def delivery_secret_id_ttl(self) -> str: + return str(self.delivery_auth.get("secret_id_ttl", self.delivery_token_ttl)) + + @property + def delivery_secret_id_num_uses(self) -> int: + return int(self.delivery_auth.get("secret_id_num_uses", 0)) + + @property + def delivery_token_num_uses(self) -> int: + return int(self.delivery_auth.get("token_num_uses", 0)) + @property def auth_allowed_paths(self) -> dict[str, list[str]]: """Allowed OpenBao paths/capabilities for an auth-capability lane.""" @@ -156,8 +212,16 @@ def validate_entry(data: dict[str, Any], *, source: str = "") -> Catalog ) if data["kind"] == "auth-capability": data.setdefault("fields", []) + data.setdefault("mount_management", "existing") + data.setdefault("delivery_auth", {}) + data.setdefault("workload_delivery", []) else: data.setdefault("auth_capability", {}) + data.setdefault("mount_management", "engine") + data.setdefault( + "delivery_auth", {"method": "approle", "management": "engine"} + ) + data.setdefault("workload_delivery", []) 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"): @@ -195,6 +259,75 @@ def validate_entry(data: dict[str, Any], *, source: str = "") -> Catalog f"{source}: each consumer needs at least 'name' and 'auth'" ) + if data["mount_management"] not in VALID_MOUNT_MANAGEMENT: + raise CatalogError( + f"{source}: mount_management '{data['mount_management']}' invalid; " + f"must be one of {VALID_MOUNT_MANAGEMENT}" + ) + + workload_delivery = data["workload_delivery"] + if not isinstance(workload_delivery, list): + raise CatalogError(f"{source}: workload_delivery must be a list") + for item in workload_delivery: + if ( + not isinstance(item, dict) + or not isinstance(item.get("mode"), str) + or not item["mode"].strip() + or not isinstance(item.get("owner"), str) + or not item["owner"].strip() + ): + raise CatalogError( + f"{source}: each workload_delivery item needs non-empty mode and owner" + ) + + if data["kind"] == "kv": + delivery_auth = data["delivery_auth"] + if not isinstance(delivery_auth, dict): + raise CatalogError(f"{source}: delivery_auth must be a mapping") + auth_method = delivery_auth.get("method", "approle") + auth_management = delivery_auth.get("management", "engine") + if auth_method not in VALID_DELIVERY_AUTH_METHODS: + raise CatalogError( + f"{source}: delivery_auth.method '{auth_method}' invalid; " + f"must be one of {VALID_DELIVERY_AUTH_METHODS}" + ) + if auth_management not in VALID_DELIVERY_AUTH_MANAGEMENT: + raise CatalogError( + f"{source}: delivery_auth.management '{auth_management}' invalid; " + f"must be one of {VALID_DELIVERY_AUTH_MANAGEMENT}" + ) + if auth_management == "none" and auth_method != "none": + raise CatalogError( + f"{source}: delivery_auth.management none requires method none" + ) + if auth_management != "none" and auth_method != "approle": + raise CatalogError( + f"{source}: current native delivery auth must use approle" + ) + if auth_management == "existing" and not delivery_auth.get("role_name"): + raise CatalogError( + f"{source}: existing delivery auth requires delivery_auth.role_name" + ) + for ttl_field in ("token_ttl", "token_max_ttl", "secret_id_ttl"): + if ttl_field in delivery_auth and ( + not isinstance(delivery_auth[ttl_field], str) + or not delivery_auth[ttl_field].strip() + ): + raise CatalogError( + f"{source}: delivery_auth.{ttl_field} must be a non-empty string" + ) + for uses_field in ("secret_id_num_uses", "token_num_uses"): + uses = delivery_auth.get(uses_field, 0) + if isinstance(uses, bool) or not isinstance(uses, int) or uses < 0: + raise CatalogError( + f"{source}: delivery_auth.{uses_field} must be a non-negative integer" + ) + native_modes = {"exec-env", "exec-file", "npm-config", "read-check", "wrapped"} + if native_modes.intersection(modes) and auth_management == "none": + raise CatalogError( + f"{source}: native delivery/verification modes require delivery_auth" + ) + approval = data["approval"] if not isinstance(approval, dict) or "model" not in approval: raise CatalogError(f"{source}: approval must include a 'model'") @@ -204,6 +337,27 @@ def validate_entry(data: dict[str, Any], *, source: str = "") -> Catalog f"allowed {VALID_APPROVAL_MODELS}" ) + risk = data.get("risk", {}) + if not isinstance(risk, dict): + raise CatalogError(f"{source}: risk must be a mapping") + classification = risk.get("classification", "standard") + if classification not in VALID_RISK_CLASSIFICATIONS: + raise CatalogError( + f"{source}: risk.classification '{classification}' invalid; " + f"must be one of {VALID_RISK_CLASSIFICATIONS}" + ) + if classification == "high": + if approval["model"] == "bootstrap-only": + raise CatalogError( + f"{source}: high-risk lanes cannot use bootstrap-only approval" + ) + for lifecycle_name in ("rotation", "deactivation"): + lifecycle = data[lifecycle_name] + if not isinstance(lifecycle, dict) or not lifecycle.get("owner"): + raise CatalogError( + f"{source}: high-risk lanes require {lifecycle_name}.owner" + ) + # npm-config delivery must declare WHERE it publishes (registry + scope), so # the registry is catalog data, never hardcoded in the engine. if "npm-config" in modes: diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 94f370c..0194cf1 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -63,6 +63,19 @@ def _writer(cfg: Config) -> EvidenceWriter: return EvidenceWriter(evidence_dir=cfg.evidence_dir, hub_url=cfg.hub_url, topic_id=cfg.topic_id) +def _require_lane_approval(cfg: Config, entry): + """Resolve and enforce the lane approval for a privileged live action.""" + if not entry.approval_required(): + return None + 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) + return decision + + # -- command handlers ------------------------------------------------------ @@ -89,13 +102,23 @@ def cmd_catalog_show(cfg: Config, args) -> int: 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"openbao: {e.mount}/{e.path} fields={e.fields} " + f"mount_management={e.mount_management}" + ) + print( + f"delivery auth: {e.delivery_auth_method}/" + f"{e.delivery_auth_management} role={e.role_name if e.has_delivery_auth else '-'}" + ) + print(f"workload: {e.workload_delivery}") 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','')}") print(f"verification: {e.verification}") print(f"rotation: {e.rotation}") print(f"deactivation: {e.deactivation}") + if e.risk: + print(f"risk: {e.risk}") print(f"description: {e.description.strip()}") return 0 @@ -172,6 +195,7 @@ def cmd_provision(cfg: Config, args) -> int: if args.stage != entry.stage: from secrets_engine.errors import ProvisioningError raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'") + decision = _require_lane_approval(cfg, entry) 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 args.generate: @@ -182,12 +206,13 @@ def cmd_provision(cfg: Config, args) -> int: mode = "from-file" print(f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed") _writer(cfg).record("provision", result=mode, catalog_id=entry.id, stage=entry.stage, - detail={"field": f}) + decision_id=decision.id if decision else "", detail={"field": f}) return 0 def cmd_verify(cfg: Config, args) -> int: entry = get_entry(cfg.catalog_dir, args.catalog_id) + decision = _require_lane_approval(cfg, entry) 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: @@ -202,7 +227,8 @@ def cmd_verify(cfg: Config, args) -> int: if not r.passed: rc = 7 _writer(cfg).record("verify", result=f"{r.check}:{'pass' if r.passed else 'fail'}", - catalog_id=entry.id, stage=entry.stage, detail=r.detail) + catalog_id=entry.id, stage=entry.stage, + decision_id=decision.id if decision else "", detail=r.detail) return rc @@ -215,13 +241,7 @@ def cmd_handoff(cfg: Config, args) -> int: 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) + decision = _require_lane_approval(cfg, entry) client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file) result = write_approle_handoff( client, @@ -257,12 +277,7 @@ def cmd_exec(cfg: Config, args) -> int: from secrets_engine.exec_delivery import exec_with_secret entry = get_entry(cfg.catalog_dir, args.catalog) # require approval + readiness before running. - 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) + decision = _require_lane_approval(cfg, entry) if not args.command: from secrets_engine.errors import DeliveryError raise DeliveryError("no command after '--'") @@ -270,9 +285,11 @@ def cmd_exec(cfg: Config, args) -> int: field = args.field or (entry.fields[0] if entry.fields else "") w = _writer(cfg) w.record("exec", result="attempt", catalog_id=entry.id, stage=entry.stage, + decision_id=decision.id if decision else "", detail={"command": args.command[0], "mode": args.mode}) rc = exec_with_secret(client, entry, field, args.command, mode=args.mode) w.record("exec", result=f"exit-{rc}", catalog_id=entry.id, stage=entry.stage, + decision_id=decision.id if decision else "", detail={"command": args.command[0]}) return rc @@ -322,6 +339,7 @@ def cmd_route(cfg: Config, args) -> int: def cmd_revoke(cfg: Config, args) -> int: entry = get_entry(cfg.catalog_dir, args.catalog_id) + decision = None if args.dry_run else _require_lane_approval(cfg, entry) client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file) if entry.kind == "auth-capability": if args.dry_run: @@ -336,7 +354,10 @@ def cmd_revoke(cfg: Config, args) -> int: 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) + _writer(cfg).record( + "revoke", result="auth-capability-deactivated", catalog_id=entry.id, + stage=entry.stage, decision_id=decision.id if decision else "" + ) return 0 if args.dry_run: print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} " @@ -344,7 +365,10 @@ def cmd_revoke(cfg: Config, args) -> int: return 0 client.kv_delete_metadata(entry.mount, entry.path) print(f"revoked lane '{entry.id}': KV metadata deleted at {entry.mount}/{entry.path}") - _writer(cfg).record("revoke", result="deactivated", catalog_id=entry.id, stage=entry.stage) + _writer(cfg).record( + "revoke", result="deactivated", catalog_id=entry.id, stage=entry.stage, + decision_id=decision.id if decision else "" + ) return 0 diff --git a/src/secrets_engine/exec_delivery.py b/src/secrets_engine/exec_delivery.py index fd779e9..c202f44 100644 --- a/src/secrets_engine/exec_delivery.py +++ b/src/secrets_engine/exec_delivery.py @@ -52,6 +52,10 @@ def resolve_npm_token_env(entry: CatalogEntry, *, policy_dir=None) -> str: def _fetch_value(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str: """Read the field value via an approle-scoped token. Held in memory only.""" + if entry.delivery_auth_method != "approle" or not entry.has_delivery_auth: + raise DeliveryError( + f"lane '{entry.id}' has no AppRole delivery auth for native exec" + ) try: token = client.approle_login_token(entry.role_name) except Exception as e: @@ -126,6 +130,10 @@ def exec_with_secret( """ if not command: raise DeliveryError("no command given to exec") + if field not in entry.fields: + raise DeliveryError( + f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}" + ) declared = set(entry.delivery_modes) if mode == "auto": diff --git a/src/secrets_engine/handoff.py b/src/secrets_engine/handoff.py index febee85..affaed7 100644 --- a/src/secrets_engine/handoff.py +++ b/src/secrets_engine/handoff.py @@ -13,6 +13,7 @@ from pathlib import Path from secrets_engine.catalog import CatalogEntry from secrets_engine.errors import ProvisioningError from secrets_engine.openbao import OpenBaoClient +from secrets_engine.safe_paths import containing_git_worktree @dataclass(frozen=True) @@ -27,12 +28,12 @@ class HandoffResult: 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" - ) + worktree = containing_git_worktree(resolved) + if worktree is not None: + raise ProvisioningError( + f"handoff file {resolved} is inside a Git worktree ({worktree}); " + "keep AppRole material outside repos" + ) return resolved diff --git a/src/secrets_engine/openbao.py b/src/secrets_engine/openbao.py index 2fbf627..38cea7b 100644 --- a/src/secrets_engine/openbao.py +++ b/src/secrets_engine/openbao.py @@ -23,6 +23,7 @@ from dataclasses import dataclass from pathlib import Path from secrets_engine.errors import BackendError, ProvisioningError +from secrets_engine.safe_paths import containing_git_worktree def _check_token_file(path: Path) -> str: @@ -36,12 +37,12 @@ def _check_token_file(path: Path) -> str: f"(mode {oct(st.st_mode & 0o777)}); must be 0600" ) # Refuse a token file living inside a Git worktree. - for parent in path.resolve().parents: - if (parent / ".git").exists(): - raise ProvisioningError( - f"bootstrap token file {path} is inside a Git worktree ({parent}); " - "store it outside any repo" - ) + worktree = containing_git_worktree(path) + if worktree is not None: + raise ProvisioningError( + f"bootstrap token file {path} is inside a Git worktree ({worktree}); " + "store it outside any repo" + ) token = path.read_text(encoding="utf-8").strip() if not token: raise ProvisioningError(f"bootstrap token file {path} is empty") diff --git a/src/secrets_engine/plan.py b/src/secrets_engine/plan.py index dddfc2a..622bcac 100644 --- a/src/secrets_engine/plan.py +++ b/src/secrets_engine/plan.py @@ -21,7 +21,7 @@ from secrets_engine.roles import ( @dataclass class PlanAction: - kind: str # "kv-mount" | "policy" | "approle" + kind: str # mutation or non-mutating check/preview action target: str # human-readable target detail: dict[str, Any] = field(default_factory=dict) @@ -87,19 +87,60 @@ def build_plan(entry: CatalogEntry, stage: str, *, decision_id: str = "") -> Pla 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"}, - ), - ] + mount_action = ( + PlanAction("kv-mount", entry.mount, {"type": "kv-v2", "management": "engine"}) + if entry.manages_mount + else PlanAction( + "kv-mount-check", + entry.mount, + {"type": "kv-v2", "management": "existing", "mutation": "none"}, + ) + ) + actions = [mount_action] + if entry.manages_delivery_auth: + actions.extend( + [ + PlanAction( + "policy", + policy_name, + {"paths": f"{entry.mount}/data/{entry.path}"}, + ), + PlanAction( + "approle", + entry.role_name, + { + "token_policies": policy_name, + "auth": "approle", + "token_ttl": entry.delivery_token_ttl, + "token_max_ttl": entry.delivery_token_max_ttl, + "token_num_uses": entry.delivery_token_num_uses, + }, + ), + ] + ) + elif entry.has_delivery_auth: + actions.extend( + [ + PlanAction( + "policy-check", + policy_name, + {"paths": f"{entry.mount}/data/{entry.path}", "mutation": "none"}, + ), + PlanAction( + "approle-check", + entry.role_name, + {"auth": "approle", "management": "existing", "mutation": "none"}, + ), + ] + ) + else: + actions.append( + PlanAction( + "policy-preview", + policy_name, + {"paths": f"{entry.mount}/data/{entry.path}", "mutation": "none"}, + ) + ) return Plan( catalog_id=entry.id, stage=stage, diff --git a/src/secrets_engine/provision.py b/src/secrets_engine/provision.py index 7f77846..255ce75 100644 --- a/src/secrets_engine/provision.py +++ b/src/secrets_engine/provision.py @@ -18,6 +18,7 @@ from pathlib import Path from secrets_engine.catalog import CatalogEntry from secrets_engine.errors import ProvisioningError from secrets_engine.openbao import OpenBaoClient +from secrets_engine.safe_paths import containing_git_worktree def _read_value_file(path: Path) -> str: @@ -29,12 +30,12 @@ def _read_value_file(path: Path) -> str: f"value file {path} is group/other-accessible " f"(mode {oct(st.st_mode & 0o777)}); must be 0600" ) - for parent in path.resolve().parents: - if (parent / ".git").exists(): - raise ProvisioningError( - f"value file {path} is inside a Git worktree ({parent}); " - "keep secret material outside repos" - ) + worktree = containing_git_worktree(path) + if worktree is not None: + raise ProvisioningError( + f"value file {path} is inside a Git worktree ({worktree}); " + "keep secret material outside repos" + ) value = path.read_text(encoding="utf-8").strip() if not value: raise ProvisioningError(f"value file {path} is empty") @@ -54,7 +55,8 @@ def provision_from_file( f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}" ) value = _read_value_file(Path(file_path)) - client.ensure_kv_mount(entry.mount) + if entry.manages_mount: + client.ensure_kv_mount(entry.mount) client.kv_put(entry.mount, entry.path, field, value) del value return field @@ -77,7 +79,8 @@ def provision_generated(client: OpenBaoClient, entry: CatalogEntry, field: str) ) alphabet = string.ascii_letters + string.digits value = "test-" + "".join(_secrets.choice(alphabet) for _ in range(32)) - client.ensure_kv_mount(entry.mount) + if entry.manages_mount: + client.ensure_kv_mount(entry.mount) client.kv_put(entry.mount, entry.path, field, value) del value return field diff --git a/src/secrets_engine/routing.py b/src/secrets_engine/routing.py index ede77be..8abbe8d 100644 --- a/src/secrets_engine/routing.py +++ b/src/secrets_engine/routing.py @@ -60,9 +60,10 @@ def route_lane( metadata_applied = False value_present = False if client is not None and client.is_reachable(): - 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.has_delivery_auth: + 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 "" @@ -80,8 +81,17 @@ def route_lane( missing = f"approved decision for '{decision_ref}'" next_command = f"secrets-engine decision inspect {decision_ref or entry.id}" elif not metadata_applied: - missing = "OpenBao policy/role apply" - next_command = f"secrets-engine apply {decision_ref or entry.id} --stage {entry.stage}" + if entry.kind == "kv" and entry.delivery_auth_management == "existing": + missing = "externally managed OpenBao policy/AppRole readiness" + next_command = ( + f"secrets-engine verify {entry.id} --positive --negative" + ) + elif entry.kind == "kv" and not entry.has_delivery_auth: + missing = "native delivery auth declaration" + next_command = f"secrets-engine plan {decision_ref or entry.id} --stage {entry.stage}" + else: + 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 = ( @@ -96,7 +106,12 @@ def route_lane( ) else: missing = "" - next_command = f"secrets-engine exec --catalog {entry.id} -- " + if {"exec-env", "npm-config"}.intersection(entry.delivery_modes): + next_command = f"secrets-engine exec --catalog {entry.id} -- " + else: + next_command = ( + f"secrets-engine verify {entry.id} --positive --negative" + ) return RouteResult( catalog_id=entry.id, diff --git a/src/secrets_engine/safe_paths.py b/src/secrets_engine/safe_paths.py new file mode 100644 index 0000000..c867ea4 --- /dev/null +++ b/src/secrets_engine/safe_paths.py @@ -0,0 +1,20 @@ +"""Filesystem checks shared by secret provisioning and handoff paths.""" +from __future__ import annotations + +from pathlib import Path + + +def containing_git_worktree(path: Path) -> Path | None: + """Return the nearest enclosing Git worktree, if one is identifiable. + + A real worktree has either a ``.git`` file (linked worktrees/submodules) or + a ``.git`` directory containing ``HEAD``. Merely finding an empty directory + named ``.git`` is not enough; sandbox and test environments may use such a + marker outside any repository. + """ + resolved = path.expanduser().resolve() + for parent in (resolved.parent, *resolved.parent.parents): + marker = parent / ".git" + if marker.is_file() or (marker.is_dir() and (marker / "HEAD").is_file()): + return parent + return None diff --git a/src/secrets_engine/verify.py b/src/secrets_engine/verify.py index 2a19217..5bbd178 100644 --- a/src/secrets_engine/verify.py +++ b/src/secrets_engine/verify.py @@ -29,6 +29,12 @@ class VerifyResult: def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> VerifyResult: """Approved consumer token must be able to read the field.""" + if entry.delivery_auth_method != "approle" or not entry.has_delivery_auth: + return VerifyResult( + "positive", + False, + {"reason": "lane has no AppRole delivery auth", "path": entry.path}, + ) try: token = client.approle_login_token(entry.role_name) except Exception as e: # backend errors -> failed verification, not a value leak @@ -145,6 +151,10 @@ def verify_auth_capability_negative(client: OpenBaoClient, entry: CatalogEntry) def run_verification( client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool ) -> list[VerifyResult]: + if entry.kind == "kv" and field not in entry.fields: + raise VerificationError( + f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}" + ) results: list[VerifyResult] = [] if entry.kind == "auth-capability": if positive: diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 14b7739..f6e3806 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -74,6 +74,86 @@ def test_unknown_delivery_mode_rejected(): validate_entry(data) +def test_existing_mount_and_workload_delivery_parse(): + data = copy.deepcopy(VALID) + data["mount"] = "platform" + data["mount_management"] = "existing" + data["workload_delivery"] = [ + {"mode": "external-secrets", "owner": "rapp-example"} + ] + entry = validate_entry(data) + assert entry.manages_mount is False + assert entry.manages_delivery_auth is True + assert entry.workload_delivery[0]["mode"] == "external-secrets" + + +def test_existing_delivery_auth_requires_explicit_role(): + data = copy.deepcopy(VALID) + data["delivery_auth"] = {"method": "approle", "management": "existing"} + with pytest.raises(CatalogError): + validate_entry(data) + data["delivery_auth"]["role_name"] = "existing-exact-role" + entry = validate_entry(data) + assert entry.manages_delivery_auth is False + assert entry.role_name == "existing-exact-role" + + +def test_native_delivery_rejects_missing_auth(): + data = copy.deepcopy(VALID) + data["delivery_auth"] = {"method": "none", "management": "none"} + with pytest.raises(CatalogError): + validate_entry(data) + + +def test_workload_delivery_requires_mode_and_owner(): + data = copy.deepcopy(VALID) + data["workload_delivery"] = [{"mode": "external-secrets"}] + with pytest.raises(CatalogError): + validate_entry(data) + + +def test_high_risk_lane_requires_owners_and_non_bootstrap_approval(): + data = copy.deepcopy(VALID) + data["risk"] = {"classification": "high"} + data["approval"] = {"model": "decision", "decision_ref": "d1"} + with pytest.raises(CatalogError): + validate_entry(data) + data["rotation"]["owner"] = "platform-owner" + data["deactivation"]["owner"] = "platform-owner" + assert validate_entry(data).risk["classification"] == "high" + data["approval"] = {"model": "bootstrap-only"} + with pytest.raises(CatalogError): + validate_entry(data) + + +def test_admitted_existing_lanes_have_exact_safe_metadata(): + entries = load_catalog(repo_root() / "catalog") + expected = { + "issue-core-ingestion-api-key": "workloads/issue-core/issue-core/issue-core-runtime", + "reuse-surface-hub-write-token": "workloads/reuse/reuse-surface/runtime-secrets", + "openrouter-llm-connect": "workloads/activity-core/llm-connect/llm-connect-provider-secrets", + "forgejo-admin-api-token": "workloads/forgejo/forgejo-admin", + "email-connect-transactional": "workloads/email-connect/transactional", + } + for lane_id, path in expected.items(): + entry = entries[lane_id] + assert entry.mount == "platform" + assert entry.path == path + assert entry.manages_mount is False + assert entry.manages_delivery_auth is True + assert entry.risk["classification"] == "high" + assert entry.rotation["owner"] + assert entry.deactivation["owner"] + assert entry.workload_delivery + assert entry.approval["decision_ref"].startswith("CCR-2026-") + + +def test_generic_and_identity_routes_are_not_catalog_lanes(): + entries = load_catalog(repo_root() / "catalog") + assert "openbao-api-key" not in entries + assert "key-cape-oidc-login" not in entries + + def test_wildcard_path_rejected(): data = copy.deepcopy(VALID) data["path"] = "test/*" diff --git a/tests/test_decisions.py b/tests/test_decisions.py index bea1609..ece6f60 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -1,6 +1,8 @@ import pytest +from types import SimpleNamespace from secrets_engine.catalog import validate_entry +from secrets_engine.cli import _require_lane_approval from secrets_engine.decisions import Decision, require_approved, resolve_decision from secrets_engine.errors import DecisionError @@ -50,3 +52,23 @@ def test_local_fixture_resolves(tmp_path): def test_missing_decision_raises(tmp_path): with pytest.raises(DecisionError): resolve_decision(hub_url="http://127.0.0.1:1", repo_root=tmp_path, decision_ref="nope") + + +def test_privileged_lane_helper_fails_closed_without_decision(tmp_path, monkeypatch): + import secrets_engine.cli as cli + + monkeypatch.setattr(cli, "repo_root", lambda: tmp_path) + with pytest.raises(DecisionError): + _require_lane_approval(SimpleNamespace(hub_url=""), _approved()) + + +def test_privileged_lane_helper_accepts_local_approval(tmp_path, monkeypatch): + import secrets_engine.cli as cli + + (tmp_path / ".decisions").mkdir() + (tmp_path / ".decisions" / "x.yaml").write_text( + "id: x\ntitle: approved\nstatus: resolved\nsuperseded_by: null\n" + ) + monkeypatch.setattr(cli, "repo_root", lambda: tmp_path) + decision = _require_lane_approval(SimpleNamespace(hub_url=""), _approved()) + assert decision.id == "x" diff --git a/tests/test_exec_delivery.py b/tests/test_exec_delivery.py index 3b1928a..f575b6a 100644 --- a/tests/test_exec_delivery.py +++ b/tests/test_exec_delivery.py @@ -1,4 +1,15 @@ -from secrets_engine.exec_delivery import _npm_userconfig, _registry_authkey +import copy + +import pytest + +from secrets_engine.catalog import validate_entry +from secrets_engine.errors import DeliveryError +from secrets_engine.exec_delivery import ( + _npm_userconfig, + _registry_authkey, + exec_with_secret, +) +from tests.test_catalog import VALID def test_registry_authkey_strips_scheme_and_trails_slash(): @@ -22,3 +33,40 @@ def test_npm_userconfig_writes_registry_and_token_ref_not_value(): assert (path.stat().st_mode & 0o077) == 0 # cleaned up on context exit assert not path.exists() + + +def test_exec_env_injects_only_selected_declared_field(monkeypatch): + data = copy.deepcopy(VALID) + data["fields"] = ["primary", "selected_value"] + entry = validate_entry(data) + + def fake_fetch(_client, got_entry, field): + assert got_entry == entry + assert field == "selected_value" + return "test-secret-value" + + def fake_spawn(command, env, secret): + assert command == ["probe"] + assert secret == "test-secret-value" + assert env["SELECTED_VALUE"] == "test-secret-value" + assert "PRIMARY" not in env + return 0 + + monkeypatch.setattr("secrets_engine.exec_delivery._fetch_value", fake_fetch) + monkeypatch.setattr("secrets_engine.exec_delivery._spawn", fake_spawn) + assert ( + exec_with_secret( + object(), entry, "selected_value", ["probe"], mode="exec-env" + ) + == 0 + ) + + +def test_exec_rejects_undeclared_field_before_fetch(monkeypatch): + entry = validate_entry(VALID) + monkeypatch.setattr( + "secrets_engine.exec_delivery._fetch_value", + lambda *_args, **_kwargs: pytest.fail("must not fetch undeclared field"), + ) + with pytest.raises(DeliveryError): + exec_with_secret(object(), entry, "other_field", ["probe"], mode="exec-env") diff --git a/tests/test_existing_lane_admission.py b/tests/test_existing_lane_admission.py new file mode 100644 index 0000000..1d48782 --- /dev/null +++ b/tests/test_existing_lane_admission.py @@ -0,0 +1,117 @@ +import copy +import os + +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.plan import build_plan +from secrets_engine.provision import provision_from_file +from secrets_engine.routing import route_lane +from tests.test_catalog import VALID + + +class RecordingApplyClient: + def __init__(self): + self.policy_writes = [] + self.approle_writes = [] + + def kv_mount_exists(self, _mount): + raise AssertionError("existing mount must not be inspected for mutation") + + def ensure_kv_mount(self, _mount): + raise AssertionError("existing mount must not be created") + + def read_policy(self, _name): + return None + + def write_policy(self, name, hcl): + self.policy_writes.append((name, hcl)) + + def ensure_approle_enabled(self): + pass + + def write_approle(self, role_name, policies, ttl="30m", **_kwargs): + self.approle_writes.append((role_name, policies, ttl)) + + +class RecordingProvisionClient: + def __init__(self): + self.puts = [] + + def ensure_kv_mount(self, _mount): + raise AssertionError("existing mount must not be created during provision") + + def kv_put(self, mount, path, field, value): + self.puts.append((mount, path, field, value)) + + +def _existing_mount_entry(): + data = copy.deepcopy(VALID) + data.update( + { + "stage": "prod", + "mount": "platform", + "path": "workloads/example/runtime", + "mount_management": "existing", + "workload_delivery": [ + {"mode": "external-secrets", "owner": "rapp-example"} + ], + } + ) + return validate_entry(data) + + +def test_apply_existing_mount_only_adds_approved_delivery_auth(): + entry = _existing_mount_entry() + plan = build_plan(entry, "prod", decision_id="approved") + client = RecordingApplyClient() + result = apply_plan(client, entry, plan) + assert any("externally managed; no mutation" in item for item in result.skipped) + assert len(client.policy_writes) == 1 + assert len(client.approle_writes) == 1 + + +def test_apply_existing_delivery_auth_is_fully_non_mutating(): + data = copy.deepcopy(VALID) + data["mount_management"] = "existing" + data["delivery_auth"] = { + "method": "approle", + "management": "existing", + "role_name": "existing-exact-role", + "policy_name": "existing-exact-policy", + } + entry = validate_entry(data) + plan = build_plan(entry, "test", decision_id="approved") + client = RecordingApplyClient() + result = apply_plan(client, entry, plan) + assert client.policy_writes == [] + assert client.approle_writes == [] + assert len(result.skipped) == 3 + assert all("no mutation" in item for item in result.skipped[:2]) + + +def test_provision_existing_mount_never_attempts_mount_creation(tmp_path): + entry = _existing_mount_entry() + value_file = tmp_path / "value" + value_file.write_text("test-only-value", encoding="utf-8") + os.chmod(value_file, 0o600) + client = RecordingProvisionClient() + provision_from_file(client, entry, "api_token", value_file) + assert client.puts == [ + ("platform", "workloads/example/runtime", "api_token", "test-only-value") + ] + + +def test_admitted_lanes_fail_closed_without_resolved_ccr(tmp_path): + entries = load_catalog(repo_root() / "catalog") + for lane_id in ( + "issue-core-ingestion-api-key", + "reuse-surface-hub-write-token", + "openrouter-llm-connect", + "forgejo-admin-api-token", + "email-connect-transactional", + ): + result = route_lane(entries[lane_id], hub_url="", repo_root=tmp_path, client=None) + assert result.ready is False + assert result.missing.startswith("approved decision for 'CCR-2026-") + assert result.next_command.startswith("secrets-engine decision inspect CCR-2026-") diff --git a/tests/test_guards.py b/tests/test_guards.py index 9846508..437d221 100644 --- a/tests/test_guards.py +++ b/tests/test_guards.py @@ -65,3 +65,52 @@ def test_valid_plan_builds(): assert plan.policy_name == "se-test-test-lane" assert any(a.kind == "approle" for a in plan.actions) assert "secret/data/test/team/thing" in plan.policy_hcl + + +def test_existing_mount_plan_has_check_not_mount_mutation(): + e = _entry( + stage="prod", + mount="platform", + path="workloads/example/runtime", + mount_management="existing", + ) + plan = build_plan(e, "prod", decision_id="d1") + assert any(a.kind == "kv-mount-check" for a in plan.actions) + assert not any(a.kind == "kv-mount" for a in plan.actions) + assert "platform/data/workloads/example/runtime" in plan.policy_hcl + + +def test_existing_auth_plan_has_checks_not_auth_mutations(): + e = _entry( + delivery_auth={ + "method": "approle", + "management": "existing", + "role_name": "existing-exact-role", + "policy_name": "existing-exact-policy", + } + ) + plan = build_plan(e, "test", decision_id="d1") + assert any(a.kind == "policy-check" for a in plan.actions) + assert any(a.kind == "approle-check" for a in plan.actions) + assert not any(a.kind == "policy" for a in plan.actions) + assert not any(a.kind == "approle" for a in plan.actions) + + +def test_every_admitted_lane_renders_existing_mount_check_and_exact_policy(): + from secrets_engine.catalog import load_catalog + from secrets_engine.config import repo_root + + entries = load_catalog(repo_root() / "catalog") + for lane_id in ( + "issue-core-ingestion-api-key", + "reuse-surface-hub-write-token", + "openrouter-llm-connect", + "forgejo-admin-api-token", + "email-connect-transactional", + ): + entry = entries[lane_id] + plan = build_plan(entry, "prod", decision_id=entry.approval["decision_ref"]) + assert [a.kind for a in plan.actions] == ["kv-mount-check", "policy", "approle"] + assert f'path "{entry.kv_data_path}"' in plan.policy_hcl + assert "*" not in entry.kv_data_path + assert plan.role_name.startswith("se-prod-") diff --git a/tests/test_integration_bao.py b/tests/test_integration_bao.py index 26c5131..ab8a02d 100644 --- a/tests/test_integration_bao.py +++ b/tests/test_integration_bao.py @@ -44,7 +44,7 @@ def bao_dev(): addr = f"http://127.0.0.1:{port}" token = "se-test-root" proc = subprocess.Popen( - [bao, "server", "-dev", f"-dev-root-token-id={token}", + [bao, "server", "-dev", "-dev-no-store-token", f"-dev-root-token-id={token}", f"-dev-listen-address=127.0.0.1:{port}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) diff --git a/tests/test_safe_paths.py b/tests/test_safe_paths.py new file mode 100644 index 0000000..f218780 --- /dev/null +++ b/tests/test_safe_paths.py @@ -0,0 +1,21 @@ +from secrets_engine.safe_paths import containing_git_worktree + + +def test_empty_git_named_directory_is_not_a_worktree(tmp_path): + (tmp_path / ".git").mkdir() + assert containing_git_worktree(tmp_path / "secret-file") is None + + +def test_git_directory_with_head_is_a_worktree(tmp_path): + repo = tmp_path / "repo" + marker = repo / ".git" + marker.mkdir(parents=True) + (marker / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + assert containing_git_worktree(repo / "secret-file") == repo + + +def test_git_file_marks_linked_worktree(tmp_path): + repo = tmp_path / "linked" + repo.mkdir() + (repo / ".git").write_text("gitdir: /outside/worktrees/linked\n", encoding="utf-8") + assert containing_git_worktree(repo / "secret-file") == repo diff --git a/workplans/ADHOC-2026-08-21.md b/workplans/ADHOC-2026-08-21.md new file mode 100644 index 0000000..1391a67 --- /dev/null +++ b/workplans/ADHOC-2026-08-21.md @@ -0,0 +1,32 @@ +--- +id: ADHOC-2026-08-21 +type: workplan +title: "OpenBao dev-test and safe-path robustness" +domain: infotech +repo: secrets-engine +status: finished +owner: codex +topic_slug: custodian +created: "2026-08-21" +updated: "2026-08-21" +--- + +# ADHOC-2026-08-21 - OpenBao dev-test and safe-path robustness + +## Keep verification safe in sandboxed environments + +```task +id: ADHOC-2026-08-21-T01 +status: done +priority: low +``` + +While verifying SECRETS-WP-0006, the suite exposed two environment-sensitive +test failures. OpenBao 2.5.5 dev mode attempted to persist its root token under +the read-only home directory, and an empty `/tmp/.git` sandbox marker was +treated as a real Git worktree. + +The dev fixture now uses `-dev-no-store-token`. Secret provisioning and AppRole +handoff still reject real worktrees (`.git` file or `.git/HEAD`) but ignore an +empty directory that is not a valid Git marker. Unit coverage preserves both +the rejection and false-positive cases. diff --git a/workplans/SECRETS-WP-0006-catalog-lane-adoption.md b/workplans/SECRETS-WP-0006-catalog-lane-adoption.md new file mode 100644 index 0000000..eabd684 --- /dev/null +++ b/workplans/SECRETS-WP-0006-catalog-lane-adoption.md @@ -0,0 +1,228 @@ +--- +id: SECRETS-WP-0006 +type: workplan +title: "Adopt concrete OpenBao credential lanes from ops-warden" +domain: infotech +repo: secrets-engine +status: active +owner: codex +topic_slug: custodian +created: "2026-08-21" +updated: "2026-08-21" +--- + +# SECRETS-WP-0006 - Adopt concrete OpenBao credential lanes from ops-warden + +## Goal + +Move concrete, OpenBao-backed credential workflows from ops-warden's interim +proxy into the secrets-engine catalog and operating surface without moving +secret custody out of OpenBao or misassigning generic routing and identity +capabilities to this repo. + +The intake originates in ops-warden message +`cbd312f8-347e-44cc-933b-9712e2036646` after earlier requests on 2026-08-11 and +2026-08-15. + +## Scope decision + +Accept these concrete OpenBao KV lanes for catalog admission and staged +operation: + +- `issue-core-ingestion-api-key`; +- `reuse-surface-hub-write-token`; +- `openrouter-llm-connect`; +- `forgejo-admin-api-token`; +- `email-connect-transactional`. + +Do not create catalog entries for these generic or non-owned routes: + +- `openbao-api-key` remains a generic credential-routing template. Concrete + workload credentials beneath it may become catalog lanes, but the template is + not itself a secret lane. +- `key-cape-oidc-login` remains owned by key-cape/Keycloak. secrets-engine may + consume OIDC identity for OpenBao authentication, but it does not own login, + MFA, or identity-token issuance. + +The current implementation is generic at the catalog level for KV +`mount`/`path`/`fields`, and `secrets-engine exec` supports `exec-env` plus the +npm-specific `npm-config` adapter. The remaining delivery-mode names are not all +exec adapters: `read-check` is verification, `approle-login` is auth-capability +handoff, and `exec-file`/`wrapped` are not yet implemented by `exec`. + +## Design constraints + +- OpenBao remains the custody, policy, lease, and audit backend. +- No raw value, token, password, wrapped token, AppRole material, or accessor is + written to Git, State Hub, chat, prompts, workplans, or normal logs. +- Catalog adoption does not by itself authorize production mutation or value + access. Each production lane needs a resolved approval and a reviewed live + apply/verification plan. +- Existing workload delivery must not be displaced. ESO/Kubernetes delivery and + provider-side rotation remain owned by their current runtime and provider + owners until an explicit migration is approved. +- High-risk lanes must retain exact consumer, field, rotation-owner, denial, and + revocation metadata. +- The production stage role currently manages owner-scoped paths on the + `secret` mount, while these lanes use the existing `platform` mount. Admission + must not silently broaden the stage role or attempt to create that mount. + +## Confirm capability and ownership with ops-warden + +```task +id: SECRETS-WP-0006-T01 +status: done +priority: high +``` + +Completed 2026-08-21. Replied in the existing thread with State Hub message +`7fb0c2ce-be41-4a1d-95b0-e2cdd97d5718`, marked the inbound message read, and +recorded decision `ae676382-1826-4e04-aa4e-bb77990c7a0d`. The reply accepted +the five concrete KV lanes, declined the generic OpenBao route and key-cape +login route, corrected the six-delivery-mode interpretation, and kept interim +proxies in place pending per-lane verification. + +Reply in the existing State Hub thread with the accepted five-lane scope, the +two declined generic/non-owned routes, and the delivery-mode correction. Accept +ops-warden's offer of a PR-shaped non-secret metadata contribution for the five +concrete lanes. + +Done when the reply is posted, the inbound message is marked read, and this +decision is recorded as non-secret State Hub evidence. + +## Define admission contract for existing production lanes + +```task +id: SECRETS-WP-0006-T02 +status: done +priority: high +``` + +Completed 2026-08-21. Added explicit `mount_management`, `delivery_auth`, and +`workload_delivery` semantics, with the operating contract documented in +`docs/catalog-admission.md`. Existing mounts and externally managed delivery +auth now render non-mutating checks; apply cannot create or rewrite them. +Existing workload delivery remains independent metadata, while an optional +engine-managed AppRole is a separate approval-gated native delivery surface. + +Define how a catalog entry represents a lane whose KV mount already exists and +whose workload delivery is ESO/Kubernetes rather than a secrets-engine-created +AppRole. Separate these concepts where needed: + +- existing-mount versus engine-managed mount; +- workload delivery versus agent/operator exec delivery; +- catalog/verification metadata versus engine-managed consumer auth; +- provider-side rotation owner versus OpenBao value update workflow. + +Acceptance: + +- Dry-run cannot propose creating or broadening the shared `platform` mount. +- A lane can retain its existing workload auth/delivery while optionally + exposing an approved exec-time adapter. +- The production stage role is not broadened by implication. + +## Review and import the five catalog entries + +```task +id: SECRETS-WP-0006-T03 +status: progress +priority: high +``` + +Progress 2026-08-21: the offered contribution did not arrive, so drafted all +five entries from ops-warden playbooks and the authoritative railiance-platform +CCRs. Sent message `ceace632-2332-46d8-a2b3-1714fbfa7e2c` requesting line-by-line +confirmation of coordinates, fields, consumers, lifecycle owners, and proposed +native delivery. T03 remains progress until that review returns; interim proxy +ownership is unchanged. + +Review ops-warden's proposed entries line by line against the schema, canonical +security boundary, live route metadata, and owning workload documentation. +Correct field lists, mount/path, consumers, delivery modes, approval references, +rotation owners, and negative checks before merging. + +Acceptance: + +- All five files contain non-secret metadata only. +- Generic `openbao-api-key` and `key-cape-oidc-login` entries are absent. +- Each entry names an exact lane rather than a provider-wide wildcard. +- Each high-risk lane has explicit rotation and deactivation expectations. + +## Implement guards and offline verification + +```task +id: SECRETS-WP-0006-T04 +status: done +priority: high +``` + +Completed 2026-08-21: implemented non-mutating existing-mount/auth planning and +apply behavior, workload-delivery validation, exact field guards for exec and +verification, safe policy/role overrides, route guidance, and focused tests. +All five admitted entries load and render `kv-mount-check` plus an exact-path +policy and bounded AppRole plan. Generic/wildcard entries and undeclared fields +are negatively covered. Privileged provision, verify, exec, handoff, and live +revoke operations now fail closed without a resolved approval. Full verification +passes 82 tests. + +Implement only the schema/planning/delivery changes required by the admitted +entries. Add tests for shared existing mounts, auth/delivery separation, +multi-field selection, exact exec-env injection, and rejection of wildcard or +generic provider-wide entries. + +Acceptance: + +- The complete catalog loads and validates. +- Each admitted lane renders a non-mutating guarded plan. +- Negative tests prove no wildcard path, broad policy, undeclared field, or + unsupported delivery mode is accepted. +- `uv run pytest` and `git diff --check` pass. + +## Stage live apply and verification + +```task +id: SECRETS-WP-0006-T05 +status: wait +priority: high +``` + +For each lane, obtain the required decision/operator approval before any live +OpenBao policy, auth-role, provisioning, rotation, or delivery change. Start +with metadata/capability-safe checks and preserve the current ops-warden proxy +until native positive and negative verification succeeds. + +Acceptance per lane: + +- Approval is resolved and linked. +- Existing workload delivery remains healthy. +- Positive access and unrelated-consumer denial are proven without reading a + value into evidence. +- Rollback/revocation steps are recorded. + +## Reconcile routing ownership and retire interim proxies + +```task +id: SECRETS-WP-0006-T06 +status: wait +priority: medium +``` + +After a lane is natively ready, update the ops-warden delegation register and +safe next action for that lane. Retire interim proxy operation lane by lane, +not as a seven-lane batch. + +Acceptance: + +- Native readiness is evidenced before the proxy is retired. +- ops-warden remains the routing front door and does not vend non-SSH secrets. +- The generic OpenBao route and key-cape login route retain their correct + owners. + +## Exit criteria + +- The five concrete OpenBao lanes have reviewed catalog entries and guarded + offline plans. +- Live ownership moves only for lanes that pass approved positive/negative + verification. +- The two generic/non-owned routes are not mislabeled as secrets-engine lanes. +- No secret material is recorded in repository or coordination evidence.