diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md new file mode 100644 index 0000000..5b3c63d --- /dev/null +++ b/docs/task-scoped-openbao-execution-contract.md @@ -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 --stage prod --request ` +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. diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index e46cee2..d60988d 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -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) diff --git a/src/secrets_engine/engine_auth.py b/src/secrets_engine/engine_auth.py index 7011457..af33aad 100644 --- a/src/secrets_engine/engine_auth.py +++ b/src/secrets_engine/engine_auth.py @@ -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 diff --git a/tests/test_execution_readiness.py b/tests/test_execution_readiness.py new file mode 100644 index 0000000..2dc1ca4 --- /dev/null +++ b/tests/test_execution_readiness.py @@ -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 == [] diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md new file mode 100644 index 0000000..057a668 --- /dev/null +++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md @@ -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.