feat: admit existing OpenBao catalog lanes
This commit is contained in:
parent
9d383442c8
commit
784be978bf
29 changed files with 1490 additions and 79 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
78
catalog/email-connect-transactional.yaml
Normal file
78
catalog/email-connect-transactional.yaml
Normal file
|
|
@ -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
|
||||
77
catalog/forgejo-admin-api-token.yaml
Normal file
77
catalog/forgejo-admin-api-token.yaml
Normal file
|
|
@ -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
|
||||
78
catalog/issue-core-ingestion-api-key.yaml
Normal file
78
catalog/issue-core-ingestion-api-key.yaml
Normal file
|
|
@ -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
|
||||
72
catalog/openrouter-llm-connect.yaml
Normal file
72
catalog/openrouter-llm-connect.yaml
Normal file
|
|
@ -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
|
||||
77
catalog/reuse-surface-hub-write-token.yaml
Normal file
77
catalog/reuse-surface-hub-write-token.yaml
Normal file
|
|
@ -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
|
||||
121
docs/catalog-admission.md
Normal file
121
docs/catalog-admission.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<role>) 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "<memory>") -> 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 = "<memory>") -> 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 = "<memory>") -> 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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} -- <command...>"
|
||||
if {"exec-env", "npm-config"}.intersection(entry.delivery_modes):
|
||||
next_command = f"secrets-engine exec --catalog {entry.id} -- <command...>"
|
||||
else:
|
||||
next_command = (
|
||||
f"secrets-engine verify {entry.id} --positive --negative"
|
||||
)
|
||||
|
||||
return RouteResult(
|
||||
catalog_id=entry.id,
|
||||
|
|
|
|||
20
src/secrets_engine/safe_paths.py
Normal file
20
src/secrets_engine/safe_paths.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/*"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
117
tests/test_existing_lane_admission.py
Normal file
117
tests/test_existing_lane_admission.py
Normal file
|
|
@ -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-")
|
||||
|
|
@ -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-")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
21
tests/test_safe_paths.py
Normal file
21
tests/test_safe_paths.py
Normal file
|
|
@ -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
|
||||
32
workplans/ADHOC-2026-08-21.md
Normal file
32
workplans/ADHOC-2026-08-21.md
Normal file
|
|
@ -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.
|
||||
228
workplans/SECRETS-WP-0006-catalog-lane-adoption.md
Normal file
228
workplans/SECRETS-WP-0006-catalog-lane-adoption.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue