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)
|
- 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)
|
- 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)
|
- 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
|
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
|
reached only through the `bao` CLI adapter (`openbao.py`); the rest of the code
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@
|
||||||
|
|
||||||
| Kind | ID | Status | Lane | Source |
|
| 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-0001 | finished | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md |
|
||||||
| workplan | SECRETS-WP-0002 | finished | — | workplans/SECRETS-WP-0002-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-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-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-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-T02 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md |
|
||||||
| task | SECRETS-WP-0001-T03 | 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-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-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-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
|
hardcodes a registry. The pilot publishes `@whynot/design` from the
|
||||||
`coulomb/whynot-design` repo to `https://forgejo.coulomb.social/api/packages/coulomb/npm/`.
|
`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
|
## Install
|
||||||
|
|
||||||
```bash
|
```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
|
# 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.
|
# 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):
|
if client.kv_mount_exists(entry.mount):
|
||||||
skipped.append(f"kv-mount {entry.mount} (already present)")
|
skipped.append(f"kv-mount {entry.mount} (already present)")
|
||||||
else:
|
else:
|
||||||
client.ensure_kv_mount(entry.mount)
|
client.ensure_kv_mount(entry.mount)
|
||||||
applied.append(f"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:
|
else:
|
||||||
skipped.append(f"kv-mount {entry.mount} (not applicable for {entry.kind})")
|
skipped.append(f"kv-mount {entry.mount} (not applicable for {entry.kind})")
|
||||||
|
|
||||||
# 2. Consumer ACL policy (write only if changed).
|
# 2. Consumer ACL policy (write only if changed).
|
||||||
current = client.read_policy(plan.policy_name)
|
if entry.manages_delivery_auth:
|
||||||
if current and _normalize(current) == _normalize(plan.policy_hcl):
|
current = client.read_policy(plan.policy_name)
|
||||||
skipped.append(f"policy {plan.policy_name} (unchanged)")
|
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:
|
else:
|
||||||
client.write_policy(plan.policy_name, plan.policy_hcl)
|
skipped.append(f"policy {plan.policy_name} (externally managed; no mutation)")
|
||||||
applied.append(f"policy {plan.policy_name}")
|
|
||||||
|
|
||||||
# 3. Consumer approle bound to that policy.
|
# 3. Consumer approle bound to that policy.
|
||||||
client.ensure_approle_enabled()
|
if entry.manages_delivery_auth:
|
||||||
if entry.kind == "auth-capability":
|
client.ensure_approle_enabled()
|
||||||
client.write_approle(
|
if entry.kind == "auth-capability":
|
||||||
plan.role_name,
|
client.write_approle(
|
||||||
[plan.policy_name],
|
plan.role_name,
|
||||||
ttl=entry.token_ttl,
|
[plan.policy_name],
|
||||||
max_ttl=entry.token_max_ttl,
|
ttl=entry.token_ttl,
|
||||||
secret_id_ttl=entry.secret_id_ttl,
|
max_ttl=entry.token_max_ttl,
|
||||||
secret_id_num_uses=entry.secret_id_num_uses,
|
secret_id_ttl=entry.secret_id_ttl,
|
||||||
token_num_uses=entry.token_num_uses,
|
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:
|
else:
|
||||||
client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl)
|
skipped.append("approle (no native delivery auth declared)")
|
||||||
applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]")
|
|
||||||
|
|
||||||
return ApplyResult(applied=applied, skipped=skipped)
|
return ApplyResult(applied=applied, skipped=skipped)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ VALID_DELIVERY_MODES = (
|
||||||
"approle-login",
|
"approle-login",
|
||||||
)
|
)
|
||||||
VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
|
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 = (
|
REQUIRED_FIELDS = (
|
||||||
"id",
|
"id",
|
||||||
|
|
@ -66,6 +70,10 @@ class CatalogEntry:
|
||||||
rotation: dict[str, Any]
|
rotation: dict[str, Any]
|
||||||
deactivation: dict[str, Any]
|
deactivation: dict[str, Any]
|
||||||
audit: 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)
|
delivery_config: dict[str, Any] = field(default_factory=dict)
|
||||||
auth_capability: dict[str, Any] = field(default_factory=dict)
|
auth_capability: dict[str, Any] = field(default_factory=dict)
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
@ -95,14 +103,62 @@ class CatalogEntry:
|
||||||
def policy_name(self) -> str:
|
def policy_name(self) -> str:
|
||||||
if self.kind == "auth-capability":
|
if self.kind == "auth-capability":
|
||||||
return self.auth_capability.get("policy_name") or self.id
|
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}"
|
return f"se-{self.stage}-{self.id}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role_name(self) -> str:
|
def role_name(self) -> str:
|
||||||
if self.kind == "auth-capability":
|
if self.kind == "auth-capability":
|
||||||
return self.auth_capability.get("role_name") or self.id
|
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}"
|
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
|
@property
|
||||||
def auth_allowed_paths(self) -> dict[str, list[str]]:
|
def auth_allowed_paths(self) -> dict[str, list[str]]:
|
||||||
"""Allowed OpenBao paths/capabilities for an auth-capability lane."""
|
"""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":
|
if data["kind"] == "auth-capability":
|
||||||
data.setdefault("fields", [])
|
data.setdefault("fields", [])
|
||||||
|
data.setdefault("mount_management", "existing")
|
||||||
|
data.setdefault("delivery_auth", {})
|
||||||
|
data.setdefault("workload_delivery", [])
|
||||||
else:
|
else:
|
||||||
data.setdefault("auth_capability", {})
|
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, "", [], {})]
|
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"):
|
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'"
|
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"]
|
approval = data["approval"]
|
||||||
if not isinstance(approval, dict) or "model" not in approval:
|
if not isinstance(approval, dict) or "model" not in approval:
|
||||||
raise CatalogError(f"{source}: approval must include a 'model'")
|
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}"
|
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
|
# npm-config delivery must declare WHERE it publishes (registry + scope), so
|
||||||
# the registry is catalog data, never hardcoded in the engine.
|
# the registry is catalog data, never hardcoded in the engine.
|
||||||
if "npm-config" in modes:
|
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)
|
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 ------------------------------------------------------
|
# -- 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"openbao: mount={e.mount} allowed={sorted(e.auth_allowed_paths)}")
|
||||||
print(f"approle: {e.role_name} policy={e.policy_name}")
|
print(f"approle: {e.role_name} policy={e.policy_name}")
|
||||||
else:
|
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"consumers: {[c['name'] for c in e.consumers]}")
|
||||||
print(f"delivery: {e.delivery_modes}")
|
print(f"delivery: {e.delivery_modes}")
|
||||||
print(f"approval: {e.approval.get('model')} ref={e.approval.get('decision_ref','')}")
|
print(f"approval: {e.approval.get('model')} ref={e.approval.get('decision_ref','')}")
|
||||||
print(f"verification: {e.verification}")
|
print(f"verification: {e.verification}")
|
||||||
print(f"rotation: {e.rotation}")
|
print(f"rotation: {e.rotation}")
|
||||||
print(f"deactivation: {e.deactivation}")
|
print(f"deactivation: {e.deactivation}")
|
||||||
|
if e.risk:
|
||||||
|
print(f"risk: {e.risk}")
|
||||||
print(f"description: {e.description.strip()}")
|
print(f"description: {e.description.strip()}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
@ -172,6 +195,7 @@ def cmd_provision(cfg: Config, args) -> int:
|
||||||
if args.stage != entry.stage:
|
if args.stage != entry.stage:
|
||||||
from secrets_engine.errors import ProvisioningError
|
from secrets_engine.errors import ProvisioningError
|
||||||
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
|
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)
|
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||||
if args.generate:
|
if args.generate:
|
||||||
|
|
@ -182,12 +206,13 @@ def cmd_provision(cfg: Config, args) -> int:
|
||||||
mode = "from-file"
|
mode = "from-file"
|
||||||
print(f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed")
|
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,
|
_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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(cfg: Config, args) -> int:
|
def cmd_verify(cfg: Config, args) -> int:
|
||||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
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)
|
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||||
if entry.stores_kv_value() and not field:
|
if entry.stores_kv_value() and not field:
|
||||||
|
|
@ -202,7 +227,8 @@ def cmd_verify(cfg: Config, args) -> int:
|
||||||
if not r.passed:
|
if not r.passed:
|
||||||
rc = 7
|
rc = 7
|
||||||
_writer(cfg).record("verify", result=f"{r.check}:{'pass' if r.passed else 'fail'}",
|
_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
|
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}'")
|
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
|
||||||
if entry.kind != "auth-capability":
|
if entry.kind != "auth-capability":
|
||||||
raise ProvisioningError(f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability")
|
raise ProvisioningError(f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability")
|
||||||
decision = None
|
decision = _require_lane_approval(cfg, entry)
|
||||||
if entry.approval_required():
|
|
||||||
decision = resolve_decision(
|
|
||||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
|
||||||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
|
||||||
)
|
|
||||||
require_approved(entry, decision)
|
|
||||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||||
result = write_approle_handoff(
|
result = write_approle_handoff(
|
||||||
client,
|
client,
|
||||||
|
|
@ -257,12 +277,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
||||||
from secrets_engine.exec_delivery import exec_with_secret
|
from secrets_engine.exec_delivery import exec_with_secret
|
||||||
entry = get_entry(cfg.catalog_dir, args.catalog)
|
entry = get_entry(cfg.catalog_dir, args.catalog)
|
||||||
# require approval + readiness before running.
|
# require approval + readiness before running.
|
||||||
if entry.approval_required():
|
decision = _require_lane_approval(cfg, entry)
|
||||||
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)
|
|
||||||
if not args.command:
|
if not args.command:
|
||||||
from secrets_engine.errors import DeliveryError
|
from secrets_engine.errors import DeliveryError
|
||||||
raise DeliveryError("no command after '--'")
|
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 "")
|
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||||
w = _writer(cfg)
|
w = _writer(cfg)
|
||||||
w.record("exec", result="attempt", catalog_id=entry.id, stage=entry.stage,
|
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})
|
detail={"command": args.command[0], "mode": args.mode})
|
||||||
rc = exec_with_secret(client, entry, field, args.command, 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,
|
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]})
|
detail={"command": args.command[0]})
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
|
|
@ -322,6 +339,7 @@ def cmd_route(cfg: Config, args) -> int:
|
||||||
|
|
||||||
def cmd_revoke(cfg: Config, args) -> int:
|
def cmd_revoke(cfg: Config, args) -> int:
|
||||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
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)
|
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||||
if entry.kind == "auth-capability":
|
if entry.kind == "auth-capability":
|
||||||
if args.dry_run:
|
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"revoked lane '{entry.id}': deleted approle {entry.role_name} "
|
||||||
f"and policy {entry.policy_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
|
return 0
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} "
|
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
|
return 0
|
||||||
client.kv_delete_metadata(entry.mount, entry.path)
|
client.kv_delete_metadata(entry.mount, entry.path)
|
||||||
print(f"revoked lane '{entry.id}': KV metadata deleted at {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
|
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:
|
def _fetch_value(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str:
|
||||||
"""Read the field value via an approle-scoped token. Held in memory only."""
|
"""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:
|
try:
|
||||||
token = client.approle_login_token(entry.role_name)
|
token = client.approle_login_token(entry.role_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -126,6 +130,10 @@ def exec_with_secret(
|
||||||
"""
|
"""
|
||||||
if not command:
|
if not command:
|
||||||
raise DeliveryError("no command given to exec")
|
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)
|
declared = set(entry.delivery_modes)
|
||||||
if mode == "auto":
|
if mode == "auto":
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from pathlib import Path
|
||||||
from secrets_engine.catalog import CatalogEntry
|
from secrets_engine.catalog import CatalogEntry
|
||||||
from secrets_engine.errors import ProvisioningError
|
from secrets_engine.errors import ProvisioningError
|
||||||
from secrets_engine.openbao import OpenBaoClient
|
from secrets_engine.openbao import OpenBaoClient
|
||||||
|
from secrets_engine.safe_paths import containing_git_worktree
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -27,12 +28,12 @@ class HandoffResult:
|
||||||
|
|
||||||
def _assert_outside_git_worktree(path: Path) -> Path:
|
def _assert_outside_git_worktree(path: Path) -> Path:
|
||||||
resolved = path.expanduser().resolve()
|
resolved = path.expanduser().resolve()
|
||||||
for parent in (resolved.parent, *resolved.parent.parents):
|
worktree = containing_git_worktree(resolved)
|
||||||
if (parent / ".git").exists():
|
if worktree is not None:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"handoff file {resolved} is inside a Git worktree ({parent}); "
|
f"handoff file {resolved} is inside a Git worktree ({worktree}); "
|
||||||
"keep AppRole material outside repos"
|
"keep AppRole material outside repos"
|
||||||
)
|
)
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from secrets_engine.errors import BackendError, ProvisioningError
|
from secrets_engine.errors import BackendError, ProvisioningError
|
||||||
|
from secrets_engine.safe_paths import containing_git_worktree
|
||||||
|
|
||||||
|
|
||||||
def _check_token_file(path: Path) -> str:
|
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"
|
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
|
||||||
)
|
)
|
||||||
# Refuse a token file living inside a Git worktree.
|
# Refuse a token file living inside a Git worktree.
|
||||||
for parent in path.resolve().parents:
|
worktree = containing_git_worktree(path)
|
||||||
if (parent / ".git").exists():
|
if worktree is not None:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"bootstrap token file {path} is inside a Git worktree ({parent}); "
|
f"bootstrap token file {path} is inside a Git worktree ({worktree}); "
|
||||||
"store it outside any repo"
|
"store it outside any repo"
|
||||||
)
|
)
|
||||||
token = path.read_text(encoding="utf-8").strip()
|
token = path.read_text(encoding="utf-8").strip()
|
||||||
if not token:
|
if not token:
|
||||||
raise ProvisioningError(f"bootstrap token file {path} is empty")
|
raise ProvisioningError(f"bootstrap token file {path} is empty")
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ from secrets_engine.roles import (
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlanAction:
|
class PlanAction:
|
||||||
kind: str # "kv-mount" | "policy" | "approle"
|
kind: str # mutation or non-mutating check/preview action
|
||||||
target: str # human-readable target
|
target: str # human-readable target
|
||||||
detail: dict[str, Any] = field(default_factory=dict)
|
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
|
assert_path_in_stage(entry) # path must be in-stage, no wildcards
|
||||||
policy_name, policy_hcl = consumer_policy_for(entry) # runs assert_policy_safe
|
policy_name, policy_hcl = consumer_policy_for(entry) # runs assert_policy_safe
|
||||||
|
|
||||||
actions = [
|
mount_action = (
|
||||||
PlanAction("kv-mount", entry.mount, {"type": "kv-v2"}),
|
PlanAction("kv-mount", entry.mount, {"type": "kv-v2", "management": "engine"})
|
||||||
PlanAction(
|
if entry.manages_mount
|
||||||
"policy",
|
else PlanAction(
|
||||||
policy_name,
|
"kv-mount-check",
|
||||||
{"paths": f"{entry.mount}/data/{entry.path}"},
|
entry.mount,
|
||||||
),
|
{"type": "kv-v2", "management": "existing", "mutation": "none"},
|
||||||
PlanAction(
|
)
|
||||||
"approle",
|
)
|
||||||
entry.role_name,
|
actions = [mount_action]
|
||||||
{"token_policies": policy_name, "auth": "approle"},
|
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(
|
return Plan(
|
||||||
catalog_id=entry.id,
|
catalog_id=entry.id,
|
||||||
stage=stage,
|
stage=stage,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from pathlib import Path
|
||||||
from secrets_engine.catalog import CatalogEntry
|
from secrets_engine.catalog import CatalogEntry
|
||||||
from secrets_engine.errors import ProvisioningError
|
from secrets_engine.errors import ProvisioningError
|
||||||
from secrets_engine.openbao import OpenBaoClient
|
from secrets_engine.openbao import OpenBaoClient
|
||||||
|
from secrets_engine.safe_paths import containing_git_worktree
|
||||||
|
|
||||||
|
|
||||||
def _read_value_file(path: Path) -> str:
|
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"value file {path} is group/other-accessible "
|
||||||
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
|
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
|
||||||
)
|
)
|
||||||
for parent in path.resolve().parents:
|
worktree = containing_git_worktree(path)
|
||||||
if (parent / ".git").exists():
|
if worktree is not None:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"value file {path} is inside a Git worktree ({parent}); "
|
f"value file {path} is inside a Git worktree ({worktree}); "
|
||||||
"keep secret material outside repos"
|
"keep secret material outside repos"
|
||||||
)
|
)
|
||||||
value = path.read_text(encoding="utf-8").strip()
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
if not value:
|
if not value:
|
||||||
raise ProvisioningError(f"value file {path} is empty")
|
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}"
|
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
|
||||||
)
|
)
|
||||||
value = _read_value_file(Path(file_path))
|
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)
|
client.kv_put(entry.mount, entry.path, field, value)
|
||||||
del value
|
del value
|
||||||
return field
|
return field
|
||||||
|
|
@ -77,7 +79,8 @@ def provision_generated(client: OpenBaoClient, entry: CatalogEntry, field: str)
|
||||||
)
|
)
|
||||||
alphabet = string.ascii_letters + string.digits
|
alphabet = string.ascii_letters + string.digits
|
||||||
value = "test-" + "".join(_secrets.choice(alphabet) for _ in range(32))
|
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)
|
client.kv_put(entry.mount, entry.path, field, value)
|
||||||
del value
|
del value
|
||||||
return field
|
return field
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,10 @@ def route_lane(
|
||||||
metadata_applied = False
|
metadata_applied = False
|
||||||
value_present = False
|
value_present = False
|
||||||
if client is not None and client.is_reachable():
|
if client is not None and client.is_reachable():
|
||||||
policy_applied = client.read_policy(entry.policy_name) is not None
|
if entry.has_delivery_auth:
|
||||||
role_applied = client.approle_exists(entry.role_name)
|
policy_applied = client.read_policy(entry.policy_name) is not None
|
||||||
metadata_applied = policy_applied and role_applied
|
role_applied = client.approle_exists(entry.role_name)
|
||||||
|
metadata_applied = policy_applied and role_applied
|
||||||
if entry.stores_kv_value():
|
if entry.stores_kv_value():
|
||||||
# Presence check uses the engine's own token; reports boolean only.
|
# Presence check uses the engine's own token; reports boolean only.
|
||||||
field = entry.fields[0] if entry.fields else ""
|
field = entry.fields[0] if entry.fields else ""
|
||||||
|
|
@ -80,8 +81,17 @@ def route_lane(
|
||||||
missing = f"approved decision for '{decision_ref}'"
|
missing = f"approved decision for '{decision_ref}'"
|
||||||
next_command = f"secrets-engine decision inspect {decision_ref or entry.id}"
|
next_command = f"secrets-engine decision inspect {decision_ref or entry.id}"
|
||||||
elif not metadata_applied:
|
elif not metadata_applied:
|
||||||
missing = "OpenBao policy/role apply"
|
if entry.kind == "kv" and entry.delivery_auth_management == "existing":
|
||||||
next_command = f"secrets-engine apply {decision_ref or entry.id} --stage {entry.stage}"
|
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":
|
elif entry.kind == "auth-capability":
|
||||||
missing = ""
|
missing = ""
|
||||||
next_command = (
|
next_command = (
|
||||||
|
|
@ -96,7 +106,12 @@ def route_lane(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
missing = ""
|
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(
|
return RouteResult(
|
||||||
catalog_id=entry.id,
|
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:
|
def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> VerifyResult:
|
||||||
"""Approved consumer token must be able to read the field."""
|
"""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:
|
try:
|
||||||
token = client.approle_login_token(entry.role_name)
|
token = client.approle_login_token(entry.role_name)
|
||||||
except Exception as e: # backend errors -> failed verification, not a value leak
|
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(
|
def run_verification(
|
||||||
client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool
|
client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool
|
||||||
) -> list[VerifyResult]:
|
) -> 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] = []
|
results: list[VerifyResult] = []
|
||||||
if entry.kind == "auth-capability":
|
if entry.kind == "auth-capability":
|
||||||
if positive:
|
if positive:
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,86 @@ def test_unknown_delivery_mode_rejected():
|
||||||
validate_entry(data)
|
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():
|
def test_wildcard_path_rejected():
|
||||||
data = copy.deepcopy(VALID)
|
data = copy.deepcopy(VALID)
|
||||||
data["path"] = "test/*"
|
data["path"] = "test/*"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from secrets_engine.catalog import validate_entry
|
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.decisions import Decision, require_approved, resolve_decision
|
||||||
from secrets_engine.errors import DecisionError
|
from secrets_engine.errors import DecisionError
|
||||||
|
|
||||||
|
|
@ -50,3 +52,23 @@ def test_local_fixture_resolves(tmp_path):
|
||||||
def test_missing_decision_raises(tmp_path):
|
def test_missing_decision_raises(tmp_path):
|
||||||
with pytest.raises(DecisionError):
|
with pytest.raises(DecisionError):
|
||||||
resolve_decision(hub_url="http://127.0.0.1:1", repo_root=tmp_path, decision_ref="nope")
|
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():
|
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
|
assert (path.stat().st_mode & 0o077) == 0
|
||||||
# cleaned up on context exit
|
# cleaned up on context exit
|
||||||
assert not path.exists()
|
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 plan.policy_name == "se-test-test-lane"
|
||||||
assert any(a.kind == "approle" for a in plan.actions)
|
assert any(a.kind == "approle" for a in plan.actions)
|
||||||
assert "secret/data/test/team/thing" in plan.policy_hcl
|
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}"
|
addr = f"http://127.0.0.1:{port}"
|
||||||
token = "se-test-root"
|
token = "se-test-root"
|
||||||
proc = subprocess.Popen(
|
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}"],
|
f"-dev-listen-address=127.0.0.1:{port}"],
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
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