Compare commits
2 commits
443c2698c3
...
97dfa5ea8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 97dfa5ea8f | |||
| 4378ec1fc2 |
11 changed files with 977 additions and 18 deletions
|
|
@ -18,6 +18,12 @@ through a temporary JSON file, never argv. The resulting OpenBao token is
|
|||
revoked at the end of the command (`token revoke -self`). Evidence records
|
||||
the provider name and an accessor fingerprint only.
|
||||
|
||||
Until railiance-platform publishes that file, `--auth auto` keeps the named
|
||||
bootstrap-file and `BAO_TOKEN` providers. `--auth service-jwt` fail-closes.
|
||||
railiance-platform published the exact consumer file on 2026-10-10 at
|
||||
`docs/contracts/secrets-engine-openbao-jwt-login.yaml` (mount `keycape-services`,
|
||||
role `secrets-engine-openbao`, issuer `https://kc.coulomb.social`). When configured,
|
||||
`--auth auto` selects service-jwt; without a contract it keeps named bootstrap/env
|
||||
providers. Explicit service-jwt fails closed when configuration is missing.
|
||||
The current service token is login-self-only and cannot perform protected lane
|
||||
operations. T06 also requires a reviewed task-scoped execution entitlement and
|
||||
functional steady-state adoption; see [final acceptance](service-jwt-final-acceptance.md).
|
||||
A service-jwt selection never reads `--bootstrap-token-file` or `BAO_TOKEN`.
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ policy, TTL/use limits, and cryptographic verification.
|
|||
When `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` names that contract, the CLI selects
|
||||
`service-jwt` and logs in for one command, then self-revokes the OpenBao
|
||||
token. Failure of that path never falls back to `--bootstrap-token-file`,
|
||||
`BAO_TOKEN`, or AppRole. Until the contract is published, `--auth auto` keeps
|
||||
those named providers and `--auth service-jwt` fail-closes. See
|
||||
`BAO_TOKEN`, or AppRole. The platform contract was published 2026-10-10. Configured `--auth auto`
|
||||
selects service-jwt; without that configuration it keeps bootstrap/env. The
|
||||
current login-self-only token cannot perform protected lane operations;
|
||||
functional adoption and task-scoped execution entitlement remain T06. See
|
||||
[openbao-jwt-login.md](openbao-jwt-login.md).
|
||||
|
||||
Canonical provider contract:
|
||||
|
|
|
|||
111
docs/service-jwt-final-acceptance.md
Normal file
111
docs/service-jwt-final-acceptance.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Service-JWT final acceptance — SECRETS-WP-0008-T06
|
||||
|
||||
Prepared 2026-10-11; source preparation only. T06 remains wait. This packet
|
||||
neither grants live authority nor provides owner acknowledgment.
|
||||
|
||||
## Delivered producer and consumer work
|
||||
|
||||
SECRETS-WP-0012 is finished: protected in-memory client-secret acquisition,
|
||||
operator cleanup, KeyCape exchange, service JWT login and service cleanup passed
|
||||
in `docs/evidence/2026-10-10-keycape-openbao-probe.json`. The platform applied
|
||||
CCR-2026-0038 on October 8 and published
|
||||
`railiance-platform/docs/contracts/secrets-engine-openbao-jwt-login.yaml` on
|
||||
October 10. Mount `keycape-services`, role `secrets-engine-openbao`, issuer
|
||||
`https://kc.coulomb.social` are published. An unpublished-mount or
|
||||
unprovisioned-registration wait is obsolete.
|
||||
|
||||
The consumer no-fallback receipt
|
||||
`docs/evidence/2026-10-10-service-jwt-no-fallback.json` proves synthetic selection
|
||||
and real Bao CLI header behavior against a loopback listener. It is not native
|
||||
claim rejection evidence. KEY-WP-0039-T01 is source acceptance done; T02 owns the
|
||||
attended actual negative-token session. RPF-WP-0035-T02 retains native claims,
|
||||
effective policy/capability denials, and MASON-WP-0009-T05 return.
|
||||
|
||||
WP0007 is six of seven complete with native per-lane adoption outstanding.
|
||||
WP0009 credential apply/verify/delivery/revocation passed in the September 27
|
||||
attended receipts, but its task cannot finish: held EUR 10 reservation still
|
||||
needs accounting reconciliation, replacement runtime admission and separate
|
||||
retry authority. These are existing live tasks; this packet closes neither.
|
||||
|
||||
## Executable missing identity-boundary check
|
||||
|
||||
`tools/verify_service_jwt_boundary.py` defaults to backend-free preparation.
|
||||
It checks exact applied CCR, published mount/role and issuer, client-secret-file
|
||||
refusal and exact guarded paths. Preparation cannot produce a passing native
|
||||
receipt. After explicit authorization for the attended metadata check, add
|
||||
`--execute` to the same command:
|
||||
|
||||
```bash
|
||||
env -u BAO_TOKEN -u VAULT_TOKEN -u SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE \
|
||||
BAO_ADDR=http://127.0.0.1:18200 \
|
||||
SECRETS_ENGINE_KEYCAPE_TOKEN_URL=https://kc.coulomb.social/token \
|
||||
SECRETS_ENGINE_KEYCAPE_ISSUER=https://kc.coulomb.social \
|
||||
SECRETS_ENGINE_OPENBAO_JWT_LOGIN=/home/worsch/railiance-platform/docs/contracts/secrets-engine-openbao-jwt-login.yaml \
|
||||
uv run python tools/verify_service_jwt_boundary.py \
|
||||
--ccr /home/worsch/railiance-platform/credential-change-requests/CCR-2026-0038-secrets-engine-service-jwt-login.yaml
|
||||
```
|
||||
|
||||
Live mode uses existing attended custody and performs two service-token metadata
|
||||
requests: lookup-self and one batch capabilities-self. The batch checks both
|
||||
client-secret guarded paths, `sys/policies/acl/*`, `auth/*/role/*` and
|
||||
`auth/token/create`; it never reads their values or attempts writes. Cleanup
|
||||
runs even on failed verification. Two metadata calls reserve room for cleanup
|
||||
within the service token's eight-use budget. Passing requires exactly the
|
||||
self policy, no identity-policy additions, deny on every requested path and
|
||||
both operator and service revocation success. Output contains hashes, capability
|
||||
booleans and session fingerprints. No backend response/error body is printed.
|
||||
Transport fixes bind each request to the configured OpenBao origin, disable
|
||||
environment proxies and refuse all redirects including relative redirects.
|
||||
Only HTTP 200 application/json with a bounded 64 KiB body and object data is
|
||||
accepted. Errors and response bodies are suppressed. Session evidence is
|
||||
restricted to the contract whitelist with strict boolean types, the fixed
|
||||
source name, and twelve-hex session handles. Native acceptance requires every
|
||||
contract field (established, source, both fingerprints and attempted/succeeded
|
||||
cleanup flags), a normally returned cleanup call, and no verification failure.
|
||||
Stale true evidence cannot override a cleanup exception. HTTP JSON with an
|
||||
errors member is refused even when a data member also exists. Synthetic/local-loopback tests
|
||||
cover redirects, proxy environment, size/status/content/shape failures, absent
|
||||
responses, unknown exceptions and unknown evidence fields with cleanup.
|
||||
This does not prove expiry/use exhaustion, native claim negatives or adoption.
|
||||
Platform retains those native test obligations and owner review of this tool.
|
||||
|
||||
## Protected-operation adoption is a distinct missing contract
|
||||
|
||||
INTENT requires task-bounded authority and a functional Lifecycle API. The
|
||||
platform design explicitly says this login policy is insufficient for native
|
||||
lanes; future execution entitlement requires a separately reviewed CCR type and
|
||||
backend-enforced scope. No standing wildcard policy is accepted.
|
||||
|
||||
Actual CLI flow (`cli._require_lane_approval`, `_open_backend`, `cmd_apply`) is
|
||||
claim → validated PDP Check → approval CAS consume → select provider → OpenBao
|
||||
operation. `_open_backend` supplies the login-only service token directly.
|
||||
There is no per-action delegated grant or scoped backend exchange in that path.
|
||||
Consequently a service-jwt default flip would consume a real approval then fail
|
||||
the protected operation. Configured `auto` chooses service-jwt; unconfigured
|
||||
`auto` still chooses bootstrap/env. Neither selection proves steady-state
|
||||
adoption. Keep existing service policy and provider defaults until the execution
|
||||
contract exists; failed JWT never switches provider.
|
||||
|
||||
Founder/owner choice needed: retain login-self-only and implement a separately
|
||||
owned task-scoped delegation contract (recommended), or explicitly redesign
|
||||
backend execution mediation with platform enforcement. Both must preserve
|
||||
claim/PDP/CAS binding, exact action/lane/target/expiry/principal scope, failure
|
||||
closed, revocation and audit evidence; neither means granting broad standing
|
||||
ACLs. Retaining attended bootstrap/env as a transition leaves T06 open.
|
||||
|
||||
Concrete missing owner obligation: secrets-engine and railiance-platform must
|
||||
agree the execution entitlement's owner and CCR schema, implementation endpoint,
|
||||
backend policy enforcement, exact request binding, TTL/use budget and revocation,
|
||||
then wire it after successful consume. KeyCape supplies identity, not authority.
|
||||
Existing native AppRole delivery sessions are short-lived workload read sessions,
|
||||
not an engine administrative entitlement. Existing native lane cutover requires
|
||||
scoped attended platform authority but does not implement this exchange.
|
||||
|
||||
T06 closes only after native KEY39/CCR negative/expiry/use/cleanup evidence and
|
||||
MASON return, a reviewed implemented execution entitlement, and a selected real
|
||||
protected action through service identity plus delegated authority: exact bound
|
||||
PDP/CAS evidence, intended backend result, unauthorized target/action/expired or
|
||||
replayed request refusal, cleanup on success and failure, no standing engine
|
||||
credential, and steady-state invocation/default migration evidence. Do not use
|
||||
`apply` or any other protected live command to discover the missing entitlement.
|
||||
All of this remains in T06 and owner tasks; no residual is silently finished.
|
||||
248
docs/task-scoped-openbao-execution-contract.md
Normal file
248
docs/task-scoped-openbao-execution-contract.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Task-scoped protected OpenBao execution — owner design
|
||||
|
||||
Design ID: `SECRETS-EXEC-20261011-v1`. Status: proposed, source preparation only.
|
||||
Owning draft: `SECRETS-WP-0013`; adoption remains `SECRETS-WP-0008-T06`.
|
||||
No native entitlement, owner assent or founder go is established here.
|
||||
|
||||
## The concrete gap and immediate correction
|
||||
|
||||
`cli._require_lane_approval` consumes the approved action before `_open_backend`.
|
||||
`engine_auth.select_engine_auth` selects service-jwt for auto whenever its login
|
||||
contract is configured. CCR-2026-0038 grants a 300-second, eight-use token with
|
||||
exactly `secrets-engine-login-self`. It explicitly denies custody data/metadata,
|
||||
policy/auth writes and child-token creation. Thus a valid approval can be
|
||||
consumed before an ordinary apply/provision/exec fails for lack of backend power.
|
||||
|
||||
The staged correction checks **local execution readiness before claim/PDP/CAS**
|
||||
for every caller about to access protected OpenBao work. Until a real reviewed
|
||||
execution backend ships, service-jwt is rejected for those callers. Explicit
|
||||
bootstrap/env providers retain their existing approval/stance gates; no fallback
|
||||
or default changes. Service-auth login/self probes remain available. Local-only
|
||||
compromise/reactivate records retain their existing path. A file/boolean claiming
|
||||
entitlement cannot make the guard pass. Tests exercise the actual apply handler,
|
||||
asserting zero claim/PDP/consume/login/apply calls for configured auto and missing
|
||||
explicit service-jwt entitlement. This correction fixes early consumption; it
|
||||
does **not** implement task-scoped protected work or complete T06.
|
||||
|
||||
## One initial execution surface
|
||||
|
||||
Implement `secrets-engine execute-ccr --catalog <id> --stage prod --request <file>`
|
||||
as an explicit, metadata-only action. It admits one owner-reviewed Platform
|
||||
`workload-kv-read` CCR with already-existing KV and auth mounts, exact generated
|
||||
policy and exact Kubernetes or existing netkingdom OIDC role. It does not call
|
||||
generic `apply_plan`: that function can manage AppRoles/mounts, which differs
|
||||
from Platform's typed CCR applier. Provision, exec delivery, rotation, KV reads,
|
||||
SecretIDs, token creation by the caller, mounting and arbitrary CLI commands
|
||||
remain outside this first entitlement. Their backend obligations require their
|
||||
own later exact action contracts, not reuse of this metadata allowance.
|
||||
|
||||
CCR38 and its role/policy stay byte-for-byte unchanged. The engine identifies
|
||||
itself through that login-only service session; authentication does not create
|
||||
authority for the metadata action. A new separately reviewed Platform
|
||||
`task-execution-grant` CCR admits the founder-owned execution mediator and its
|
||||
temporary exact two-path task token. Existing applied CCRs, including CCR38,
|
||||
cannot be replayed to manufacture new native work. A real unexecuted, separately
|
||||
approved metadata CCR and native success/negative/cleanup receipts are needed
|
||||
for the first proof; none is selected or approved by this design.
|
||||
|
||||
## Exact request binding
|
||||
|
||||
Use the existing `authorization.build_action_request(entry, 'apply', ...)` shape
|
||||
and canonical digest rules. Add a reviewed `context.execution_contract` object
|
||||
before observing the claim or calling Check. Its fields are:
|
||||
|
||||
| Field | Binding |
|
||||
| --- | --- |
|
||||
| contract_version | `SECRETS-EXEC-20261011-v1` |
|
||||
| operation_id | Owner-issued UUID, part of the request digest |
|
||||
| operation | `metadata-apply-ccr`, closed enum |
|
||||
| execution_actor | Verified `service:secrets-engine`, separate from initiating subject |
|
||||
| ccr_id / ccr_immutable_sha256 | Exact unapplied owner CCR ID and immutable content digest |
|
||||
| execution_grant_ccr_id / grant_immutable_sha256 | Exact reviewed task-token materialization and cleanup contract |
|
||||
| catalog_sha256 | Exact non-secret approved catalog target specification |
|
||||
| source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source |
|
||||
| backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity |
|
||||
| operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body |
|
||||
| operation_deadline | Upper bound no later than approval, PDP or task-token expiry |
|
||||
| backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses |
|
||||
| expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents |
|
||||
|
||||
The action subject/type/purpose/tenant are the actual initiating principal's
|
||||
verified identity and existing catalog purpose, not inferred from engine service
|
||||
identity or arbitrary environment fields. Founder-attended invocation names that
|
||||
principal and the representation explicitly. The mediator refuses absent owner
|
||||
identity correspondence; a caller-authored subject string is not evidence.
|
||||
|
||||
The signed/served approval must bind the same approval-free execution context;
|
||||
the PDP must return the exact canonical request and matching approval digest.
|
||||
Publish and pin the updated access-engine policy package/version that evaluates
|
||||
this context; an older package accepting only a catalog ID cannot authorize it.
|
||||
No package default or implicit semantic acceptance is assumed. Approval identity,
|
||||
decision ID and canonical request digest travel together in a non-secret request
|
||||
descriptor. They are provenance until freshly validated and CAS-consumed.
|
||||
|
||||
CCR immutable digests exclude only explicitly owner-defined append-only review/
|
||||
evidence fields; policy bytes, role bytes, typed mutation body, target coordinates,
|
||||
grant/deadline and source revisions are included. Approval IDs added after
|
||||
issuance do not change their own binding. The mediator independently reconstructs
|
||||
all operation bodies from owner sources and compares the resulting digest list.
|
||||
It accepts no caller ACL template, path override, shell argv or raw JSON body.
|
||||
|
||||
## Execution mediation and consume ordering
|
||||
|
||||
Use the existing attended Platform front door, invoked by the founder, to run a
|
||||
new fixed Platform owner mediator. No daemon, broad new service or new bearer
|
||||
delivery to agents is required. The caller engine prepares a non-secret request
|
||||
and receives a non-secret receipt. The founder-held admin credential exists only
|
||||
inside the reviewed Platform child, through the existing attended route. It is
|
||||
never exported to the caller. Neither local receipt files nor user-authored
|
||||
`approved` labels authorize the child.
|
||||
|
||||
The Platform child runs the reviewed Secrets Engine authorization/consume code
|
||||
at the last responsible execution boundary; the CLI preparation path does not
|
||||
consume early and then hand an untrusted receipt to a more privileged process.
|
||||
The backend mediation contract must preserve engine ownership of lifecycle
|
||||
validation while Platform owns its OpenBao credential and typed native primitive.
|
||||
|
||||
1. Load pinned sources and exact non-secret request; check typed CCR/grant owner
|
||||
approval, catalog/subject/operation correspondence, source/state expectations,
|
||||
owner-only execution state directory, supported method/body allowlist and
|
||||
local clock/deadline. No protected OpenBao call occurs yet.
|
||||
2. Atomically reserve the operation in a dedicated engine execution ledger,
|
||||
uniquely keyed by `approval_id + request_digest`; the operation UUID is inside
|
||||
the digest. Bind the authenticated caller, sources and deadline. This is
|
||||
execution state, not the audit outbox or a local evidence-presence gate.
|
||||
Multiworker locking uses SQLite `BEGIN IMMEDIATE` and uniqueness constraints;
|
||||
only the reservation creator may proceed.
|
||||
3. Use `authorize_action` / approval-claim validation / `check_decision` / exact
|
||||
envelope validation, with pinned policy package and trusted application time.
|
||||
Recheck freshness after network calls. Require served human control for the
|
||||
production grant; missing claims, unreachable PDP or mismatched binding refuse.
|
||||
4. Call `require_production_consume` once for that exact binding. Require
|
||||
confirmed matching consumption. Persist `consume-confirmed` before any
|
||||
protected OpenBao login, custody read, capability check or mutation. A consume
|
||||
timeout is `unknown`; never treat it as absence or automatically retry.
|
||||
A consumed/idempotent response without the same live local admission is not
|
||||
permission to execute again. Retrying a completed operation returns only its
|
||||
existing receipt; an incomplete/unknown operation requires owner reconciliation
|
||||
and a fresh exact approval for any additional backend action.
|
||||
5. After consume, perform the existing attended in-memory client-secret source
|
||||
and CCR38 service login without provider fallback. Verify its exact login-self
|
||||
policy/TTL/uses and service identity; keep this session self-only. Failure
|
||||
consumes the trial without metadata effects; no implicit re-login or retry.
|
||||
6. In the Platform child, use the separately authorized founder-held identity
|
||||
only to materialize the reviewed exact ephemeral task policy/token. OpenBao's
|
||||
token parent remains platform-owned; the CCR38 token cannot mint it. The grant
|
||||
binds every policy/token create/revoke/delete step to this operation and its
|
||||
cleanup. Do not give a standing `sys/policies/acl/*`, `auth/*/role/*` or
|
||||
`auth/token/create` grant to the engine service.
|
||||
7. Recheck decision, approval and operation deadlines before **each** protected
|
||||
request. The task token is nonrenewable, no default/identity policies and
|
||||
grants only read/create/update on the one approved ACL policy path and the
|
||||
one approved workload auth-role path, plus revoke-self. No child token, mount
|
||||
administration, KV data/metadata, SecretID or sibling role is admitted.
|
||||
OpenBao ACL narrows endpoint access; it cannot restrict the HCL/role payload.
|
||||
Only the mediator's independently reconstructed body equality enforces that.
|
||||
8. Use an injected restricted backend adapter for the exact ordered native
|
||||
calls. Compare current expected state; write only the approved generated body,
|
||||
read it back, and compare it. No `OpenBaoClient._run_ok` raw failure reaches
|
||||
receipts: capture/discard backend output and use closed reason codes. A generic
|
||||
403/timeout or “already exists” is not successful verification.
|
||||
9. Finally self-revoke the task token and login-self token, then retire only the
|
||||
exact per-operation grant policy under founder-owned cleanup authority.
|
||||
Record confirmed revocation/absence separately from native apply status.
|
||||
Policy retirement alone does not prove issued-token revocation.
|
||||
|
||||
Task-token use budget is declared before issuance: two before-state reads, two
|
||||
writes, two readbacks and one self-revoke (seven uses), plus at most four explicit
|
||||
metadata capability checks and one reserved cleanup use (twelve total). Failed
|
||||
backend calls count; never retry to evade limits. Task TTL is
|
||||
`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus
|
||||
the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain
|
||||
for planned requests. No renewal or remint extends the operation. CCR38 keeps its
|
||||
own 300s/eight-use limit; login-self checks and revoke fit its separate budget.
|
||||
|
||||
## Failure, replay and recovery
|
||||
|
||||
Ledger states: `reserved → consume-confirmed → authenticating → grant-issued →
|
||||
applying → verified`, followed by a separate `cleanup-confirmed` or
|
||||
`cleanup-unconfirmed` result. Every external call is durably recorded as about
|
||||
to execute before the call, then recorded complete afterward. Crash between
|
||||
those steps is `unknown` rather than presumed failure. Concurrent duplicate
|
||||
requests never both reach consume or OpenBao.
|
||||
|
||||
An operation can end refused before consumption, consumed-without-backend-work,
|
||||
partially-applied, backend-outcome-unknown, verified-cleanup-unconfirmed or
|
||||
verified-cleanup-confirmed. No incomplete state permits blind reapplication.
|
||||
Cleanup continues when an action deadline expires, under the original explicit
|
||||
cleanup obligation, without new business writes. Read/repair of changed target
|
||||
state requires a fresh exact owner-reviewed recovery approval; no old snapshot
|
||||
overwrites concurrent owner edits. Hard interruption uses the Platform owner's
|
||||
bounded revocation/cleanup procedure with protected accessor custody. Ledger
|
||||
and evidence carry only fingerprints/metadata; the raw accessor/token never
|
||||
leaves owner memory or the separately admitted owner-private recovery channel.
|
||||
|
||||
Revocation failure blocks owner acceptance and T06 closure, even if TTL will
|
||||
eventually expire. Emit non-secret atomic local lifecycle events/outbox records
|
||||
at the ledger boundaries; audit-core outage does not suppress cleanup. Do not
|
||||
claim an empty archive proves no effects, or that local ledger state is founder
|
||||
approval. Owner acceptance requires native before/after equality, denied sibling
|
||||
actions and verified cleanup under the same exact trial.
|
||||
|
||||
## Implementation hooks and closure tests
|
||||
|
||||
- `engine_auth.py`: staged readiness guard now. Later separate identity-session
|
||||
and execution-adapter objects; generic service-jwt client remains self-only.
|
||||
- `cli.py`: staged guard passed through backend-action approval callers now.
|
||||
New explicit execute-ccr preparation/mediated dispatch later; ordinary apply
|
||||
does not acquire new rights merely because the login contract is configured.
|
||||
- `authorization.py`, `approval_consume.py`: execution context participates in
|
||||
both approval-free binding and full PDP digest; owner child returns the actual
|
||||
`ConsumedApproval`, not only an `AuthorizedAction` ID.
|
||||
- New `execution_contract.py`, `execution_ledger.py`, `mediated_backend.py`:
|
||||
strict closed schema, deterministic compiler, separate admission state,
|
||||
pinned owner-native adapter and non-secret outcome types. Ordinary subprocess
|
||||
command execution is not an implementation of this adapter.
|
||||
- Platform owns task-execution-grant CCR/typed validator/token materializer/
|
||||
fixed execution child/recovery hooks. Approval Engine/PDP owners acknowledge
|
||||
the request+claim/package correspondence; secrets-engine cannot assent for them.
|
||||
|
||||
Counterexamples required before shipping: login-only auto provider consumes
|
||||
nothing; stale/forged grant file changes nothing; same name with changed HCL or
|
||||
role body refuses; wrong method/sibling policy/role/KV/SecretID/child-token/mount
|
||||
write refuses; changed source/caller/tenant/catalog/deadline alters the bound
|
||||
request and refuses; consume conflict/unavailability/timeout produces zero
|
||||
backend calls; idempotent approval consumption never causes a second execution;
|
||||
concurrent calls admit one worker; postconsume login failure never falls back;
|
||||
expiry during a multi-call operation stops new mutations but permits scoped
|
||||
cleanup; readback mismatch/transport failure is incomplete; revoke failure
|
||||
never passes; partial apply and crash after write require explicit recovery.
|
||||
|
||||
Current staged readiness regression suite: eight new checks plus 28 existing
|
||||
auth, consume and privileged-evidence checks pass (36 total). No native action
|
||||
was performed. Full entitlement implementation, policy-package publication,
|
||||
independent Platform/Secrets Engine/identity/approval/PDP acknowledgement and
|
||||
exact founder-scoped native proof remain required. A successful metadata trial
|
||||
alone would not cover value provision or exec delivery.
|
||||
|
||||
## Existing commands and the next contract they actually need
|
||||
|
||||
| Existing operation | Current login-only outcome | Contract required after the metadata pilot |
|
||||
| --- | --- | --- |
|
||||
| apply / apply_plan | Readiness refusal before consume; no backend | A separate exact policy/AppRole/mount structure compiler and grant for each admitted PlanAction. The workload-kv-read execute-ccr pilot does not implement these generic AppRole/mount calls. |
|
||||
| provision / rotate | Readiness refusal before consume; no KV writes | Exact KV v2 data path, declared field set, expected CAS version, no sibling changes, owner-held opaque input handle, confirmed new version and separately admitted revoke/cleanup. Never expose secret plaintext or guessable secret-value hashes in request/evidence. |
|
||||
| verify positive/negative | Readiness refusal before consume; no custody reads | Metadata capability probe plus, only when required, exact declared field-presence read through a restricted owner adapter. Return booleans; malformed/denied/transport error is inconclusive, not absence. Independent negative identity and exact deny paths are separate bindings. |
|
||||
| exec / wrap | Readiness refusal before consume; no read/injection/wrap | Exact custody path/field, signed executable/cwd/destination/exec-owner binding and duration, no user arbitrary shell, owner-mediated read and approved process-local delivery, or exact one-use wrap creation/unwrap/expiry; self revoke and injected material cleanup. |
|
||||
| handoff | Readiness refusal before consume; no role-id/SecretID mint | Exact existing AppRole and single-use SecretID creation, bounded TTL/uses and owner-admitted delivery; native role-id lookup/SecretID mint/login/revoke budget and no caller standing credential. |
|
||||
| revoke / native suspend/deactivate | Readiness refusal before consume; no backend revocation/deletion | Exact policy/role/lease/accessor target and expected state, admitted lifecycle effect, rollback/custody preservation and denial readback. Token/lease handles remain owner-private; receipt fingerprints cannot authorize another handle. |
|
||||
| session revoke | Readiness refusal before its backend; legacy stance remains | Exact already-held protected handle, typed token/lease revocation and independent subject/authority binding; no arbitrary accessor or lease-prefix wildcard. |
|
||||
| destroy | Existing live destroy refusal remains | Exact destructive dual-control contract and custody/backup/version proof; metadata pilot supplies neither. |
|
||||
| compromise / reactivate local state | Existing local state gate remains | No new OpenBao entitlement; these local records do not establish actual native revocation/reactivation. |
|
||||
| service-auth probe-openbao | Existing explicit self-only identity trial | CCR38 positive/negative/lifetime/revoke proof, with no implied entitlement to any catalog lane. |
|
||||
|
||||
Each subsequent contract needs its own owner-reviewed compiler, request/PDP
|
||||
package binding, method/body enforcement, consume admission, TTL/use budget,
|
||||
failure/recovery rules and native counterexamples. No success in the initial
|
||||
metadata pilot unblocks these rows by default. Full T06 adoption requires the
|
||||
actual selected engine production workflow to run through the corresponding
|
||||
reviewed entitlement, with native effect, denial/no-fallback and cleanup proof.
|
||||
|
|
@ -43,7 +43,11 @@ from secrets_engine.config import Config, repo_root
|
|||
from secrets_engine.decisions import require_approved, resolve_decision
|
||||
from secrets_engine.errors import BackendError, DecisionError, SecretsEngineError
|
||||
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
||||
from secrets_engine.engine_auth import login_service_jwt, select_engine_auth
|
||||
from secrets_engine.engine_auth import (
|
||||
login_service_jwt,
|
||||
select_engine_auth,
|
||||
require_backend_execution_ready,
|
||||
)
|
||||
from secrets_engine.lane_state import (
|
||||
load_lane_state,
|
||||
operation_state,
|
||||
|
|
@ -111,6 +115,7 @@ def _require_lane_approval(
|
|||
evidence: PrivilegedActionEvidence | None = None,
|
||||
*,
|
||||
fields: tuple[str, ...] = (),
|
||||
auth_args: object | None = None,
|
||||
):
|
||||
"""Apply published PEP stance, resolve lane approval, then CAS-consume.
|
||||
|
||||
|
|
@ -125,6 +130,8 @@ def _require_lane_approval(
|
|||
# Steps 1 and 2 first: a validated claim and decision are what make the
|
||||
# unreachable-engine residue inapplicable. Absent configuration returns
|
||||
# None and production stays closed exactly as before.
|
||||
if auth_args is not None:
|
||||
require_backend_execution_ready(cfg, auth_args, action or "unknown")
|
||||
authorization = authorize_action(
|
||||
cfg, entry, action or "unknown", None, fields=fields,
|
||||
policy_targets=(entry.policy_name,) if entry.has_delivery_auth else (),
|
||||
|
|
@ -303,7 +310,7 @@ def cmd_apply(cfg: Config, args) -> int:
|
|||
return 0
|
||||
|
||||
with _privileged_evidence(cfg, entry, "apply") as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "apply", evidence)
|
||||
decision = _require_lane_approval(cfg, entry, "apply", evidence, auth_args=args)
|
||||
evidence.mark_approved(decision)
|
||||
plan = build_plan(
|
||||
entry, args.stage, decision_id=decision.id if decision else ""
|
||||
|
|
@ -331,7 +338,8 @@ def cmd_provision(cfg: Config, args) -> int:
|
|||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||
)
|
||||
decision = _require_lane_approval(
|
||||
cfg, entry, "provision", evidence, fields=(field,) if field else ()
|
||||
cfg, entry, "provision", evidence, fields=(field,) if field else (),
|
||||
auth_args=args,
|
||||
)
|
||||
evidence.mark_approved(decision)
|
||||
require_provision_state(cfg.evidence_dir, entry.id)
|
||||
|
|
@ -368,7 +376,8 @@ def cmd_rotate(cfg: Config, args) -> int:
|
|||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||
)
|
||||
decision = _require_lane_approval(
|
||||
cfg, entry, "rotate", evidence, fields=(field,) if field else ()
|
||||
cfg, entry, "rotate", evidence, fields=(field,) if field else (),
|
||||
auth_args=args,
|
||||
)
|
||||
evidence.mark_approved(decision)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
|
|
@ -410,7 +419,8 @@ def cmd_verify(cfg: Config, args) -> int:
|
|||
},
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(
|
||||
cfg, entry, "verify", evidence, fields=tuple(fields)
|
||||
cfg, entry, "verify", evidence, fields=tuple(fields),
|
||||
auth_args=args,
|
||||
)
|
||||
evidence.mark_approved(decision)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
|
|
@ -482,7 +492,7 @@ def cmd_handoff(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(
|
||||
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
|
||||
decision = _require_lane_approval(cfg, entry, "handoff", evidence, auth_args=args)
|
||||
evidence.mark_approved(decision)
|
||||
require_delivery_state(cfg.evidence_dir, entry.id, "handoff")
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
|
|
@ -522,7 +532,7 @@ def cmd_wrap(cfg: Config, args) -> int:
|
|||
with _privileged_evidence(
|
||||
cfg, entry, "wrap", detail={"ttl": args.ttl, "out_file": args.out}
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "wrap", evidence)
|
||||
decision = _require_lane_approval(cfg, entry, "wrap", evidence, auth_args=args)
|
||||
evidence.mark_approved(decision)
|
||||
require_delivery_state(cfg.evidence_dir, entry.id, "wrap")
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
|
|
@ -576,7 +586,8 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
))
|
||||
# require approval + readiness before running.
|
||||
decision = _require_lane_approval(
|
||||
cfg, entry, "exec", evidence, fields=(field,) if field else ()
|
||||
cfg, entry, "exec", evidence, fields=(field,) if field else (),
|
||||
auth_args=args,
|
||||
)
|
||||
evidence.mark_approved(decision)
|
||||
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
|
||||
|
|
@ -598,7 +609,8 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
},
|
||||
))
|
||||
lane_decision = _require_lane_approval(
|
||||
cfg, lane, "exec", lane_evidence, fields=(lane_field,)
|
||||
cfg, lane, "exec", lane_evidence, fields=(lane_field,),
|
||||
auth_args=args,
|
||||
)
|
||||
lane_evidence.mark_approved(lane_decision)
|
||||
require_delivery_state(cfg.evidence_dir, lane.id, "exec")
|
||||
|
|
@ -646,6 +658,7 @@ def cmd_session_revoke(cfg: Config, args) -> int:
|
|||
detail={"handle_kind": kind},
|
||||
)
|
||||
with evidence:
|
||||
require_backend_execution_ready(cfg, args, "session-revoke")
|
||||
stance = apply_unreachable_engine_stance(cfg, stance_entry, "session-revoke")
|
||||
evidence.mark_stance(stance)
|
||||
handle = read_strict_token_file(
|
||||
|
|
@ -726,7 +739,7 @@ def cmd_revoke(cfg: Config, args) -> int:
|
|||
with _privileged_evidence(
|
||||
cfg, entry, "revoke", detail={"operation": plan.operation}
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "deactivate", evidence)
|
||||
decision = _require_lane_approval(cfg, entry, "deactivate", evidence, auth_args=args)
|
||||
evidence.mark_approved(decision)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
result = apply_lifecycle_plan(client, plan)
|
||||
|
|
@ -831,7 +844,7 @@ def cmd_lifecycle(cfg: Config, args) -> int:
|
|||
"live destroy is disabled until an exact-action destruction "
|
||||
"approval contract is available; use --dry-run to inspect targets"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
|
||||
decision = _require_lane_approval(cfg, entry, args.operation, evidence, auth_args=args)
|
||||
evidence.mark_approved(decision)
|
||||
with _open_backend(cfg, args, evidence) as client:
|
||||
result = apply_lifecycle_plan(client, plan)
|
||||
|
|
|
|||
|
|
@ -156,3 +156,21 @@ def login_service_jwt(cfg: Any) -> ScopedTokenSession:
|
|||
finally:
|
||||
# Drop the KeyCape JWT from this frame; OpenBao verifies the signature.
|
||||
del service_jwt
|
||||
|
||||
|
||||
def require_backend_execution_ready(cfg: Any, args: Any, action: str) -> AuthSelection:
|
||||
"""Reject a login-only provider before approval consume or backend work.
|
||||
|
||||
CCR-2026-0038 authenticates identity; it grants no protected lane action.
|
||||
A configured login contract is not an execution entitlement. No mediated
|
||||
execution backend is shipped, so no local file/flag may declare it ready.
|
||||
Named bootstrap/env providers retain their existing separate gates.
|
||||
"""
|
||||
selection = select_engine_auth(cfg, args)
|
||||
if selection.provider == "service-jwt":
|
||||
raise BackendError(
|
||||
"service-jwt protected execution is not ready: CCR-2026-0038 is "
|
||||
"login-only; no reviewed task-scoped execution backend is shipped. "
|
||||
"Approval was not consumed and no OpenBao call was made"
|
||||
)
|
||||
return selection
|
||||
|
|
|
|||
71
tests/test_execution_readiness.py
Normal file
71
tests/test_execution_readiness.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine import cli
|
||||
from secrets_engine.engine_auth import require_backend_execution_ready
|
||||
from secrets_engine.errors import BackendError
|
||||
|
||||
|
||||
def cfg(contract=None):
|
||||
return SimpleNamespace(openbao_jwt_login_file=contract)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('provider,contract', [('service-jwt', None), ('auto', '/non-secret/login.yaml')])
|
||||
def test_login_only_refused_before_claim_consume_or_backend(provider, contract, monkeypatch):
|
||||
events = []
|
||||
for name in ['authorize_action', 'require_production_consume', 'login_service_jwt']:
|
||||
monkeypatch.setattr(cli, name, lambda *a, _name=name, **k: events.append(_name))
|
||||
monkeypatch.setattr(cli.OpenBaoClient, 'resolve', lambda *a, **k: events.append('backend'))
|
||||
args = SimpleNamespace(auth=provider, bootstrap_token_file=None)
|
||||
with pytest.raises(BackendError, match='Approval was not consumed'):
|
||||
cli._require_lane_approval(cfg(contract), SimpleNamespace(stage='prod'), 'apply', auth_args=args)
|
||||
assert events == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('provider,bootstrap', [('env', None), ('bootstrap', '/private/explicit-token')])
|
||||
def test_named_legacy_provider_retained(provider, bootstrap):
|
||||
args = SimpleNamespace(auth=provider, bootstrap_token_file=bootstrap)
|
||||
result = require_backend_execution_ready(cfg('/published/login.yaml'), args, 'apply')
|
||||
assert result.provider == provider
|
||||
assert result.break_glass is (provider == 'bootstrap')
|
||||
|
||||
|
||||
def test_auto_with_contract_and_bootstrap_never_falls_back():
|
||||
with pytest.raises(BackendError, match='no fallback'):
|
||||
require_backend_execution_ready(cfg('/published/login.yaml'),
|
||||
SimpleNamespace(auth='auto', bootstrap_token_file='/private/file'), 'apply')
|
||||
|
||||
|
||||
def test_readiness_cannot_be_self_asserted():
|
||||
c = cfg('/published/login.yaml')
|
||||
c.execution_entitlement_file = '/pretend/approved.json'
|
||||
c.service_jwt_execution_ready = True
|
||||
with pytest.raises(BackendError, match='no reviewed task-scoped'):
|
||||
require_backend_execution_ready(c, SimpleNamespace(auth='auto', bootstrap_token_file=None), 'apply')
|
||||
|
||||
|
||||
def test_self_probe_auth_selection_remains_service_jwt():
|
||||
from secrets_engine.engine_auth import select_engine_auth
|
||||
assert select_engine_auth(cfg('/published/login.yaml'),
|
||||
SimpleNamespace(auth='auto', bootstrap_token_file=None)).provider == 'service-jwt'
|
||||
|
||||
|
||||
def test_real_apply_handler_refuses_before_consumption(tmp_path, monkeypatch):
|
||||
import copy
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.config import Config
|
||||
from tests.test_catalog import VALID
|
||||
entry = validate_entry(copy.deepcopy(VALID))
|
||||
configuration = Config(catalog_dir=tmp_path, policy_dir=tmp_path,
|
||||
evidence_dir=tmp_path / 'evidence', hub_url='', bao_addr='http://127.0.0.1:8200',
|
||||
topic_id='fixture', openbao_jwt_login_file=tmp_path / 'login-contract.yaml')
|
||||
monkeypatch.setattr(cli, '_resolve_lane_and_decision', lambda *a: entry)
|
||||
calls = []
|
||||
for name in ['authorize_action', 'require_production_consume', 'login_service_jwt', 'apply_plan']:
|
||||
monkeypatch.setattr(cli, name, lambda *a, _name=name, **k: calls.append(_name))
|
||||
args = SimpleNamespace(ref=entry.id, stage=entry.stage, dry_run=False,
|
||||
auth='auto', bootstrap_token_file=None)
|
||||
with pytest.raises(BackendError, match='Approval was not consumed'):
|
||||
cli.cmd_apply(configuration, args)
|
||||
assert calls == []
|
||||
212
tests/test_service_jwt_boundary.py
Normal file
212
tests/test_service_jwt_boundary.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.errors import BackendError
|
||||
|
||||
spec = importlib.util.spec_from_file_location("boundary", Path(__file__).parents[1] / "tools/verify_service_jwt_boundary.py")
|
||||
boundary = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(boundary)
|
||||
|
||||
|
||||
class Session:
|
||||
client = SimpleNamespace()
|
||||
closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def evidence(self):
|
||||
return {"secret_source": "openbao-oidc-in-memory", "established": True,
|
||||
"operator_session_handle": "0123456789ab", "session_handle": "abcdef012345",
|
||||
"operator_revocation_attempted": True, "operator_revocation_succeeded": True,
|
||||
"revocation_attempted": self.closed, "revocation_succeeded": self.closed}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra_identity", [False, True])
|
||||
def test_batch_denial_checks_identity_policy_and_always_cleans(extra_identity):
|
||||
session = Session()
|
||||
paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)
|
||||
calls = []
|
||||
|
||||
def request(client, path, payload=None):
|
||||
calls.append((path, payload))
|
||||
if path == "auth/token/lookup-self":
|
||||
return {"policies": ["secrets-engine-login-self"], "identity_policies": ["admin"] if extra_identity else []}
|
||||
return {p: ["deny"] for p in paths}
|
||||
|
||||
result = boundary.verify_session(session, {"expected_policy": "secrets-engine-login-self", "denied_paths": paths}, request)
|
||||
assert result["native_verified"] is (not extra_identity)
|
||||
assert session.closed
|
||||
assert len(calls) == 2
|
||||
assert calls[1][1] == {"paths": paths}
|
||||
assert not result["protected_operation_adoption_proved"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["request", "missing-deny", "revoke"])
|
||||
def test_failed_or_partial_acceptance_never_passes(failure):
|
||||
session = Session()
|
||||
paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)
|
||||
|
||||
def request(client, path, payload=None):
|
||||
if failure == "request":
|
||||
raise BackendError("sensitive backend error")
|
||||
if path == "auth/token/lookup-self":
|
||||
return {"policies": ["secrets-engine-login-self"]}
|
||||
return {p: ["deny"] for p in paths[:-1] if failure == "missing-deny"} if failure == "missing-deny" else {p: ["deny"] for p in paths}
|
||||
|
||||
if failure == "revoke":
|
||||
def close():
|
||||
raise BackendError("sensitive backend error")
|
||||
session.close = close
|
||||
result = boundary.verify_session(session, {"expected_policy": "secrets-engine-login-self", "denied_paths": paths}, request)
|
||||
assert not result["native_verified"]
|
||||
assert "sensitive backend error" not in str(result)
|
||||
if failure != "revoke":
|
||||
assert session.closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fault", ["oversize-header", "oversize-body", "malformed", "wrong-shape", "wrong-status", "wrong-content", "no-response", "unknown-error"])
|
||||
def test_transport_failures_suppress_body_and_cleanup(monkeypatch, fault):
|
||||
from email.message import Message
|
||||
headers = Message()
|
||||
headers["Content-Type"] = "text/plain" if fault == "wrong-content" else "application/json"
|
||||
if fault == "oversize-header":
|
||||
headers["Content-Length"] = str(boundary.MAX_RESPONSE_BYTES + 1)
|
||||
response = SimpleNamespace(status=503 if fault == "wrong-status" else 200, headers=headers)
|
||||
raw = b'{"data": {}}'
|
||||
if fault == "oversize-body":
|
||||
raw = b'x' * (boundary.MAX_RESPONSE_BYTES + 1)
|
||||
if fault == "malformed":
|
||||
raw = b'private-body'
|
||||
if fault == "wrong-shape":
|
||||
raw = b'{"data": []}'
|
||||
|
||||
class Response:
|
||||
status = response.status
|
||||
headers = response.headers
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def read(self, count):
|
||||
assert count == boundary.MAX_RESPONSE_BYTES + 1
|
||||
return raw
|
||||
|
||||
def opening(req, timeout):
|
||||
if fault in ("no-response", "unknown-error"):
|
||||
raise RuntimeError("private-body")
|
||||
return Response()
|
||||
monkeypatch.setattr(boundary, "build_opener", lambda *args: SimpleNamespace(open=opening))
|
||||
session = Session()
|
||||
session.client = SimpleNamespace(addr="http://127.0.0.1:18200", token="sentinel")
|
||||
plan = {"expected_policy": "secrets-engine-login-self", "denied_paths": list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)}
|
||||
result = boundary.verify_session(session, plan, lambda c,p,d=None: boundary.request_json(c,p,d,expected_addr=c.addr))
|
||||
assert not result["native_verified"]
|
||||
assert session.closed
|
||||
assert "private-body" not in str(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("location", ["http://127.0.0.1:9/private", "/relative"])
|
||||
def test_real_redirect_refused_and_proxy_environment_ignored(monkeypatch, location):
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
observed = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
observed.append(self.path)
|
||||
self.send_response(302)
|
||||
self.send_header("Location", location)
|
||||
self.end_headers()
|
||||
def log_message(self, *args): pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
# Invalid proxy would prevent the first request if environment proxies were used.
|
||||
monkeypatch.setenv("http_proxy", "http://127.0.0.1:1")
|
||||
monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:1")
|
||||
monkeypatch.setenv("no_proxy", "")
|
||||
monkeypatch.setenv("NO_PROXY", "")
|
||||
addr = f"http://127.0.0.1:{server.server_port}"
|
||||
session = Session()
|
||||
session.client = SimpleNamespace(addr=addr, token="synthetic-token-only")
|
||||
try:
|
||||
result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths": list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)},
|
||||
lambda c,p,d=None: boundary.request_json(c,p,d,expected_addr=addr))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join()
|
||||
assert observed == ["/v1/auth/token/lookup-self"]
|
||||
assert session.closed
|
||||
assert not result["native_verified"]
|
||||
|
||||
|
||||
def test_origin_mismatch_refused_before_request(monkeypatch):
|
||||
monkeypatch.setattr(boundary, "build_opener", lambda *args: pytest.fail("must not send token"))
|
||||
client = SimpleNamespace(addr="http://evil.invalid", token="sentinel")
|
||||
with pytest.raises(BackendError, match="response suppressed"):
|
||||
boundary.request_json(client, "auth/token/lookup-self", expected_addr="http://127.0.0.1:18200")
|
||||
|
||||
|
||||
def test_unknown_evidence_and_private_failure_not_published():
|
||||
session = Session()
|
||||
session.evidence = lambda: {"private_token": "private-body", "session_handle": "private-body", "secret_source": "private-body", "operator_revocation_succeeded": True, "revocation_succeeded": True}
|
||||
def fail(*args): raise RuntimeError("private-body")
|
||||
result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths": list(boundary.EXPECTED_GUARDS)}, fail)
|
||||
assert session.closed
|
||||
assert not result["native_verified"]
|
||||
assert "private-body" not in str(result)
|
||||
assert "private_token" not in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", list(boundary.SAFE_EVIDENCE))
|
||||
def test_every_contract_evidence_field_required(missing):
|
||||
session = Session()
|
||||
original = session.evidence
|
||||
def evidence():
|
||||
result = original()
|
||||
del result[missing]
|
||||
return result
|
||||
session.evidence = evidence
|
||||
paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)
|
||||
def request(client, path, payload=None):
|
||||
return {"policies": ["secrets-engine-login-self"]} if path == "auth/token/lookup-self" else {p:["deny"] for p in paths}
|
||||
result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths": paths}, request)
|
||||
assert session.closed
|
||||
assert not result["native_verified"]
|
||||
|
||||
|
||||
def test_revoke_exception_cannot_be_overridden_by_stale_evidence():
|
||||
session = Session()
|
||||
session.closed = True
|
||||
def close(): raise RuntimeError("private failure")
|
||||
session.close = close
|
||||
paths = list(boundary.EXPECTED_GUARDS + boundary.ADMIN_DENIES)
|
||||
def request(client, path, payload=None):
|
||||
return {"policies": ["secrets-engine-login-self"]} if path == "auth/token/lookup-self" else {p:["deny"] for p in paths}
|
||||
result = boundary.verify_session(session, {"expected_policy":"secrets-engine-login-self", "denied_paths":paths}, request)
|
||||
assert result["revocation_succeeded"] is True
|
||||
assert not result["native_verified"]
|
||||
assert "private failure" not in str(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("errors", [[], ["private backend failure"], None])
|
||||
def test_errors_and_data_response_never_accepted(monkeypatch, errors):
|
||||
import json
|
||||
from email.message import Message
|
||||
headers = Message()
|
||||
headers["Content-Type"] = "application/json"
|
||||
class Response:
|
||||
status = 200
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def read(self, count): return json.dumps({"data": {}, "errors": errors}).encode()
|
||||
response = Response()
|
||||
response.headers = headers
|
||||
monkeypatch.setattr(boundary, "build_opener", lambda *args: SimpleNamespace(open=lambda *a,**k: response))
|
||||
client = SimpleNamespace(addr="http://127.0.0.1:18200", token="synthetic-only")
|
||||
with pytest.raises(BackendError, match="response suppressed"):
|
||||
boundary.request_json(client, "auth/token/lookup-self", expected_addr=client.addr)
|
||||
194
tools/verify_service_jwt_boundary.py
Normal file
194
tools/verify_service_jwt_boundary.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Prepare or attend CCR-2026-0038 self-policy/capability acceptance.
|
||||
|
||||
Default is backend-free. --execute invokes existing attended in-memory custody;
|
||||
never print backend response bodies, secret values, tokens or full accessors.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
import yaml
|
||||
|
||||
from secrets_engine.config import Config
|
||||
from secrets_engine.engine_auth import keycape_config, load_jwt_login_contract, login_service_jwt
|
||||
from secrets_engine.errors import BackendError
|
||||
|
||||
ADMIN_DENIES = ("sys/policies/acl/*", "auth/*/role/*", "auth/token/create")
|
||||
EXPECTED_GUARDS = (
|
||||
"platform/data/workloads/keycape/secrets-engine-openbao",
|
||||
"platform/metadata/workloads/keycape/secrets-engine-openbao",
|
||||
)
|
||||
|
||||
|
||||
def prepare(cfg, ccr_path):
|
||||
raw = ccr_path.read_bytes()
|
||||
ccr = yaml.safe_load(raw)
|
||||
if not isinstance(ccr, dict) or ccr.get("id") != "CCR-2026-0038" or ccr.get("status") != "applied":
|
||||
raise BackendError("expected applied CCR-2026-0038")
|
||||
bao = ccr.get("openbao", {})
|
||||
auth = bao.get("auth", {})
|
||||
contract = load_jwt_login_contract(cfg)
|
||||
kcfg = keycape_config(cfg)
|
||||
if (contract.mount, contract.role, contract.bound_issuer) != (
|
||||
auth.get("mount"), auth.get("role"), kcfg.issuer
|
||||
):
|
||||
raise BackendError("login contract and issuer must match applied CCR")
|
||||
if bao.get("guarded_paths") != list(EXPECTED_GUARDS) or bao.get("policy_name") != "secrets-engine-login-self":
|
||||
raise BackendError("unexpected CCR guarded paths or self policy")
|
||||
return {
|
||||
"ccr_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"contract_sha256": hashlib.sha256(contract.path.read_bytes()).hexdigest(),
|
||||
"denied_paths": list(EXPECTED_GUARDS + ADMIN_DENIES),
|
||||
"expected_policy": "secrets-engine-login-self",
|
||||
}
|
||||
|
||||
|
||||
MAX_RESPONSE_BYTES = 65536
|
||||
SAFE_EVIDENCE = {
|
||||
"secret_source": str,
|
||||
"operator_session_handle": str,
|
||||
"session_handle": str,
|
||||
"established": bool,
|
||||
"operator_revocation_attempted": bool,
|
||||
"operator_revocation_succeeded": bool,
|
||||
"revocation_attempted": bool,
|
||||
"revocation_succeeded": bool,
|
||||
}
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise BackendError("redirect refused")
|
||||
|
||||
|
||||
def origin(url):
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/"):
|
||||
raise BackendError("invalid configured OpenBao origin")
|
||||
return (parsed.scheme, parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||
|
||||
|
||||
def request_json(client, path, payload=None, *, expected_addr=None):
|
||||
# expected_addr is captured from Config.load(), never inferred from a response.
|
||||
try:
|
||||
if expected_addr is None or origin(client.addr) != origin(expected_addr):
|
||||
raise BackendError("unexpected OpenBao origin")
|
||||
if path not in ("auth/token/lookup-self", "sys/capabilities-self"):
|
||||
raise BackendError("unexpected metadata endpoint")
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
req = Request(expected_addr.rstrip("/") + "/v1/" + path, data=data,
|
||||
headers={"X-Vault-Token": client.token, "Content-Type": "application/json"})
|
||||
# Disable environment proxies and all redirect following before supplying token.
|
||||
opener = build_opener(ProxyHandler({}), NoRedirect())
|
||||
with opener.open(req, timeout=30) as response:
|
||||
if response.status != 200 or response.headers.get_content_type() != "application/json":
|
||||
raise BackendError("unexpected metadata response")
|
||||
length = response.headers.get("Content-Length")
|
||||
if length is not None and (not length.isdecimal() or int(length) > MAX_RESPONSE_BYTES):
|
||||
raise BackendError("oversize metadata response")
|
||||
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
||||
if len(raw) > MAX_RESPONSE_BYTES:
|
||||
raise BackendError("oversize metadata response")
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, dict) or not isinstance(result.get("data"), dict) or "errors" in result:
|
||||
raise BackendError("malformed metadata response")
|
||||
return result["data"]
|
||||
except Exception:
|
||||
raise BackendError("boundary metadata request failed; response suppressed") from None
|
||||
|
||||
|
||||
def safe_evidence(session):
|
||||
result = {}
|
||||
try:
|
||||
evidence = session.evidence()
|
||||
if not isinstance(evidence, dict):
|
||||
return result
|
||||
for key, kind in SAFE_EVIDENCE.items():
|
||||
value = evidence.get(key)
|
||||
if type(value) is not kind:
|
||||
continue
|
||||
if kind is str:
|
||||
if key == "secret_source" and value != "openbao-oidc-in-memory":
|
||||
continue
|
||||
if key.endswith("handle") and not re.fullmatch(r"[0-9a-f]{12}", value):
|
||||
continue
|
||||
result[key] = value
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def verify_session(session, plan, request=request_json):
|
||||
"""Two metadata requests, then unconditional cleanup, within 8-use budget."""
|
||||
result = {**plan, "auth_provider": "service-jwt", "native_verified": False}
|
||||
cleanup_returned = False
|
||||
try:
|
||||
lookup = request(session.client, "auth/token/lookup-self")
|
||||
# Effective identity policies matter even when default policy is absent.
|
||||
policies = lookup.get("policies")
|
||||
identities = lookup.get("identity_policies", [])
|
||||
result["exact_self_policy"] = policies == [plan["expected_policy"]] and identities == []
|
||||
caps = request(session.client, "sys/capabilities-self", {"paths": plan["denied_paths"]})
|
||||
result["capability_denials"] = {path: caps.get(path) == ["deny"] for path in plan["denied_paths"]}
|
||||
except Exception:
|
||||
result["failure"] = "metadata verification failed; response suppressed"
|
||||
finally:
|
||||
try:
|
||||
session.close()
|
||||
cleanup_returned = True
|
||||
except Exception:
|
||||
result["failure"] = "service revocation failed"
|
||||
result.update(safe_evidence(session))
|
||||
evidence_complete = (
|
||||
result.get("secret_source") == "openbao-oidc-in-memory"
|
||||
and result.get("established") is True
|
||||
and result.get("operator_revocation_attempted") is True
|
||||
and result.get("operator_revocation_succeeded") is True
|
||||
and result.get("revocation_attempted") is True
|
||||
and result.get("revocation_succeeded") is True
|
||||
and all(isinstance(result.get(key), str) and re.fullmatch(r"[0-9a-f]{12}", result[key])
|
||||
for key in ("operator_session_handle", "session_handle"))
|
||||
)
|
||||
result["native_verified"] = bool(
|
||||
cleanup_returned and "failure" not in result and evidence_complete
|
||||
and result.get("exact_self_policy") and all(result.get("capability_denials", {}).values())
|
||||
and len(result.get("capability_denials", {})) == len(plan["denied_paths"])
|
||||
and result.get("operator_revocation_succeeded") and result.get("revocation_succeeded")
|
||||
)
|
||||
# These are deliberately separate from boundary acceptance.
|
||||
result["claim_negatives_proved"] = False
|
||||
result["protected_operation_adoption_proved"] = False
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--ccr", type=Path, required=True)
|
||||
parser.add_argument("--execute", action="store_true", help="attended live self metadata check; requires scoped authorization")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
cfg = Config.load()
|
||||
plan = prepare(cfg, args.ccr)
|
||||
result = verify_session(
|
||||
login_service_jwt(cfg), plan,
|
||||
lambda client, path, payload=None: request_json(client, path, payload, expected_addr=cfg.bao_addr),
|
||||
) if args.execute else {
|
||||
**plan, "mode": "backend-free-preparation", "native_verified": False,
|
||||
"claim_negatives_proved": False, "protected_operation_adoption_proved": False,
|
||||
}
|
||||
except Exception:
|
||||
print(json.dumps({"native_verified": False, "failure": "preflight/login failed; details suppressed"}))
|
||||
return 1
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0 if not args.execute or result["native_verified"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -332,7 +332,7 @@ Acceptance:
|
|||
id: SECRETS-WP-0008-T06
|
||||
status: wait
|
||||
depends_on: [RPF-WP-0035-T02]
|
||||
blocking_reason: "Positive service-JWT login proof passed 2026-10-10 (SECRETS-WP-0012-T03, docs/evidence/2026-10-10-keycape-openbao-probe.json). Waits on railiance-platform closing RPF-WP-0035-T02 with the CCR-2026-0038 negative checks, then on routing live engine actions through service-jwt instead of bootstrap/env."
|
||||
blocking_reason: "Published JWT contract and positive login/revocation are delivered. Native KEY-WP-0039-T02 claim negatives, CCR-2026-0038 effective-policy/capability/expiry/use checks and MASON-WP-0009-T05 remain. Login-self-only grants no protected-operation authority: agree and implement separately reviewed task-scoped backend execution entitlement, then prove functional steady-state service-identity adoption without bootstrap/env fallback."
|
||||
priority: medium
|
||||
state_hub_task_id: "d7bc8bdc-a0f8-5058-a640-374ef9859148"
|
||||
```
|
||||
|
|
@ -352,8 +352,10 @@ argv), then self-revokes. A JWT selection refuses `--bootstrap-token-file` and
|
|||
does not read `BAO_TOKEN` on failure. `--bootstrap-token-file` is a named
|
||||
break-glass provider with `auth_break_glass` evidence.
|
||||
|
||||
The platform-owned OpenBao JWT mount/role is still unpublished, so auto keeps
|
||||
bootstrap/env and this task remains `wait`.
|
||||
Historical 2026-09-02 state, superseded 2026-10-10: the platform-owned
|
||||
OpenBao JWT mount/role was then unpublished. The consumer contract is now
|
||||
published; native negative/capability checks and functional task-scoped backend
|
||||
execution adoption remain open, so T06 remains `wait`.
|
||||
|
||||
Inbox triage 2026-09-21. railiance-platform `29cccf8a` (2026-09-09): status,
|
||||
not a contract. Their side is RPF-WP-0035-T02 (design
|
||||
|
|
@ -549,3 +551,20 @@ T06 still depends on RPF-WP-0035-T02, whose current file remains wait for live
|
|||
platform service registration/custody and scoped JWT provisioning plus native
|
||||
login, denial and revocation evidence. Source tenant corrections are complete.
|
||||
The workplan is now blocked; no new task or workplan was created.
|
||||
|
||||
### 2026-10-11 final gate preparation
|
||||
|
||||
Completed preparation record: `docs/service-jwt-final-acceptance.md` and
|
||||
`tools/verify_service_jwt_boundary.py`; synthetic acceptance tests cover added
|
||||
identity policy, omitted deny, metadata failure, failed cleanup, redirects,
|
||||
environment proxies, bounded transport, complete typed evidence, stale cleanup
|
||||
claims and mutually exclusive backend error/data responses. The tool
|
||||
default is backend-free and cannot claim native success. It prepares the exact
|
||||
applied CCR capability batch and preserves the eight-use cleanup budget.
|
||||
|
||||
The actual CLI consumes approval before supplying its selected backend token.
|
||||
The current service token grants self endpoints only, and no delegated backend
|
||||
entitlement is implemented. Configuring auto now would not prove functional
|
||||
protected-action adoption. T06 stays wait; no provider default, policy, secret,
|
||||
live registration or owner acknowledgment changed. Existing live owner tasks
|
||||
retain all remaining acceptance and execution entitlement work.
|
||||
|
|
|
|||
65
workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md
Normal file
65
workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
id: SECRETS-WP-0013
|
||||
type: workplan
|
||||
title: "Design and prove task-scoped protected OpenBao execution"
|
||||
domain: infotech
|
||||
repo: secrets-engine
|
||||
status: proposed
|
||||
flavor: planning
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-11"
|
||||
updated: "2026-10-11"
|
||||
depends_on: []
|
||||
---
|
||||
|
||||
Source-staged draft only; no owner acceptance or native authority inferred.
|
||||
Existing SECRETS-WP-0008-T06 remains open for actual adoption. CCR38 is an
|
||||
unchanged identity-only login and is not a protected execution entitlement.
|
||||
|
||||
## Define the exact mediated execution contract
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0013-T01
|
||||
status: progress
|
||||
priority: high
|
||||
```
|
||||
|
||||
Design SECRETS-EXEC-20261011-v1 in docs/task-scoped-openbao-execution-contract.md
|
||||
binds action, target, payload/source digests, initiating identity, exact PDP/CAS,
|
||||
owner-held ephemeral two-path token, bounded TTL/uses, durable admission and
|
||||
cleanup. Independent Platform/Secrets Engine/identity/approval/PDP owner returns
|
||||
are required. No foreign review is written by this draft.
|
||||
|
||||
## Prevent consumption without execution readiness
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0013-T02
|
||||
status: progress
|
||||
priority: high
|
||||
```
|
||||
|
||||
Staged local fail-closed guard rejects service-jwt protected actions before
|
||||
approval consumption because no reviewed task-execution backend exists. Named
|
||||
bootstrap/env paths and login-self probes remain distinct. Eight new readiness
|
||||
tests plus 28 existing auth/consume/evidence checks pass (36 total). Owner review,
|
||||
publication and native checkout verification remain pending; not marked done.
|
||||
|
||||
## Implement and prove one native metadata action
|
||||
|
||||
```task
|
||||
id: SECRETS-WP-0013-T03
|
||||
status: wait
|
||||
depends_on: [SECRETS-WP-0013-T01, RPF-WP-0035-T12]
|
||||
wait_reason: "Wait for independent execution-contract acceptance and Platform task-scoped entitlement design/materialization, then exact founder native go."
|
||||
priority: high
|
||||
```
|
||||
|
||||
After T01 independent owner acceptance, implement the narrow execute-ccr surface,
|
||||
Platform task-grant mediator, bound policy package and admission ledger. Select
|
||||
an actual unexecuted approved metadata CCR, exact founder go and attended owner
|
||||
custody. Prove real protected effect, sibling/body/refusal/replay counterexamples
|
||||
and confirmed revocation/cleanup. No applied CCR38 replay or broad default flip.
|
||||
Native metadata proof does not certify provision/exec delivery or all T06 scope.
|
||||
|
||||
2026-10-11 source revision v2: qualified T03 dependencies point to the owning design and Platform execution-entitlement task. WP13 owns the missing task-scoped execution seam; WP8-T06 retains production adoption and its full workflow proof. Source publication and fixture checks do not imply native acceptance. Original v1 stage remains unchanged.
|
||||
Loading…
Add table
Add a link
Reference in a new issue