feat: admit and exercise KeyCape verifier custody activation
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
52b24eab9a
commit
b7861de20e
8 changed files with 662 additions and 184 deletions
|
|
@ -3,22 +3,21 @@ kind: credential-change-request
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
request_type: workload-kv-read
|
request_type: workload-kv-read
|
||||||
title: KeyCape verifier custody for the secrets-engine-approval confidential client
|
title: KeyCape verifier custody for the secrets-engine-approval confidential client
|
||||||
status: proposed
|
status: approved
|
||||||
created: '2026-09-08'
|
created: '2026-09-08'
|
||||||
updated: '2026-09-09'
|
updated: '2026-09-08'
|
||||||
requester:
|
requester:
|
||||||
agent: claude
|
agent: claude
|
||||||
reason: >-
|
reason: "KEY-WP-0013-T02 (State Hub message 278a3ebe-b529-49f6-bd1a-e3ebcf318260)\
|
||||||
KEY-WP-0013-T02 (State Hub message 278a3ebe-b529-49f6-bd1a-e3ebcf318260) asks
|
\ asks railiance-platform to admit custody for two confidential client_credentials\
|
||||||
railiance-platform to admit custody for two confidential client_credentials
|
\ registrations. This CCR covers the first: client secrets-engine-approval, subject\
|
||||||
registrations. This CCR covers the first: client secrets-engine-approval,
|
\ service:secrets-engine, audience approval-engine, tenant:platform per resolved\
|
||||||
subject service:secrets-engine, audience approval-engine, tenant:platform per
|
\ decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, 15m token lifetime. KeyCape owns\
|
||||||
resolved decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, 15m token lifetime.
|
\ the registration and issuance; this request establishes only the custody path\
|
||||||
KeyCape owns the registration and issuance; this request establishes only the
|
\ and the KeyCape-side delivery of the client secret it must verify presented\
|
||||||
custody path and the KeyCape-side delivery of the client secret it must verify
|
\ credentials against. It does not admit the client-side lane by which secrets-engine\
|
||||||
presented credentials against. It does not admit the client-side lane by which
|
\ would read its own copy \u2014 that is a separate request with a separate consumer\
|
||||||
secrets-engine would read its own copy — that is a separate request with a
|
\ gate."
|
||||||
separate consumer gate.
|
|
||||||
review:
|
review:
|
||||||
required: true
|
required: true
|
||||||
required_approvers:
|
required_approvers:
|
||||||
|
|
@ -28,25 +27,36 @@ review:
|
||||||
- at: '2026-09-08'
|
- at: '2026-09-08'
|
||||||
reviewer: railiance-platform (codex/claude)
|
reviewer: railiance-platform (codex/claude)
|
||||||
decision: paths_confirmed_field_corrected
|
decision: paths_confirmed_field_corrected
|
||||||
comment: >-
|
comment: 'KV path platform/workloads/secrets-engine/approval-client is confirmed
|
||||||
KV path platform/workloads/secrets-engine/approval-client is confirmed
|
unchanged: it matches the platform/workloads/<workload>/<lane> convention enforced
|
||||||
unchanged: it matches the platform/workloads/<workload>/<lane> convention
|
by scripts/credential-change.py and used by every admitted platform lane. The
|
||||||
enforced by scripts/credential-change.py and used by every admitted platform
|
proposed field name client_secret is corrected to CLIENT_SECRET; KV field names
|
||||||
lane. The proposed field name client_secret is corrected to CLIENT_SECRET;
|
are uppercase by convention (PROVISIONER_TOKEN, CORE_HUB_API_TOKEN) and lowercase
|
||||||
KV field names are uppercase by convention (PROVISIONER_TOKEN,
|
names fail FIELD_NAME_RE validation. The Kubernetes Secret key client-secret
|
||||||
CORE_HUB_API_TOKEN) and lowercase names fail FIELD_NAME_RE validation. The
|
and the KeyCape environment name are confirmed as proposed.'
|
||||||
Kubernetes Secret key client-secret and the KeyCape environment name are
|
- at: '2026-09-08T23:05:19+00:00'
|
||||||
confirmed as proposed.
|
reviewer: User (platform-operator; explicit session approval)
|
||||||
|
decision: approved
|
||||||
|
comment: User answered "I approve, go on." to the explicit two-CCR approval question
|
||||||
|
naming both required roles. Scope remains verifier-side custody and its attended
|
||||||
|
rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md.
|
||||||
|
No client-side read or factory spending grant.
|
||||||
|
- at: '2026-09-08T23:05:19+00:00'
|
||||||
|
reviewer: User (key-cape-owner; explicit session approval)
|
||||||
|
decision: approved
|
||||||
|
comment: User answered "I approve, go on." to the explicit two-CCR approval question
|
||||||
|
naming both required roles. Scope remains verifier-side custody and its attended
|
||||||
|
rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md.
|
||||||
|
No client-side read or factory spending grant.
|
||||||
target:
|
target:
|
||||||
domain: financials
|
domain: financials
|
||||||
tenant: platform
|
tenant: platform
|
||||||
workload: secrets-engine
|
workload: secrets-engine
|
||||||
environment: production
|
environment: production
|
||||||
purpose: >-
|
purpose: Hold the secrets-engine-approval confidential client secret in platform
|
||||||
Hold the secrets-engine-approval confidential client secret in platform custody
|
custody and project it into the KeyCape runtime so KeyCape can verify presented
|
||||||
and project it into the KeyCape runtime so KeyCape can verify presented
|
client_credentials without the value living in Git, a chart value, or a hand-created
|
||||||
client_credentials without the value living in Git, a chart value, or a
|
Kubernetes Secret.
|
||||||
hand-created Kubernetes Secret.
|
|
||||||
openbao:
|
openbao:
|
||||||
mount: platform
|
mount: platform
|
||||||
kv_path: platform/workloads/secrets-engine/approval-client
|
kv_path: platform/workloads/secrets-engine/approval-client
|
||||||
|
|
@ -76,94 +86,72 @@ access_frontdoor:
|
||||||
readiness: pending-review
|
readiness: pending-review
|
||||||
delivery:
|
delivery:
|
||||||
surface: external-secrets
|
surface: external-secrets
|
||||||
target: >-
|
target: 'ClusterSecretStore openbao-keycape-secrets-engine-approval, limited to
|
||||||
ClusterSecretStore openbao-keycape-secrets-engine-approval, limited to
|
|
||||||
namespace sso, to ExternalSecret sso/keycape-secrets-engine-approval-client and
|
namespace sso, to ExternalSecret sso/keycape-secrets-engine-approval-client and
|
||||||
Secret sso/keycape-secrets-engine-approval-client with key client-secret.
|
Secret sso/keycape-secrets-engine-approval-client with key client-secret. KeyCape
|
||||||
KeyCape resolves it as KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET through a
|
resolves it as KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET through a secretKeyRef,
|
||||||
secretKeyRef, matching the live KEYCAPE_RAPP_QONTO_CLIENT_SECRET shape observed
|
matching the live KEYCAPE_RAPP_QONTO_CLIENT_SECRET shape observed on image main-153258b.
|
||||||
on image main-153258b. Manifests:
|
Manifests: argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml
|
||||||
argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml
|
and keycape-approval-clients.externalsecrets.yaml.'
|
||||||
and keycape-approval-clients.externalsecrets.yaml.
|
|
||||||
risk:
|
risk:
|
||||||
classification: high
|
classification: high
|
||||||
notes:
|
notes:
|
||||||
- >-
|
- This is a verifier-side copy. KeyCape holding the client secret lets it authenticate
|
||||||
This is a verifier-side copy. KeyCape holding the client secret lets it
|
the client; it does not let KeyCape act as the client, but a leak of this value
|
||||||
authenticate the client; it does not let KeyCape act as the client, but a leak
|
allows anyone to present as secrets-engine for approval:read and approval:consume
|
||||||
of this value allows anyone to present as secrets-engine for approval:read and
|
until the registration is disabled.
|
||||||
approval:consume until the registration is disabled.
|
- Custody path is under the client's own workload prefix, not KeyCape's, because
|
||||||
- >-
|
the credential belongs to the secrets-engine client identity. The reader admitted
|
||||||
Custody path is under the client's own workload prefix, not KeyCape's, because
|
here is the External Secrets Operator on behalf of the sso namespace only; a future
|
||||||
the credential belongs to the secrets-engine client identity. The reader
|
secrets-engine-side read is a separate lane and separate policy.
|
||||||
admitted here is the External Secrets Operator on behalf of the sso namespace
|
- The existing keycape-rapp-qonto-client Secret is hand-created and not ESO-managed.
|
||||||
only; a future secrets-engine-side read is a separate lane and separate policy.
|
Do not extend this store or ExternalSecret to adopt it; that is a distinct migration
|
||||||
- >-
|
with its own owner.
|
||||||
The existing keycape-rapp-qonto-client Secret is hand-created and not
|
- The two approval clients are deliberately kept on separate policies, roles and
|
||||||
ESO-managed. Do not extend this store or ExternalSecret to adopt it; that is a
|
|
||||||
distinct migration with its own owner.
|
|
||||||
- >-
|
|
||||||
The two approval clients are deliberately kept on separate policies, roles and
|
|
||||||
stores so the operator client can be revoked without disturbing this one. Their
|
stores so the operator client can be revoked without disturbing this one. Their
|
||||||
scope sets differ; a shared policy would erase that boundary.
|
scope sets differ; a shared policy would erase that boundary.
|
||||||
- >-
|
- Compromise response is KeyCape disabling the client registration plus rotation
|
||||||
Compromise response is KeyCape disabling the client registration plus rotation
|
of this KV version. An OpenBao token expiry does not invalidate an already issued
|
||||||
of this KV version. An OpenBao token expiry does not invalidate an already
|
client secret.
|
||||||
issued client secret.
|
|
||||||
verification:
|
verification:
|
||||||
positive:
|
positive:
|
||||||
- >-
|
- The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key client-secret
|
||||||
The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key
|
without printing the value.
|
||||||
client-secret without printing the value.
|
- The KeyCape build that reads KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET starts
|
||||||
- >-
|
and issues a token for subject service:secrets-engine with audience approval-engine,
|
||||||
The KeyCape build that reads KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET
|
tenant:platform, 15m lifetime, verified against live JWKS signature.
|
||||||
starts and issues a token for subject service:secrets-engine with audience
|
|
||||||
approval-engine, tenant:platform, 15m lifetime, verified against live JWKS
|
|
||||||
signature.
|
|
||||||
- Exact claim bindings are asserted by KeyCape without any value or token logged.
|
- Exact claim bindings are asserted by KeyCape without any value or token logged.
|
||||||
negative:
|
negative:
|
||||||
- >-
|
- A namespace outside the approved ClusterSecretStore condition cannot use this
|
||||||
A namespace outside the approved ClusterSecretStore condition cannot use this
|
|
||||||
store to read the path.
|
store to read the path.
|
||||||
- >-
|
- A service account outside external-secrets/external-secrets cannot authenticate
|
||||||
A service account outside external-secrets/external-secrets cannot authenticate
|
|
||||||
through role external-secrets-keycape-secrets-engine-approval.
|
through role external-secrets-keycape-secrets-engine-approval.
|
||||||
- >-
|
- The role cannot read the sibling approval-engine operator-client path, any parent
|
||||||
The role cannot read the sibling approval-engine operator-client path, any
|
listing, or any other platform workload path.
|
||||||
parent listing, or any other platform workload path.
|
- The secrets-engine-approval client is denied scopes outside approval:read and
|
||||||
- >-
|
|
||||||
The secrets-engine-approval client is denied scopes outside approval:read and
|
|
||||||
approval:consume, and human identities are denied approval:consume.
|
approval:consume, and human identities are denied approval:consume.
|
||||||
activation_conditions:
|
activation_conditions:
|
||||||
- >-
|
- KeyCape and railiance-platform agree the single attended rollout window in docs/credential-lane-designs/keycape-approval-clients.md;
|
||||||
KeyCape and railiance-platform agree the single attended rollout window in
|
the KeyCape image that reads both environment names is built and pinned but not
|
||||||
docs/credential-lane-designs/keycape-approval-clients.md; the KeyCape image that
|
yet deployed.
|
||||||
reads both environment names is built and pinned but not yet deployed.
|
- Attended first provision runs only through the governed openbao-platform-admin-login
|
||||||
- >-
|
lane (founder_required, attended OIDC via netkingdom role=platform-admin), with
|
||||||
Attended first provision runs only through the governed
|
a unique receipt path and no value in chat, command arguments or shell history.
|
||||||
openbao-platform-admin-login lane (founder_required, attended OIDC via
|
- Policy, Kubernetes auth role and ClusterSecretStore applied before the ExternalSecret;
|
||||||
netkingdom role=platform-admin), with a unique receipt path and no value in
|
sync confirmed before the KeyCape image is rolled out.
|
||||||
chat, command arguments or shell history.
|
- Positive and negative results recorded with non-secret request ids or timestamps.
|
||||||
- >-
|
|
||||||
Policy, Kubernetes auth role and ClusterSecretStore applied before the
|
|
||||||
ExternalSecret; sync confirmed before the KeyCape image is rolled out.
|
|
||||||
- >-
|
|
||||||
Positive and negative results recorded with non-secret request ids or
|
|
||||||
timestamps.
|
|
||||||
evidence: []
|
evidence: []
|
||||||
lifecycle:
|
lifecycle:
|
||||||
deactivate: >-
|
deactivate: KeyCape disables the secrets-engine-approval registration; platform
|
||||||
KeyCape disables the secrets-engine-approval registration; platform detaches
|
detaches the policy from role external-secrets-keycape-secrets-engine-approval
|
||||||
the policy from role external-secrets-keycape-secrets-engine-approval and
|
and removes the ExternalSecret. The KV version is retained until KeyCape confirms
|
||||||
removes the ExternalSecret. The KV version is retained until KeyCape confirms
|
|
||||||
disablement.
|
disablement.
|
||||||
rotate: >-
|
rotate: KeyCape mints a replacement client secret; platform writes the new KV version
|
||||||
KeyCape mints a replacement client secret; platform writes the new KV version
|
|
||||||
under the same attended authority; ESO refresh delivers it and KeyCape restarts
|
under the same attended authority; ESO refresh delivers it and KeyCape restarts
|
||||||
or re-reads. Rotation is independent of the operator client.
|
or re-reads. Rotation is independent of the operator client.
|
||||||
compromised: >-
|
compromised: Disable the registration at KeyCape first (that is what stops token
|
||||||
Disable the registration at KeyCape first (that is what stops token issuance),
|
issuance), then rotate the KV version and confirm no other namespace consumed
|
||||||
then rotate the KV version and confirm no other namespace consumed the store.
|
the store.
|
||||||
state_hub:
|
state_hub:
|
||||||
decision_id: b533a271-b704-4c5c-98a2-9a5951aadfb6
|
decision_id: b533a271-b704-4c5c-98a2-9a5951aadfb6
|
||||||
decision_api_url: http://127.0.0.1:8000/decisions/b533a271-b704-4c5c-98a2-9a5951aadfb6
|
decision_api_url: http://127.0.0.1:8000/decisions/b533a271-b704-4c5c-98a2-9a5951aadfb6
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,17 @@ kind: credential-change-request
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
request_type: workload-kv-read
|
request_type: workload-kv-read
|
||||||
title: KeyCape verifier custody for the approval-engine-operator confidential client
|
title: KeyCape verifier custody for the approval-engine-operator confidential client
|
||||||
status: proposed
|
status: approved
|
||||||
created: '2026-09-08'
|
created: '2026-09-08'
|
||||||
updated: '2026-09-09'
|
updated: '2026-09-08'
|
||||||
requester:
|
requester:
|
||||||
agent: claude
|
agent: claude
|
||||||
reason: >-
|
reason: 'Second of the two registrations in KEY-WP-0013-T02 (State Hub message 278a3ebe-b529-49f6-bd1a-e3ebcf318260):
|
||||||
Second of the two registrations in KEY-WP-0013-T02 (State Hub message
|
client approval-engine-operator, subject service:approval-engine-operator, audience
|
||||||
278a3ebe-b529-49f6-bd1a-e3ebcf318260): client approval-engine-operator, subject
|
approval-engine, tenant:platform per resolved decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6,
|
||||||
service:approval-engine-operator, audience approval-engine, tenant:platform per
|
15m token lifetime. Scopes are approval:create, read, approve, revoke, supersede,
|
||||||
resolved decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, 15m token lifetime.
|
observe and emit, explicitly without approval:consume. KeyCape owns registration
|
||||||
Scopes are approval:create, read, approve, revoke, supersede, observe and emit,
|
and issuance; this request establishes only custody and the KeyCape-side delivery.'
|
||||||
explicitly without approval:consume. KeyCape owns registration and issuance;
|
|
||||||
this request establishes only custody and the KeyCape-side delivery.
|
|
||||||
review:
|
review:
|
||||||
required: true
|
required: true
|
||||||
required_approvers:
|
required_approvers:
|
||||||
|
|
@ -25,20 +23,31 @@ review:
|
||||||
- at: '2026-09-08'
|
- at: '2026-09-08'
|
||||||
reviewer: railiance-platform (codex/claude)
|
reviewer: railiance-platform (codex/claude)
|
||||||
decision: paths_confirmed_field_corrected
|
decision: paths_confirmed_field_corrected
|
||||||
comment: >-
|
comment: KV path platform/workloads/approval-engine/operator-client is confirmed
|
||||||
KV path platform/workloads/approval-engine/operator-client is confirmed
|
unchanged and conforms to the platform/workloads/<workload>/<lane> convention.
|
||||||
unchanged and conforms to the platform/workloads/<workload>/<lane>
|
Field client_secret is corrected to CLIENT_SECRET for the same uppercase convention
|
||||||
convention. Field client_secret is corrected to CLIENT_SECRET for the same
|
and validator constraint as CCR-2026-0017. Kubernetes Secret sso/keycape-approval-engine-operator-client
|
||||||
uppercase convention and validator constraint as CCR-2026-0017. Kubernetes
|
with key client-secret and the KeyCape environment name are confirmed as proposed.
|
||||||
Secret sso/keycape-approval-engine-operator-client with key client-secret and
|
- at: '2026-09-08T23:05:20+00:00'
|
||||||
the KeyCape environment name are confirmed as proposed.
|
reviewer: User (platform-operator; explicit session approval)
|
||||||
|
decision: approved
|
||||||
|
comment: User answered "I approve, go on." to the explicit two-CCR approval question
|
||||||
|
naming both required roles. Scope remains verifier-side custody and its attended
|
||||||
|
rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md.
|
||||||
|
No client-side read or factory spending grant.
|
||||||
|
- at: '2026-09-08T23:05:20+00:00'
|
||||||
|
reviewer: User (key-cape-owner; explicit session approval)
|
||||||
|
decision: approved
|
||||||
|
comment: User answered "I approve, go on." to the explicit two-CCR approval question
|
||||||
|
naming both required roles. Scope remains verifier-side custody and its attended
|
||||||
|
rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md.
|
||||||
|
No client-side read or factory spending grant.
|
||||||
target:
|
target:
|
||||||
domain: financials
|
domain: financials
|
||||||
tenant: platform
|
tenant: platform
|
||||||
workload: approval-engine
|
workload: approval-engine
|
||||||
environment: production
|
environment: production
|
||||||
purpose: >-
|
purpose: Hold the approval-engine-operator confidential client secret in platform
|
||||||
Hold the approval-engine-operator confidential client secret in platform
|
|
||||||
custody and project it into the KeyCape runtime so KeyCape can verify presented
|
custody and project it into the KeyCape runtime so KeyCape can verify presented
|
||||||
client_credentials for the operator client.
|
client_credentials for the operator client.
|
||||||
openbao:
|
openbao:
|
||||||
|
|
@ -70,77 +79,58 @@ access_frontdoor:
|
||||||
readiness: pending-review
|
readiness: pending-review
|
||||||
delivery:
|
delivery:
|
||||||
surface: external-secrets
|
surface: external-secrets
|
||||||
target: >-
|
target: 'ClusterSecretStore openbao-keycape-approval-engine-operator, limited to
|
||||||
ClusterSecretStore openbao-keycape-approval-engine-operator, limited to
|
|
||||||
namespace sso, to ExternalSecret sso/keycape-approval-engine-operator-client and
|
namespace sso, to ExternalSecret sso/keycape-approval-engine-operator-client and
|
||||||
Secret sso/keycape-approval-engine-operator-client with key client-secret.
|
Secret sso/keycape-approval-engine-operator-client with key client-secret. KeyCape
|
||||||
KeyCape resolves it as KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET through a
|
resolves it as KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET through a secretKeyRef.
|
||||||
secretKeyRef. Manifests:
|
Manifests: argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml
|
||||||
argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml
|
and keycape-approval-clients.externalsecrets.yaml.'
|
||||||
and keycape-approval-clients.externalsecrets.yaml.
|
|
||||||
risk:
|
risk:
|
||||||
classification: high
|
classification: high
|
||||||
notes:
|
notes:
|
||||||
- >-
|
- "This client carries the widest approval scope set in the pair \u2014 create,\
|
||||||
This client carries the widest approval scope set in the pair — create,
|
\ approve, revoke and supersede. A leak allows forging approval lifecycle actions,\
|
||||||
approve, revoke and supersede. A leak allows forging approval lifecycle actions,
|
\ which is a stronger outcome than the read/consume client in CCR-2026-0017."
|
||||||
which is a stronger outcome than the read/consume client in CCR-2026-0017.
|
- The absence of approval:consume is a deliberate separation of duties. Any later
|
||||||
- >-
|
|
||||||
The absence of approval:consume is a deliberate separation of duties. Any later
|
|
||||||
request to add consume to this client is a new lane decision, not a scope edit.
|
request to add consume to this client is a new lane decision, not a scope edit.
|
||||||
- >-
|
- Kept on its own policy, role and store so it can be revoked independently of the
|
||||||
Kept on its own policy, role and store so it can be revoked independently of the
|
|
||||||
secrets-engine-approval client.
|
secrets-engine-approval client.
|
||||||
- >-
|
- Compromise response is KeyCape disabling the registration plus rotation of this
|
||||||
Compromise response is KeyCape disabling the registration plus rotation of this
|
|
||||||
KV version; approval actions already emitted are not retracted by rotation and
|
KV version; approval actions already emitted are not retracted by rotation and
|
||||||
need approval-engine's own audit review.
|
need approval-engine's own audit review.
|
||||||
verification:
|
verification:
|
||||||
positive:
|
positive:
|
||||||
- >-
|
- The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key client-secret
|
||||||
The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key
|
without printing the value.
|
||||||
client-secret without printing the value.
|
- The KeyCape build issues a token for subject service:approval-engine-operator
|
||||||
- >-
|
with audience approval-engine, tenant:platform and 15m lifetime, verified against
|
||||||
The KeyCape build issues a token for subject service:approval-engine-operator
|
live JWKS signature.
|
||||||
with audience approval-engine, tenant:platform and 15m lifetime, verified
|
|
||||||
against live JWKS signature.
|
|
||||||
negative:
|
negative:
|
||||||
- >-
|
- A namespace outside the approved ClusterSecretStore condition cannot use this
|
||||||
A namespace outside the approved ClusterSecretStore condition cannot use this
|
|
||||||
store to read the path.
|
store to read the path.
|
||||||
- >-
|
- A service account outside external-secrets/external-secrets cannot authenticate
|
||||||
A service account outside external-secrets/external-secrets cannot authenticate
|
|
||||||
through role external-secrets-keycape-approval-engine-operator.
|
through role external-secrets-keycape-approval-engine-operator.
|
||||||
- >-
|
- The role cannot read the sibling secrets-engine approval-client path, any parent
|
||||||
The role cannot read the sibling secrets-engine approval-client path, any parent
|
|
||||||
listing, or any other platform workload path.
|
listing, or any other platform workload path.
|
||||||
- >-
|
- The operator client is denied approval:consume, and denial is observed rather
|
||||||
The operator client is denied approval:consume, and denial is observed rather
|
|
||||||
than assumed.
|
than assumed.
|
||||||
activation_conditions:
|
activation_conditions:
|
||||||
- >-
|
- Same single attended rollout window and prepared-but-undeployed KeyCape image
|
||||||
Same single attended rollout window and prepared-but-undeployed KeyCape image as
|
as CCR-2026-0017; see docs/credential-lane-designs/keycape-approval-clients.md.
|
||||||
CCR-2026-0017; see docs/credential-lane-designs/keycape-approval-clients.md.
|
- Attended first provision only through openbao-platform-admin-login (founder_required,
|
||||||
- >-
|
attended OIDC via netkingdom role=platform-admin) with a unique receipt path.
|
||||||
Attended first provision only through openbao-platform-admin-login
|
- Policy, Kubernetes auth role and ClusterSecretStore applied before the ExternalSecret;
|
||||||
(founder_required, attended OIDC via netkingdom role=platform-admin) with a
|
sync confirmed before the KeyCape rollout.
|
||||||
unique receipt path.
|
|
||||||
- >-
|
|
||||||
Policy, Kubernetes auth role and ClusterSecretStore applied before the
|
|
||||||
ExternalSecret; sync confirmed before the KeyCape rollout.
|
|
||||||
- Positive and negative results recorded with non-secret request ids or timestamps.
|
- Positive and negative results recorded with non-secret request ids or timestamps.
|
||||||
evidence: []
|
evidence: []
|
||||||
lifecycle:
|
lifecycle:
|
||||||
deactivate: >-
|
deactivate: KeyCape disables the approval-engine-operator registration; platform
|
||||||
KeyCape disables the approval-engine-operator registration; platform detaches the
|
detaches the policy from role external-secrets-keycape-approval-engine-operator
|
||||||
policy from role external-secrets-keycape-approval-engine-operator and removes
|
and removes the ExternalSecret.
|
||||||
the ExternalSecret.
|
rotate: KeyCape mints a replacement client secret; platform writes the new KV version
|
||||||
rotate: >-
|
|
||||||
KeyCape mints a replacement client secret; platform writes the new KV version
|
|
||||||
under the same attended authority. Rotation is independent of CCR-2026-0017.
|
under the same attended authority. Rotation is independent of CCR-2026-0017.
|
||||||
compromised: >-
|
compromised: Disable the registration at KeyCape, rotate the KV version, and refer
|
||||||
Disable the registration at KeyCape, rotate the KV version, and refer emitted
|
emitted approval actions to approval-engine for audit review.
|
||||||
approval actions to approval-engine for audit review.
|
|
||||||
state_hub:
|
state_hub:
|
||||||
decision_id: efa90517-0cae-4eb6-a68d-5b0489c84d65
|
decision_id: efa90517-0cae-4eb6-a68d-5b0489c84d65
|
||||||
decision_api_url: http://127.0.0.1:8000/decisions/efa90517-0cae-4eb6-a68d-5b0489c84d65
|
decision_api_url: http://127.0.0.1:8000/decisions/efa90517-0cae-4eb6-a68d-5b0489c84d65
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
# KeyCape approval-client custody: review packet
|
# KeyCape approval-client custody: review packet
|
||||||
|
|
||||||
Prepared 2026-09-09 by the-custodian. Both requests are **proposed**; no named
|
Prepared 2026-09-09 by the-custodian. Both requests are **approved** by the user as platform operator and KeyCape
|
||||||
approval or custody mutation has been recorded. This is the review gate for
|
owner, explicitly recorded on 2026-09-09. The admission receipt is
|
||||||
|
[here](../evidence/2026-09-09-keycape-approval-admission.json). This is the review record for
|
||||||
RPF-WP-0035-T05, consumed by HFACT-WP-0001-T03.
|
RPF-WP-0035-T05, consumed by HFACT-WP-0001-T03.
|
||||||
|
|
||||||
| Request | Secret path and field | Client authority | Pending decision |
|
| Request | Secret path and field | Client authority | Resolved decision |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| [CCR-2026-0017](../../credential-change-requests/CCR-2026-0017-keycape-secrets-engine-approval-client.yaml) | `platform/workloads/secrets-engine/approval-client`, `CLIENT_SECRET` | `approval:read`, `approval:consume` | `b533a271-b704-4c5c-98a2-9a5951aadfb6` |
|
| [CCR-2026-0017](../../credential-change-requests/CCR-2026-0017-keycape-secrets-engine-approval-client.yaml) | `platform/workloads/secrets-engine/approval-client`, `CLIENT_SECRET` | `approval:read`, `approval:consume` | `b533a271-b704-4c5c-98a2-9a5951aadfb6` |
|
||||||
| [CCR-2026-0018](../../credential-change-requests/CCR-2026-0018-keycape-approval-engine-operator-client.yaml) | `platform/workloads/approval-engine/operator-client`, `CLIENT_SECRET` | create/read/approve/revoke/supersede/observe/emit; no consume | `efa90517-0cae-4eb6-a68d-5b0489c84d65` |
|
| [CCR-2026-0018](../../credential-change-requests/CCR-2026-0018-keycape-approval-engine-operator-client.yaml) | `platform/workloads/approval-engine/operator-client`, `CLIENT_SECRET` | create/read/approve/revoke/supersede/observe/emit; no consume | `efa90517-0cae-4eb6-a68d-5b0489c84d65` |
|
||||||
|
|
||||||
Both require **platform-operator and key-cape-owner** review. Each has its own
|
Both named **platform-operator and key-cape-owner** reviews are approved. Each has its own
|
||||||
exact-path policy, Kubernetes auth role and ClusterSecretStore, bound to
|
exact-path policy, Kubernetes auth role and ClusterSecretStore, bound to
|
||||||
`external-secrets/external-secrets`, limited to `sso`. ESO owns the resulting
|
`external-secrets/external-secrets`, limited to `sso`. ESO owns the resulting
|
||||||
Secret; authentication tokens have a 15-minute TTL. Client secrets require
|
Secret; authentication tokens have a 15-minute TTL. Client secrets require
|
||||||
explicit rotation or registration disablement; token expiry does not revoke them.
|
explicit rotation or registration disablement; token expiry does not revoke them.
|
||||||
|
|
||||||
The approved object would authorize verifier-side custody only. Client-side
|
The approved requests authorize verifier-side custody only. Client-side
|
||||||
retrieval, audit sender/receiver custody, operator `approval:consume`, adoption
|
retrieval, audit sender/receiver custody, operator `approval:consume`, adoption
|
||||||
of the Qonto Secret and factory spending remain outside these two requests.
|
of the Qonto Secret and factory spending remain outside these two requests.
|
||||||
|
|
||||||
|
|
@ -29,14 +30,16 @@ review, not substitutes for either named approval.
|
||||||
Execution still follows the [owner rollout sequence](keycape-approval-clients.md)
|
Execution still follows the [owner rollout sequence](keycape-approval-clients.md)
|
||||||
through the founder-attended Warden/OpenBao envelope: policy/roles, CAS=0 initial
|
through the founder-attended Warden/OpenBao envelope: policy/roles, CAS=0 initial
|
||||||
custody, Valid stores, SecretSynced delivery, compatible config/image replacement,
|
custody, Valid stores, SecretSynced delivery, compatible config/image replacement,
|
||||||
then positive and negative acceptance and metadata-only receipts. The generated
|
then positive and negative acceptance and metadata-only receipts. The contained owner command is now `scripts/keycape_approval_custody.py activate`.
|
||||||
plan leaves initial secret provisioning to the contained owner procedure; it is
|
It preflights both config and deployment changes, seeds with CAS=0, verifies native
|
||||||
not an already exercised end-to-end activation command. Finish and exercise that
|
and namespace boundaries, then calls NetKingdom's contained compatible rollout.
|
||||||
procedure before opening the activation window. Current proposed requests refuse
|
A failure restores the prior config/image pair before detaching verifier delivery;
|
||||||
`apply-plan`; no status has been changed to get around that refusal.
|
initial KV versions remain in custody. The four new exact data/metadata deny
|
||||||
|
stanzas extend the coding-agent boundary without changing any grant.
|
||||||
|
|
||||||
Review outcome: approve, deny or needs changes, naming the reviewer and the role
|
Validation: six checks against a disposable local OpenBao, eight config/rollback
|
||||||
they are accepting. Both required role confirmations must be recorded for each
|
tests, and real HTTP signature/claim/scope acceptance against the pinned KeyCape
|
||||||
CCR before execution. A generic CLI status change alone is insufficient evidence
|
image with synthetic keys. Production in-pod CLI and existing-human-login checks
|
||||||
that both reviews occurred. No owner messages or browser login were triggered
|
remain part of the live window. The image exercise omits the in-pod CLI explicitly.
|
||||||
by preparation of this packet.
|
Use a unique private receipt and protected owner recovery path; a fresh attended
|
||||||
|
login after the rollout establishes the existing human path on the new build.
|
||||||
|
|
|
||||||
36
docs/evidence/2026-09-09-keycape-approval-admission.json
Normal file
36
docs/evidence/2026-09-09-keycape-approval-admission.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"schema": "railiance.keycape-custody-user-approval.v1",
|
||||||
|
"recorded_at": "2026-09-08T23:05:19.886216+00:00",
|
||||||
|
"user_response": "I approve, go on.",
|
||||||
|
"approved_question": "Do you approve CCR-2026-0017 and CCR-2026-0018, as platform operator and KeyCape owner, for the verifier-side credential delivery described in the review packet?",
|
||||||
|
"review_packet_revision": "52b24eab9a8aff3396e71b2296d0240a44f3b0db",
|
||||||
|
"roles": [
|
||||||
|
"platform-operator",
|
||||||
|
"key-cape-owner"
|
||||||
|
],
|
||||||
|
"scope": "Two verifier-side KeyCape client credential custody requests, including their governed attended provisioning and compatible rollout sequence.",
|
||||||
|
"client_side_read_authorized": false,
|
||||||
|
"factory_spending_authorized": false,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "CCR-2026-0017",
|
||||||
|
"decision_id": "b533a271-b704-4c5c-98a2-9a5951aadfb6",
|
||||||
|
"status": "approved",
|
||||||
|
"decision_status": "resolved",
|
||||||
|
"reviewed_roles": [
|
||||||
|
"platform-operator",
|
||||||
|
"key-cape-owner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CCR-2026-0018",
|
||||||
|
"decision_id": "efa90517-0cae-4eb6-a68d-5b0489c84d65",
|
||||||
|
"status": "approved",
|
||||||
|
"decision_status": "resolved",
|
||||||
|
"reviewed_roles": [
|
||||||
|
"platform-operator",
|
||||||
|
"key-cape-owner"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -123,3 +123,9 @@ path "platform/data/workloads/telemetry/grafana-admin" {
|
||||||
path "platform/metadata/workloads/telemetry/grafana-admin" {
|
path "platform/metadata/workloads/telemetry/grafana-admin" {
|
||||||
capabilities = ["deny"]
|
capabilities = ["deny"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Approved verifier custody: CCR-2026-0017 / CCR-2026-0018.
|
||||||
|
path "platform/data/workloads/secrets-engine/approval-client" { capabilities = ["deny"] }
|
||||||
|
path "platform/data/workloads/approval-engine/operator-client" { capabilities = ["deny"] }
|
||||||
|
path "platform/metadata/workloads/secrets-engine/approval-client" { capabilities = ["deny"] }
|
||||||
|
path "platform/metadata/workloads/approval-engine/operator-client" { capabilities = ["deny"] }
|
||||||
|
|
|
||||||
331
scripts/keycape_approval_custody.py
Normal file
331
scripts/keycape_approval_custody.py
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Silent, attended first provision and verification of CCR-2026-0017/0018."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from state_hub_preflight_lane import ROOT, LaneError, assert_cluster, bao, command, data, revoke
|
||||||
|
|
||||||
|
LANES = (
|
||||||
|
('CCR-2026-0017', 'secrets-engine', 'approval-client', 'keycape-secrets-engine-approval'),
|
||||||
|
('CCR-2026-0018', 'approval-engine', 'operator-client', 'keycape-approval-engine-operator'),
|
||||||
|
)
|
||||||
|
BOUNDARY = 'sys/policies/acl/agent-high-risk-boundary'
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, reason):
|
||||||
|
if not condition:
|
||||||
|
raise LaneError(reason)
|
||||||
|
|
||||||
|
|
||||||
|
def contracts():
|
||||||
|
spec = importlib.util.spec_from_file_location('credential_change', ROOT / 'scripts/credential-change.py')
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
result = []
|
||||||
|
for ref, workload, purpose, suffix in LANES:
|
||||||
|
path = module.resolve_ccr(ref)
|
||||||
|
ccr, errors, _ = module.validate_ccr(path)
|
||||||
|
require(not errors and ccr['status'] in {'approved', 'applied', 'verified', 'active'}, 'approved_ccrs_required')
|
||||||
|
for role in ('platform-operator', 'key-cape-owner'):
|
||||||
|
require(any(x.get('decision') == 'approved' and role in x.get('reviewer', '')
|
||||||
|
and 'explicit session approval' in x.get('reviewer', '')
|
||||||
|
for x in ccr['review']['comments']), 'both_named_reviews_required')
|
||||||
|
expected_path = f'platform/workloads/{workload}/{purpose}'
|
||||||
|
policy = 'workload-kv-read-' + suffix
|
||||||
|
role = 'external-secrets-' + suffix
|
||||||
|
expected_role = {'bound_service_account_names': ['external-secrets'],
|
||||||
|
'bound_service_account_namespaces': ['external-secrets'],
|
||||||
|
'policies': policy, 'ttl': '15m'}
|
||||||
|
require(ccr['openbao']['kv_path'] == expected_path and ccr['openbao']['fields'] == ['CLIENT_SECRET']
|
||||||
|
and ccr['openbao']['policy_name'] == policy
|
||||||
|
and ccr['openbao']['auth']['role'] == role
|
||||||
|
and ccr['openbao']['auth']['mount'] == 'kubernetes'
|
||||||
|
and ccr['openbao']['auth']['bound_claims_confirmed'] is True
|
||||||
|
and module.auth_payload(ccr) == expected_role, 'exact_approved_contract_required')
|
||||||
|
hcl = module.generated_policy_hcl(ccr)
|
||||||
|
require((ROOT / ccr['openbao']['policy_file']).read_text() == hcl, 'policy_source_drift')
|
||||||
|
result.append({'ccr': ref, 'kv': expected_path.replace('platform/', 'platform/data/', 1),
|
||||||
|
'metadata': expected_path.replace('platform/', 'platform/metadata/', 1),
|
||||||
|
'policy': policy, 'hcl': hcl, 'role': role, 'role_payload': expected_role,
|
||||||
|
'store': 'openbao-' + suffix, 'secret': suffix + '-client',
|
||||||
|
'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest()})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_optional(path):
|
||||||
|
result = bao(['read', '-format=json', path], allow_failure=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return data(result)['data']
|
||||||
|
require(b'404' in result.stderr or b'No value found' in result.stderr, 'absence_not_proven')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def role_matches(actual, lane):
|
||||||
|
return (actual.get('bound_service_account_names') == ['external-secrets']
|
||||||
|
and actual.get('bound_service_account_namespaces') == ['external-secrets']
|
||||||
|
and not actual.get('bound_service_account_namespace_selector')
|
||||||
|
and actual.get('token_policies') == [lane['policy']]
|
||||||
|
and actual.get('token_ttl') == 900 and not actual.get('token_period')
|
||||||
|
and not actual.get('audience') and not actual.get('token_no_default_policy')
|
||||||
|
and not actual.get('token_bound_cidrs') and not actual.get('token_num_uses'))
|
||||||
|
|
||||||
|
|
||||||
|
def provision(lanes, receipt):
|
||||||
|
# Complete all drift/absence checks before the first write.
|
||||||
|
for lane in lanes:
|
||||||
|
require(read_optional(lane['metadata']) is None, 'existing_custody_requires_resume_or_rotation_review')
|
||||||
|
old_policy = read_optional('sys/policies/acl/' + lane['policy'])
|
||||||
|
require(old_policy is None or old_policy['policy'] == lane['hcl'], 'policy_drift')
|
||||||
|
old_role = read_optional('auth/kubernetes/role/' + lane['role'])
|
||||||
|
require(old_role is None or role_matches(old_role, lane), 'role_drift')
|
||||||
|
# The new values must not become readable through a generic coding-agent grant.
|
||||||
|
boundary = read_optional(BOUNDARY)
|
||||||
|
require(boundary is not None, 'coding_agent_boundary_required')
|
||||||
|
current = boundary['policy']
|
||||||
|
additions = ''
|
||||||
|
for lane in lanes:
|
||||||
|
for path in (lane['kv'], lane['metadata']):
|
||||||
|
if '"' + path + '"' not in current:
|
||||||
|
additions += 'path "' + path + '" { capabilities = ["deny"] }\n'
|
||||||
|
else:
|
||||||
|
require(re.search(r'path\s+"' + re.escape(path) + r'"\s*\{\s*capabilities\s*=\s*\["deny"\]\s*\}', current), 'boundary_path_drift')
|
||||||
|
if additions:
|
||||||
|
require(read_optional(BOUNDARY)['policy'] == current, 'boundary_revision_changed')
|
||||||
|
bao(['write', BOUNDARY, '-'], payload={'policy': current + '\n' + additions})
|
||||||
|
require(read_optional(BOUNDARY)['policy'] == current + '\n' + additions, 'boundary_readback_failed')
|
||||||
|
receipt['coding_agent_boundary_extended_only_to_new_paths'] = bool(additions)
|
||||||
|
for lane in lanes:
|
||||||
|
row = {'ccr': lane['ccr'], 'source_sha256': lane['source_sha256'], 'policy_applied': False,
|
||||||
|
'role_applied': False, 'custody_seeded': False}
|
||||||
|
receipt['lanes'].append(row)
|
||||||
|
bao(['write', 'sys/policies/acl/' + lane['policy'], '-'], payload={'policy': lane['hcl']})
|
||||||
|
row['policy_applied'] = True
|
||||||
|
bao(['write', 'auth/kubernetes/role/' + lane['role'], '-'], payload=lane['role_payload'])
|
||||||
|
row['role_applied'] = True
|
||||||
|
require(read_optional('sys/policies/acl/' + lane['policy'])['policy'] == lane['hcl'], 'policy_readback_failed')
|
||||||
|
require(role_matches(read_optional('auth/kubernetes/role/' + lane['role']), lane), 'role_readback_failed')
|
||||||
|
for lane, row in zip(lanes, receipt['lanes']):
|
||||||
|
result = data(bao(['write', '-format=json', lane['kv'], '-'],
|
||||||
|
payload={'options': {'cas': 0}, 'data': {'CLIENT_SECRET': secrets.token_urlsafe(48)}}))
|
||||||
|
require(result['data']['version'] == 1, 'unexpected_initial_version')
|
||||||
|
row.update(custody_seeded=True, kv_version=1, request_id=result.get('request_id'))
|
||||||
|
receipt['initial_values_generation'] = 'independent CSPRNG 48-byte values; memory-to-OpenBao stdin only; CAS=0'
|
||||||
|
|
||||||
|
|
||||||
|
def require_denied(result):
|
||||||
|
require(result.returncode != 0 and b'403' in result.stderr and b'permission denied' in result.stderr.lower(), 'denial_not_proven')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_native(kube, lanes, receipt):
|
||||||
|
for lane, row in zip(lanes, receipt['lanes']):
|
||||||
|
jwt = command(kube + ['-n', 'external-secrets', 'create', 'token', 'external-secrets', '--duration=10m']).stdout.decode().strip()
|
||||||
|
auth = data(bao(['write', '-format=json', 'auth/kubernetes/login', '-'], payload={'role': lane['role'], 'jwt': jwt}))['auth']
|
||||||
|
token = auth['client_token']
|
||||||
|
try:
|
||||||
|
require(set(auth['token_policies']) == {lane['policy'], 'default'} and 0 < auth['lease_duration'] <= 900, 'issued_role_or_ttl_mismatch')
|
||||||
|
native = data(bao(['read', '-format=json', lane['kv']], token=token))['data']
|
||||||
|
require(set(native['data']) == {'CLIENT_SECRET'} and len(native['data']['CLIENT_SECRET']) == 64, 'custody_shape_mismatch')
|
||||||
|
sibling = next(x['kv'] for x in lanes if x['ccr'] != lane['ccr'])
|
||||||
|
require_denied(bao(['read', '-format=json', sibling], token=token, allow_failure=True))
|
||||||
|
require_denied(bao(['list', '-format=json', 'platform/metadata/workloads'], token=token, allow_failure=True))
|
||||||
|
row.update(native_reader_verified=True, cross_path_denied=True, parent_listing_denied=True, auth_ttl=auth['lease_duration'])
|
||||||
|
finally:
|
||||||
|
revoke(token)
|
||||||
|
require_denied(bao(['token', 'lookup', '-format=json'], token=token, allow_failure=True))
|
||||||
|
row['reader_revocation_verified'] = True
|
||||||
|
bad_jwt = command(kube + ['-n', 'external-secrets', 'create', 'token', 'default', '--duration=10m']).stdout.decode().strip()
|
||||||
|
bad = bao(['write', '-format=json', 'auth/kubernetes/login', '-'], payload={'role': lane['role'], 'jwt': bad_jwt}, allow_failure=True)
|
||||||
|
if bad.returncode == 0:
|
||||||
|
revoke(data(bad)['auth']['client_token'])
|
||||||
|
raise LaneError('wrong_service_account_authenticated')
|
||||||
|
require(b'403' in bad.stderr and b'service account' in bad.stderr.lower(), 'wrong_service_account_denial_inconclusive')
|
||||||
|
row['wrong_service_account_denied'] = True
|
||||||
|
boundary_child = data(bao(['token', 'create', '-format=json', '-policy=' + lane['policy'], '-policy=agent-high-risk-boundary', '-no-default-policy', '-ttl=60s']))['auth']['client_token']
|
||||||
|
try:
|
||||||
|
for path in (lane['kv'], lane['metadata']):
|
||||||
|
require_denied(bao(['read', '-format=json', path], token=boundary_child, allow_failure=True))
|
||||||
|
row['coding_agent_deny_wins'] = True
|
||||||
|
finally:
|
||||||
|
revoke(boundary_child)
|
||||||
|
|
||||||
|
|
||||||
|
def deliver(kube, lanes, receipt):
|
||||||
|
base = ROOT / 'argocd/platform-addons/openbao-secretstore'
|
||||||
|
command(kube + ['apply', '-f', str(base / 'openbao-keycape-approval-clients.clustersecretstore.yaml')])
|
||||||
|
for lane in lanes:
|
||||||
|
command(kube + ['wait', '--for=condition=Ready', 'clustersecretstore/' + lane['store'], '--timeout=45s'])
|
||||||
|
command(kube + ['apply', '-f', str(base / 'keycape-approval-clients.externalsecrets.yaml')])
|
||||||
|
for lane, row in zip(lanes, receipt['lanes']):
|
||||||
|
command(kube + ['-n', 'sso', 'wait', '--for=condition=Ready', 'externalsecret/' + lane['secret'], '--timeout=45s'])
|
||||||
|
es = data(command(kube + ['-n', 'sso', 'get', 'externalsecret', lane['secret'], '-o', 'json']))
|
||||||
|
secret = data(command(kube + ['-n', 'sso', 'get', 'secret', lane['secret'], '-o', 'json']))
|
||||||
|
import base64
|
||||||
|
native = data(bao(['read', '-format=json', lane['kv']]))['data']
|
||||||
|
require(set(secret['data']) == {'client-secret'} and base64.b64decode(secret['data']['client-secret']).decode() == native['data']['CLIENT_SECRET'], 'eso_delivery_mismatch')
|
||||||
|
require(any(x['uid'] == es['metadata']['uid'] for x in secret['metadata'].get('ownerReferences', [])), 'eso_does_not_own_secret')
|
||||||
|
row.update(store_ready=True, external_secret_ready=True, delivery_matches=True,
|
||||||
|
secret_uid=secret['metadata']['uid'], secret_resource_version=secret['metadata']['resourceVersion'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def verify_namespace_boundaries(kube, lanes, receipt):
|
||||||
|
# A unique, exclusively created namespace isolates the negative probes.
|
||||||
|
ns = 'keycape-custody-check-' + secrets.token_hex(5)
|
||||||
|
created = data(command(kube + ['create', 'namespace', ns, '-o', 'json']))
|
||||||
|
try:
|
||||||
|
command(kube + ['-n', ns, 'create', 'serviceaccount', 'external-secrets'])
|
||||||
|
jwt = command(kube + ['-n', ns, 'create', 'token', 'external-secrets', '--duration=10m']).stdout.decode().strip()
|
||||||
|
for lane, row in zip(lanes, receipt['lanes']):
|
||||||
|
bad = bao(['write', '-format=json', 'auth/kubernetes/login', '-'],
|
||||||
|
payload={'role': lane['role'], 'jwt': jwt}, allow_failure=True)
|
||||||
|
if bad.returncode == 0:
|
||||||
|
revoke(data(bad)['auth']['client_token'])
|
||||||
|
raise LaneError('wrong_namespace_authenticated')
|
||||||
|
require(b'403' in bad.stderr and b'namespace' in bad.stderr.lower(), 'wrong_namespace_denial_inconclusive')
|
||||||
|
row['wrong_namespace_denied'] = True
|
||||||
|
probe = {'apiVersion': 'external-secrets.io/v1', 'kind': 'ExternalSecret',
|
||||||
|
'metadata': {'name': lane['secret'], 'namespace': ns},
|
||||||
|
'spec': {'refreshInterval': '15s',
|
||||||
|
'secretStoreRef': {'kind': 'ClusterSecretStore', 'name': lane['store']},
|
||||||
|
'target': {'name': lane['secret'], 'creationPolicy': 'Owner'},
|
||||||
|
'data': [{'secretKey': 'client-secret', 'remoteRef': {
|
||||||
|
'key': lane['kv'].removeprefix('platform/data/'), 'property': 'CLIENT_SECRET'}}]}}
|
||||||
|
command(kube + ['create', '-f', '-'], payload=probe)
|
||||||
|
denied = False
|
||||||
|
for _ in range(20):
|
||||||
|
result = data(command(kube + ['-n', ns, 'get', 'externalsecret', lane['secret'], '-o', 'json']))
|
||||||
|
conditions = result.get('status', {}).get('conditions', [])
|
||||||
|
denied = any(c.get('type') == 'Ready' and c.get('status') == 'False'
|
||||||
|
and c.get('reason') == 'SecretSyncedError' for c in conditions)
|
||||||
|
if denied:
|
||||||
|
events = data(command(kube + ['-n', ns, 'get', 'events',
|
||||||
|
'--field-selector=involvedObject.uid=' + result['metadata']['uid'], '-o', 'json']))
|
||||||
|
denied = any('not allowed' in e.get('message', '').lower()
|
||||||
|
and 'namespace' in e.get('message', '').lower() for e in events['items'])
|
||||||
|
if denied:
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
require(denied, 'outside_namespace_store_denial_inconclusive')
|
||||||
|
absent = command(kube + ['-n', ns, 'get', 'secret', lane['secret'], '-o', 'json'], allow_failure=True)
|
||||||
|
require(absent.returncode != 0 and b'NotFound' in absent.stderr, 'outside_namespace_secret_not_absent')
|
||||||
|
row['outside_namespace_store_denied'] = True
|
||||||
|
finally:
|
||||||
|
command(kube + ['delete', '--raw=/api/v1/namespaces/' + ns, '-f', '/dev/stdin'],
|
||||||
|
payload={'apiVersion': 'v1', 'kind': 'DeleteOptions',
|
||||||
|
'preconditions': {'uid': created['metadata']['uid']}})
|
||||||
|
receipt['namespace_probe_cleanup_requested'] = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def load_rollout():
|
||||||
|
path = Path('/home/worsch/net-kingdom/sso-mfa/k8s/keycape/approval-clients-rollout.py')
|
||||||
|
spec = importlib.util.spec_from_file_location('keycape_rollout', path)
|
||||||
|
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_delivery(kube, lanes, receipt):
|
||||||
|
# Config/image restoration runs in the owner helper before this call.
|
||||||
|
for lane in lanes:
|
||||||
|
existing = command(kube + ['-n', 'sso', 'get', 'externalsecret', lane['secret'], '-o', 'json'], allow_failure=True)
|
||||||
|
if existing.returncode == 0:
|
||||||
|
obj = data(existing)
|
||||||
|
require(obj['spec']['secretStoreRef'] == {'kind': 'ClusterSecretStore', 'name': lane['store']}, 'rollback_external_secret_drift')
|
||||||
|
command(kube + ['delete', '--raw=/apis/external-secrets.io/v1/namespaces/sso/externalsecrets/' + lane['secret'], '-f', '/dev/stdin'],
|
||||||
|
payload={'apiVersion': 'v1', 'kind': 'DeleteOptions', 'preconditions': {'uid': obj['metadata']['uid']}})
|
||||||
|
else:
|
||||||
|
require(b'NotFound' in existing.stderr, 'rollback_external_secret_absence_unproven')
|
||||||
|
role = read_optional('auth/kubernetes/role/' + lane['role'])
|
||||||
|
if role is not None:
|
||||||
|
require(role_matches(role, lane), 'rollback_auth_role_drift')
|
||||||
|
payload = dict(lane['role_payload'], policies=[])
|
||||||
|
bao(['write', 'auth/kubernetes/role/' + lane['role'], '-'], payload=payload)
|
||||||
|
receipt['verifier_delivery_disabled_custody_versions_retained'] = True
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, receipt):
|
||||||
|
require(Path.home().parent.name == '.warden-attended-login' and not os.environ.get('BAO_TOKEN') and not os.environ.get('VAULT_TOKEN'), 'attended_warden_envelope_required')
|
||||||
|
lanes = contracts()
|
||||||
|
kube = ['kubectl', '--kubeconfig', args.kubeconfig]
|
||||||
|
assert_cluster(kube)
|
||||||
|
identity = data(bao(['token', 'lookup', '-format=json']))['data']
|
||||||
|
require('platform-admin' in identity['policies'] and 'root' not in identity['policies'], 'attended_platform_admin_required')
|
||||||
|
env = os.environ.copy(); env['KUBECONFIG'] = args.kubeconfig
|
||||||
|
check = data(command(['python3', '-B', '/home/worsch/net-kingdom/sso-mfa/k8s/keycape/openbao-client-config.py', 'issuer-check-live'], env=env))
|
||||||
|
require(check['issuer_matches'] and check['verified_issuer'] == 'https://auth.coulomb.social', 'verified_issuer_pin_required')
|
||||||
|
receipt['issuer_pin_revision'] = check['before']['resource_version']
|
||||||
|
rollout = load_rollout() if args.action == 'activate' else None
|
||||||
|
if rollout:
|
||||||
|
# Exercise config construction and API admission before any custody write.
|
||||||
|
secret = rollout.get(kube, 'secret', 'keycape-config')
|
||||||
|
dep = rollout.get(kube, 'deployment', 'keycape')
|
||||||
|
config = rollout.replacement(secret, rollout.registrations())
|
||||||
|
candidate = rollout.candidate_spec(dep)
|
||||||
|
rollout.patch_object(kube, 'secret', secret, '/data/config.yaml', config, dry=True)
|
||||||
|
rollout.patch_object(kube, 'deployment', dep, '/spec', candidate, dry=True)
|
||||||
|
require(args.recovery and not Path(args.recovery).exists(), 'unique_recovery_path_required')
|
||||||
|
for lane in lanes:
|
||||||
|
for kind in ('secret', 'externalsecret'):
|
||||||
|
existing = command(kube + ['-n', 'sso', 'get', kind, lane['secret'], '-o', 'json'], allow_failure=True)
|
||||||
|
require(existing.returncode != 0 and b'NotFound' in existing.stderr, 'existing_delivery_requires_reconciliation')
|
||||||
|
try:
|
||||||
|
receipt['phase'] = 'provision'
|
||||||
|
if args.action in {'provision', 'activate'}:
|
||||||
|
provision(lanes, receipt)
|
||||||
|
else:
|
||||||
|
receipt['lanes'] = [{'ccr': lane['ccr']} for lane in lanes]
|
||||||
|
receipt['phase'] = 'native_verification'
|
||||||
|
verify_native(kube, lanes, receipt)
|
||||||
|
receipt['phase'] = 'eso_delivery'
|
||||||
|
deliver(kube, lanes, receipt)
|
||||||
|
receipt['phase'] = 'namespace_boundaries'
|
||||||
|
verify_namespace_boundaries(kube, lanes, receipt)
|
||||||
|
receipt['status'] = 'custody_and_eso_verified_pending_keycape_rollout'
|
||||||
|
if rollout:
|
||||||
|
receipt['phase'] = 'keycape_rollout'
|
||||||
|
receipt['keycape'] = {'existing_human_login_before': True}
|
||||||
|
rollout.rollout(kube, receipt['keycape'], Path(args.recovery))
|
||||||
|
receipt['status'] = 'custody_and_service_acceptance_passed_pending_fresh_human_login'
|
||||||
|
receipt['keycape_rollout_completed'] = True
|
||||||
|
receipt['phase'] = 'awaiting_fresh_human_login'
|
||||||
|
except Exception:
|
||||||
|
receipt['status'] = 'failed'
|
||||||
|
if rollout and (not receipt.get('keycape') or receipt['keycape'].get('compatible_pair_restored')):
|
||||||
|
deactivate_delivery(kube, lanes, receipt)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('action', choices=['provision', 'verify', 'activate'])
|
||||||
|
parser.add_argument('--kubeconfig', required=True)
|
||||||
|
parser.add_argument('--receipt', required=True)
|
||||||
|
parser.add_argument('--recovery')
|
||||||
|
args = parser.parse_args()
|
||||||
|
receipt = {'schema': 'platform.keycape-approval-custody.v1', 'status': 'failed', 'lanes': [],
|
||||||
|
'started_at': datetime.now(timezone.utc).isoformat(), 'credential_values_emitted': False,
|
||||||
|
'client_side_read_admitted': False, 'keycape_rollout_completed': False}
|
||||||
|
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
try:
|
||||||
|
run(args, receipt)
|
||||||
|
except Exception as exc:
|
||||||
|
receipt['error'] = str(exc) if isinstance(exc, LaneError) else 'contained_operation_failed'
|
||||||
|
finally:
|
||||||
|
receipt['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||||
|
with os.fdopen(fd, 'w') as out:
|
||||||
|
json.dump(receipt, out, indent=2); out.write('\n')
|
||||||
|
return 0 if receipt['status'] != 'failed' else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
114
tests/test_keycape_approval_custody.py
Normal file
114
tests/test_keycape_approval_custody.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Opt-in local OpenBao exercise; no production API or credential helper used."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||||
|
import keycape_approval_custody as lane
|
||||||
|
|
||||||
|
|
||||||
|
class CustodyExercise(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if not shutil.which('bao'):
|
||||||
|
raise unittest.SkipTest('OpenBao executable required')
|
||||||
|
sock = socket.socket(); sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]; sock.close()
|
||||||
|
cls.env = os.environ.copy()
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
for name in ('BAO_ADDR', 'VAULT_ADDR'):
|
||||||
|
cls.env[name] = f'http://127.0.0.1:{port}'
|
||||||
|
for name in ('BAO_TOKEN', 'VAULT_TOKEN', 'BAO_DEV_ROOT_TOKEN_ID'):
|
||||||
|
cls.env[name] = token
|
||||||
|
cls.server = subprocess.Popen(['bao', 'server', '-dev', '-dev-no-store-token',
|
||||||
|
f'-dev-listen-address=127.0.0.1:{port}'], env=cls.env,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
cls.environment = patch.dict(os.environ, cls.env, clear=True); cls.environment.start()
|
||||||
|
try:
|
||||||
|
for _ in range(40):
|
||||||
|
if lane.bao(['status'], allow_failure=True).returncode == 0:
|
||||||
|
break
|
||||||
|
time.sleep(.25)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('local_dev_server_not_ready')
|
||||||
|
lane.bao(['secrets', 'enable', '-path=platform', 'kv-v2'])
|
||||||
|
lane.bao(['auth', 'enable', 'kubernetes'])
|
||||||
|
cls.lanes = lane.contracts()
|
||||||
|
except Exception:
|
||||||
|
cls.environment.stop(); cls.server.terminate(); cls.server.wait(timeout=10)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.environment.stop(); cls.server.terminate(); cls.server.wait(timeout=10)
|
||||||
|
|
||||||
|
def test_01_first_provision_and_real_acl_boundaries(self):
|
||||||
|
lane.bao(['write', lane.BOUNDARY, '-'], payload={'policy': 'path "protected/fixture" { capabilities = ["deny"] }\n'})
|
||||||
|
receipt = {'lanes': []}
|
||||||
|
lane.provision(self.lanes, receipt)
|
||||||
|
values = []
|
||||||
|
for item in self.lanes:
|
||||||
|
value = lane.data(lane.bao(['read', '-format=json', item['kv']]))['data']['data']['CLIENT_SECRET']
|
||||||
|
self.assertTrue(len(value) == 64)
|
||||||
|
values.append(value)
|
||||||
|
auth = lane.data(lane.bao(['token', 'create', '-format=json', '-policy=' + item['policy'], '-ttl=60s']))['auth']
|
||||||
|
token = auth['client_token']
|
||||||
|
try:
|
||||||
|
self.assertEqual(lane.bao(['read', '-format=json', item['kv']], token=token).returncode, 0)
|
||||||
|
sibling = next(x['kv'] for x in self.lanes if x != item)
|
||||||
|
lane.require_denied(lane.bao(['read', sibling], token=token, allow_failure=True))
|
||||||
|
lane.require_denied(lane.bao(['list', 'platform/metadata/workloads'], token=token, allow_failure=True))
|
||||||
|
finally:
|
||||||
|
lane.revoke(token)
|
||||||
|
lane.require_denied(lane.bao(['token', 'lookup'], token=token, allow_failure=True))
|
||||||
|
child = lane.data(lane.bao(['token', 'create', '-format=json', '-policy=' + item['policy'], '-policy=agent-high-risk-boundary', '-ttl=60s']))['auth']['client_token']
|
||||||
|
try:
|
||||||
|
lane.require_denied(lane.bao(['read', item['kv']], token=child, allow_failure=True))
|
||||||
|
finally:
|
||||||
|
lane.revoke(child)
|
||||||
|
self.assertTrue(values[0] != values[1])
|
||||||
|
self.assertTrue(all(x['kv_version'] == 1 for x in receipt['lanes']))
|
||||||
|
self.assertFalse(any(value in json.dumps(receipt) for value in values))
|
||||||
|
|
||||||
|
def test_02_retry_refuses_existing_custody(self):
|
||||||
|
with self.assertRaisesRegex(lane.LaneError, 'existing_custody_requires'):
|
||||||
|
lane.provision(self.lanes, {'lanes': []})
|
||||||
|
self.assertTrue(all(lane.read_optional(x['metadata'])['current_version'] == 1 for x in self.lanes))
|
||||||
|
|
||||||
|
def test_03_denials_must_be_authoritative(self):
|
||||||
|
for result in [subprocess.CompletedProcess([], 0, b'', b''),
|
||||||
|
subprocess.CompletedProcess([], 1, b'', b'404 Not Found'),
|
||||||
|
subprocess.CompletedProcess([], 1, b'', b'503 permission denied'),
|
||||||
|
subprocess.CompletedProcess([], 1, b'', b'403 unrelated refusal')]:
|
||||||
|
with self.assertRaises(lane.LaneError):
|
||||||
|
lane.require_denied(result)
|
||||||
|
|
||||||
|
def test_04_missing_approval_refused(self):
|
||||||
|
with patch.object(lane, 'ROOT', Path('/nonexistent-keycape-contract')):
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
lane.contracts()
|
||||||
|
|
||||||
|
def test_05_role_drift_refused(self):
|
||||||
|
item = self.lanes[0]
|
||||||
|
actual = lane.read_optional('auth/kubernetes/role/' + item['role'])
|
||||||
|
self.assertTrue(lane.role_matches(actual, item))
|
||||||
|
for field, value in [('token_policies', [item['policy'], 'extra']),
|
||||||
|
('bound_service_account_namespaces', ['*']),
|
||||||
|
('bound_service_account_names', ['*']), ('token_ttl', 3600), ('token_period', 900)]:
|
||||||
|
self.assertFalse(lane.role_matches(dict(actual, **{field: value}), item))
|
||||||
|
|
||||||
|
def test_06_direct_execution_refused(self):
|
||||||
|
with self.assertRaisesRegex(lane.LaneError, 'attended_warden_envelope_required'):
|
||||||
|
lane.run(None, {})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
@ -145,9 +145,9 @@ closure: `history/2026-09-05-preflight-signing-activation-complete.md`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RPF-WP-0035-T05
|
id: RPF-WP-0035-T05
|
||||||
status: wait
|
status: progress
|
||||||
needs_human: true
|
needs_human: false
|
||||||
intervention_note: "Review CCR-2026-0017 and CCR-2026-0018 as platform-operator and key-cape-owner; both pending decisions are linked from the CCRs and the review packet. Signed issuer and exact configuration pin are proved."
|
intervention_note: ""
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e15d62c9-e5da-5721-a135-87c050f7851c"
|
state_hub_task_id: "e15d62c9-e5da-5721-a135-87c050f7851c"
|
||||||
```
|
```
|
||||||
|
|
@ -218,6 +218,16 @@ human dependency; HFACT consumes it without a duplicate approval request.
|
||||||
Custody/ESO activation, client verification and the separate client-side/audit
|
Custody/ESO activation, client verification and the separate client-side/audit
|
||||||
lanes remain open. Current CLI apply plans refuse the proposed requests.
|
lanes remain open. Current CLI apply plans refuse the proposed requests.
|
||||||
|
|
||||||
|
|
||||||
|
2026-09-09 admission: the user explicitly approved both CCRs in the named
|
||||||
|
platform-operator and key-cape-owner roles. Both source requests are approved
|
||||||
|
and both linked Hub decisions are resolved. Receipt:
|
||||||
|
`docs/evidence/2026-09-09-keycape-approval-admission.json`. The completed review
|
||||||
|
intervention is cleared; T05 is progress while the contained first-provision
|
||||||
|
procedure is exercised and the admitted attended rollout is carried through.
|
||||||
|
Verifier-side scope, separate client-side/audit lanes and live acceptance remain
|
||||||
|
as defined in the reviewed requests.
|
||||||
|
|
||||||
## Dependency review — 2026-09-06
|
## Dependency review — 2026-09-06
|
||||||
|
|
||||||
SECRETS-WP-0008-T02 now records the local PIP claim/validation join implemented
|
SECRETS-WP-0008-T02 now records the local PIP claim/validation join implemented
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue