diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md index 5b3c63d..cfc9bb2 100644 --- a/docs/task-scoped-openbao-execution-contract.md +++ b/docs/task-scoped-openbao-execution-contract.md @@ -47,8 +47,10 @@ 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 +Use `authorization.build_action_request(entry, 'execute-ccr', ...)` with the +existing canonical digest rules. `apply` is a distinct legacy action: its v2 +policy ignores this metadata contract and must never stand in for execute-ccr. +Add a reviewed `context.execution_contract` object before observing the claim or calling Check. Its fields are: | Field | Binding | @@ -246,3 +248,66 @@ 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. + +## Source preparation: typed request propagation (2026-10-11) + +The staged ExecutionContract descriptor is deliberately a closed, immutable +non-secret value. `parse` validates shape and captures JSON; every `as_dict` +revalidates even directly constructed instances and returns a detached copy. +It is not an owner approval, source authenticity attestation, principal +representation or proof that the requested origin is admitted. Compiler and +mediator must independently reconstruct and authenticate sources and bodies. + +The exact proposed descriptor serialization refines the table above: + +- Digests use `sha256:` plus 64 lowercase hexadecimal characters. UUIDs use + canonical lowercase hyphenated form. CCR and grant IDs must be distinct. +- `source_bundle` has exactly engine_dispatcher, platform_mediator, typed_ccr and + policy_auth; each has exactly an absolute normalized `path` and `sha256`. + This parses paths without reading files; pin verification remains unimplemented. +- `operations` is an ordered list of exactly method/path/payload_sha256 objects. + It accepts only GET/PUT metadata policy and auth-role endpoint shapes, with + normalized relative paths. This schema does not reconstruct bodies or prove + that all before/write/readback operations occur or target exactly two paths. + A later owner compiler must enforce those semantics before any execution. +- `backend_origin` is an explicit-port HTTP(S) origin without credentials, path, + query or fragment. Syntactic acceptance creates no approved route or network + call. Estate pinning and server authentication remain mediator obligations. +- `backend_budget` has exactly ttl_seconds (integer 30–120) and uses (integer + 1–12); booleans fail. Listed calls must leave at least one cleanup use. + Renewal, remint and live per-call accounting remain outside this descriptor. +- expected_before/expected_after are non-secret state digests; operation_deadline + is timezone-aware. Before claim, refuse expired deadlines using the existing + trusted-time window. After claim and Check, refuse a deadline later than either + served approval or decision expiry. A future mediator must recheck before every + effect and provide separately authorized cleanup after business expiry. +- catalog_sha256 is a descriptor-local sorted compact JSON fingerprint of the + complete existing context.catalog_target, distinct from canonical PDP digests. + Resource catalog ID/stage, subject, tenant and purpose remain bound separately + through the unchanged canonical request hashing implementation. + +The same typed descriptor passes through build_action_request, _expected_request, +resolve_approval_observation and authorize_action. Any contract on apply or another +legacy action refuses. execute-ccr without a typed valid contract, prod stage or +platform tenant refuses. execute-ccr always binds human_control=true, even on a +legacy bootstrap catalog, and cannot use an unserved/demo fallback. Invalid or +catalog-mismatched descriptors refuse before claim/PDP/CAS. Returned requests are +independent copies; later caller mutation cannot change the stored descriptor. + +99 focused source tests pass across context propagation, the existing approval +join, decision validation, readiness, consume and engine auth. New tests exercise +all four actual builders, actual consume request formatting and claim-free/full +joins; mutation of operation, CCR/grant/source/body/method/path/budget/deadline/ +estate/state/principal/purpose/catalog/tenant/stage refuses the original approval. +Transport/PDP stand-ins deliberately supply hypothetical human control and an +ALLOW: they prove propagation, not a shipped policy or real principal admission. + +No execute-ccr CLI, identity-owner verified initiating-principal interface, +new access policy package, typed owner compiler, task-grant materializer, +independent authenticated consume-confirmation integration, durable admission +journal, restricted backend, per-call deadline checks or native entitlement is +supplied by this stage. Existing cfg subject strings remain legacy provenance; +this change does not make them verified human representation. Existing generic +consume receipt validation limitations remain explicitly separate. service-jwt +readiness guard/default and ordinary actions remain unchanged. T01 stays in +progress, T03 stays wait, WP13 stays proposed, and WP8-T06 stays open. diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index e428dfe..cfe1aac 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -19,6 +19,7 @@ from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen +from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim from secrets_engine.catalog import human_control_required from secrets_engine.decision_check import check_decision @@ -148,6 +149,7 @@ def _expected_request( fields: tuple[str, ...] = (), policy_targets: tuple[str, ...] = (), auth_targets: tuple[str, ...] = (), + execution_contract: ExecutionContract | None = None, ) -> dict[str, Any]: """Build the exact CheckRequest both steps must agree on. @@ -175,6 +177,7 @@ def _expected_request( fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, + execution_contract=execution_contract, ) @@ -230,6 +233,7 @@ def resolve_approval_observation( fields: tuple[str, ...] = (), policy_targets: tuple[str, ...] = (), auth_targets: tuple[str, ...] = (), + execution_contract: ExecutionContract | None = None, opener: Callable[..., Any] | None = None, ) -> ApprovalObservation | None: """Observe a fresh fact and prepare the claim-bearing request (step 1). @@ -238,20 +242,30 @@ def resolve_approval_observation( human control raises instead, so no stage/demo fallback can discharge it. Correspondence and a consume binding require authorize_action's PDP step. """ + execution_request = None + if action == "execute-ccr" or execution_contract is not None: + execution_request = _expected_request( + cfg, entry, action, fields=fields, policy_targets=policy_targets, + auth_targets=auth_targets, execution_contract=execution_contract, + ) + # The caller's descriptor is not authenticated principal/owner evidence. + # This requirement cannot disappear on an unserved/demo lane. base_url = str(getattr(cfg, "approval_url", "") or "") auth_configured = approval_auth_configured(cfg) authorization_id = _authorization_id(entry, decision) - required_human_control = human_control_required(entry) + required_human_control = action == "execute-ccr" or human_control_required(entry) if not base_url or not auth_configured or not authorization_id: if required_human_control: raise DecisionError("human_control requires a served approval claim and exact action binding") return None - expected_request = _expected_request( + expected_request = execution_request or _expected_request( cfg, entry, action, fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, + execution_contract=execution_contract, ) + require_execution_deadline(expected_request, time_window=read_window(cfg)) claim = fetch_approval_claim( base_url=base_url, token_provider=lambda: approval_token(cfg, scope="approval:read"), @@ -260,6 +274,8 @@ def resolve_approval_observation( ) observe_pdp_approval_claim(claim, approval_id=authorization_id, require_human_control=required_human_control, time_window=read_window(cfg)) + require_execution_deadline(expected_request, time_window=read_window(cfg), + bounds=(claim["validity"]["expires_at"],)) expected_request["context"]["approval"] = claim return ApprovalObservation(authorization_id, expected_request, claim) @@ -273,6 +289,7 @@ def authorize_action( fields: tuple[str, ...] = (), policy_targets: tuple[str, ...] = (), auth_targets: tuple[str, ...] = (), + execution_contract: ExecutionContract | None = None, opener: Callable[..., Any] | None = None, pdp_opener: Callable[..., Any] | None = None, ) -> AuthorizedAction | None: @@ -288,6 +305,7 @@ def authorize_action( fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, + execution_contract=execution_contract, opener=opener, ) if observation is None: @@ -334,6 +352,8 @@ def authorize_action( require_human_control=expected_request["context"].get("human_control") is True, time_window=window, ) + require_execution_deadline(expected_request, time_window=window, + bounds=(observation.claim["validity"]["expires_at"], validated.expires_at)) if validated.action != action: raise DecisionError("access-engine decision does not bind this action") return AuthorizedAction( diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index 398b3e2..312ea54 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -18,6 +18,7 @@ from typing import Any from secrets_engine.catalog import CatalogEntry, human_control_required from secrets_engine.errors import DecisionError +from secrets_engine.execution_contract import ExecutionContract, attach_execution_contract from secrets_engine.application_time import validity_bounds DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") @@ -67,6 +68,7 @@ def build_action_request( auth_targets: list[str] | tuple[str, ...] = (), request_id: str = "", tenant: str = REQUEST_TENANT, + execution_contract: ExecutionContract | None = None, ) -> dict[str, Any]: """Build the exact normalized secrets-engine profile for flex-auth. @@ -121,6 +123,7 @@ def build_action_request( }, } ) + attach_execution_contract(request, execution_contract) if human_control_required(entry): # Bind the requested control to the exact PDP submission. This is a # requirement on the observed approval, not an authorization verdict. diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py new file mode 100644 index 0000000..5dc1cae --- /dev/null +++ b/src/secrets_engine/execution_contract.py @@ -0,0 +1,176 @@ +"""Closed, immutable metadata descriptor; shape is never execution authority. + +This source-only compiler input cannot grant an action, authenticate a principal, +prove owner source authenticity or open a backend. Those integration gates remain +outside this type. Existing actions cannot borrow the new descriptor. +""" +from __future__ import annotations + +import copy +import hashlib +import json +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import PurePosixPath +from urllib.parse import urlsplit +from uuid import UUID + +from secrets_engine.errors import DecisionError + +VERSION = "SECRETS-EXEC-20261011-v1" +HASH = re.compile(r"^sha256:[0-9a-f]{64}$") +KEYS = { + "contract_version", "operation_id", "operation", "execution_actor", "ccr_id", + "ccr_immutable_sha256", "execution_grant_ccr_id", "grant_immutable_sha256", + "catalog_sha256", "source_bundle", "backend_origin", "cluster_uid", + "operations", "operation_deadline", "backend_budget", "expected_before", + "expected_after", +} +SOURCE_ROLES = {"engine_dispatcher", "platform_mediator", "typed_ccr", "policy_auth"} + + +def _refuse() -> None: + # Do not interpolate untrusted descriptors into messages/evidence. + raise DecisionError("invalid execution_contract descriptor") + + +def _closed(value, keys): + if type(value) is not dict or set(value) != set(keys): + _refuse() + return value + + +def _text(value): + if type(value) is not str or not value or len(value) > 4096 or any(ord(c) < 32 for c in value): + _refuse() + return value + + +def _hash(value): + if type(value) is not str or not HASH.fullmatch(value): + _refuse() + + +def _path(value, *, absolute=False): + _text(value) + if (value.startswith("/") != absolute or "\\" in value or "%" in value + or "?" in value or "#" in value or ":" in value + or any(p in {"", ".", ".."} for p in value.lstrip("/").split("/")) + or str(PurePosixPath(value)) != value): + _refuse() + + +def _validated(value): + value = copy.deepcopy(_closed(value, KEYS)) + if value['contract_version'] != VERSION or value['operation'] != 'metadata-apply-ccr' or value['execution_actor'] != 'service:secrets-engine': + _refuse() + try: + if str(UUID(_text(value['operation_id']))) != value['operation_id']: + _refuse() + except (ValueError, AttributeError): + _refuse() + for key in ['ccr_id', 'execution_grant_ccr_id', 'cluster_uid']: + _text(value[key]) + if value['ccr_id'] == value['execution_grant_ccr_id']: + _refuse() + for key in ['ccr_immutable_sha256', 'grant_immutable_sha256', 'catalog_sha256', 'expected_before', 'expected_after']: + _hash(value[key]) + sources = _closed(value['source_bundle'], SOURCE_ROLES) + for source in sources.values(): + _closed(source, {'path', 'sha256'}) + _path(source['path'], absolute=True) + _hash(source['sha256']) + try: + origin = urlsplit(_text(value['backend_origin'])) + if (origin.scheme not in {'http', 'https'} or not origin.hostname + or origin.username is not None or origin.password is not None + or origin.path or origin.query or origin.fragment or origin.port is None): + _refuse() + except ValueError: + _refuse() + try: + deadline = datetime.fromisoformat(_text(value['operation_deadline']).replace('Z', '+00:00')) + if deadline.tzinfo is None: + _refuse() + except ValueError: + _refuse() + budget = _closed(value['backend_budget'], {'ttl_seconds', 'uses'}) + if (type(budget['ttl_seconds']) is not int or not 30 <= budget['ttl_seconds'] <= 120 + or type(budget['uses']) is not int or not 1 <= budget['uses'] <= 12): + _refuse() + operations = value['operations'] + if type(operations) is not list or not 1 <= len(operations) < budget['uses']: + _refuse() # Reserve at least one cleanup use; no silent retries. + for operation in operations: + _closed(operation, {'method', 'path', 'payload_sha256'}) + if type(operation['method']) is not str or operation['method'] not in {'GET', 'PUT'}: + _refuse() + _path(operation['path']) + # Only metadata pilot endpoint shapes, never KV/mount/token/secretid. + if not re.fullmatch(r'(sys/policies/acl/[A-Za-z0-9_-]+|auth/[A-Za-z0-9_-]+/role/[A-Za-z0-9_-]+)', operation['path']): + _refuse() + _hash(operation['payload_sha256']) + return value + + +def catalog_target_digest(target: dict) -> str: + """Descriptor-local sorted JSON fingerprint, distinct from PDP digest.""" + encoded = json.dumps(target, sort_keys=True, ensure_ascii=False, allow_nan=False, + separators=(',', ':')).encode('utf-8') + return 'sha256:' + hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class ExecutionContract: + _serialized: str + + @classmethod + def parse(cls, descriptor: dict) -> 'ExecutionContract': + validated = _validated(descriptor) + return cls(json.dumps(validated, sort_keys=True, separators=(',', ':'), allow_nan=False)) + + def as_dict(self) -> dict: + # Revalidate even a directly constructed instance: no trusted label bypass. + try: + return _validated(json.loads(self._serialized)) + except (ValueError, TypeError, RecursionError): + _refuse() + + +def attach_execution_contract(request: dict, contract: ExecutionContract | None) -> None: + action = request['action'] + if action != 'execute-ccr': + if contract is not None: + raise DecisionError('execution_contract requires execute-ccr action') + return + if type(contract) is not ExecutionContract: + raise DecisionError('execute-ccr requires typed execution_contract') + value = contract.as_dict() + if request['resource']['attributes']['stage'] != 'prod': + raise DecisionError('execute-ccr requires prod stage') + if request['tenant'] != 'tenant:platform': + raise DecisionError('execute-ccr requires platform tenant') + if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']): + raise DecisionError('execution_contract catalog target does not match') + request['context']['execution_contract'] = value + request['context']['human_control'] = True + + +def require_execution_deadline(request: dict, *, time_window=None, bounds=()) -> None: + """Check trusted-time freshness and served upper bounds before CAS binding.""" + contract = request.get('context', {}).get('execution_contract') + if contract is None: + return + from secrets_engine.application_time import validity_bounds + deadline = datetime.fromisoformat(contract['operation_deadline'].replace('Z', '+00:00')) + _, upper = validity_bounds(None, time_window) + if upper >= deadline: + raise DecisionError('execution_contract deadline has expired') + for bound in bounds: + try: + limit = datetime.fromisoformat(bound.replace('Z', '+00:00')) + if limit.tzinfo is None or deadline > limit: + raise DecisionError('execution_contract deadline exceeds served authority') + except (ValueError, TypeError, AttributeError): + raise DecisionError('execution_contract requires served deadline') from None diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py new file mode 100644 index 0000000..263021c --- /dev/null +++ b/tests/test_execution_context.py @@ -0,0 +1,197 @@ +"""Source-only builder tests; mocks confer no real grant or principal admission.""" +import copy +from dataclasses import replace +import json +from datetime import datetime, timedelta, timezone + +import pytest + +from secrets_engine.approval_consume import consume_approval, _expected_request, authorize_action, resolve_approval_observation +from secrets_engine.authorization import approval_binding_digest, build_action_request, request_digest +from secrets_engine.catalog import validate_entry +from secrets_engine.errors import DecisionError +from secrets_engine.execution_contract import ExecutionContract, catalog_target_digest +from tests.test_catalog import VALID +from tests.test_consume_binding_join import _Cfg, _token, _opener, AUTH_ID +from tests.authorization_stub import AuthorizationStub + +H = 'sha256:' + 'a' * 64 + + +def entry(): + raw = copy.deepcopy(VALID) + raw['stage'] = 'prod' + raw['approval'] = {'model': 'bootstrap-only', 'authorization_id': AUTH_ID, 'purpose': 'metadata-test'} + return validate_entry(raw) + + +def descriptor(e): + baseline = build_action_request(e, 'apply', subject_id='secrets-engine', subject_type='service', purpose='metadata-test') + return { + 'contract_version': 'SECRETS-EXEC-20261011-v1', 'operation_id': 'cbfbced0-09d5-48c8-a1e7-a2ea4dc69153', + 'operation': 'metadata-apply-ccr', 'execution_actor': 'service:secrets-engine', + 'ccr_id': 'CCR-2099-0001', 'ccr_immutable_sha256': H, 'execution_grant_ccr_id': 'CCR-2099-0002', + 'grant_immutable_sha256': H, 'catalog_sha256': catalog_target_digest(baseline['context']['catalog_target']), + 'source_bundle': {name: {'path': '/owner/'+name+'.py', 'sha256': H} + for name in ['engine_dispatcher', 'platform_mediator', 'typed_ccr', 'policy_auth']}, + 'backend_origin': 'http://127.0.0.1:18200', 'cluster_uid': 'test-estate-not-live', + 'operations': [{'method': 'GET', 'path': 'sys/policies/acl/test-policy', 'payload_sha256': H}, + {'method': 'PUT', 'path': 'auth/netkingdom/role/test-role', 'payload_sha256': H}], + 'operation_deadline': (datetime.now(timezone.utc)+timedelta(seconds=60)).isoformat(), + 'backend_budget': {'ttl_seconds': 120, 'uses': 12}, 'expected_before': H, 'expected_after': H, + } + + +def request(e, contract): + return build_action_request(e, 'execute-ccr', subject_id='secrets-engine', subject_type='service', + purpose='metadata-test', execution_contract=contract) + + +def served(pdp): + claim = pdp.claim() + claim['binding']['human_control'] = True + return claim + + +def test_four_builders_preserve_context_and_binding(tmp_path): + e = entry(); raw = descriptor(e); contract = ExecutionContract.parse(raw) + cfg = _Cfg(_token(tmp_path), authorization_subject_id='secrets-engine', authorization_subject_type='service') + expected = request(e, contract) + assert expected['context']['human_control'] is True + assert _expected_request(cfg,e,'execute-ccr',execution_contract=contract) == expected + pdp = AuthorizationStub(approval_id=AUTH_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version) + pdp.bind_request(expected); claim = served(pdp) + observed = resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=contract,opener=_opener(claim)) + assert observed.request['context']['execution_contract'] == raw + assert approval_binding_digest(observed.request) == request_digest(expected) + assert request_digest(observed.request) != request_digest(expected) + submitted=[] + def check(req, timeout=None): + value=json.loads(req.data); submitted.append(value) + return _opener(pdp.decision(value))(req) + authorized = authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(claim),pdp_opener=check) + assert authorized.binding.request_digest == request_digest(submitted[0]) + assert submitted[0] == observed.request + from tests.test_approval_consume import _Response + consume_bodies = [] + def consume(req, timeout=None): + value = json.loads(req.data); consume_bodies.append(value) + return _Response(200, {'status':'consumed', 'approval_id':AUTH_ID, + 'request_digest':value['request_digest'], 'decision_id':value['decision_id'], + 'consumed_at':datetime.now(timezone.utc).isoformat(), 'idempotent':False}) + confirmed = consume_approval(base_url=cfg.approval_url, token_file=cfg.approval_token_file, + binding=authorized.binding, opener=consume) + assert consume_bodies == [{'request_digest':request_digest(submitted[0]), 'decision_id':authorized.decision_id}] + assert confirmed.request_digest != request_digest(expected) + raw['operations'][0]['path']='sys/policies/acl/changed' + observed.request['context']['execution_contract']['source_bundle']['typed_ccr']['path']='/changed.py' + assert contract.as_dict()['operations'][0]['path']=='sys/policies/acl/test-policy' + assert contract.as_dict()['source_bundle']['typed_ccr']['path']=='/owner/typed_ccr.py' + + +@pytest.mark.parametrize('change', ['operation_id','ccr','grant','source','body','method','path','budget','deadline','state','cluster']) +def test_changed_binding_cannot_use_original_claim(tmp_path,change): + e=entry(); original=descriptor(e); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') + pdp=AuthorizationStub(approval_id=AUTH_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) + pdp.bind_request(request(e,ExecutionContract.parse(original))); claim=served(pdp) + changed=copy.deepcopy(original) + if change=='operation_id': changed[change]='775ab8f4-4731-47c8-bd98-342af22885dd' + elif change=='ccr': changed['ccr_immutable_sha256']='sha256:'+'b'*64 + elif change=='grant': changed['grant_immutable_sha256']='sha256:'+'b'*64 + elif change=='source': changed['source_bundle']['typed_ccr']['sha256']='sha256:'+'b'*64 + elif change=='body': changed['operations'][1]['payload_sha256']='sha256:'+'b'*64 + elif change=='method': changed['operations'][0]['method']='PUT' + elif change=='path': changed['operations'][0]['path']='sys/policies/acl/changed' + elif change=='budget': changed['backend_budget']['uses']=11 + elif change=='deadline': changed['operation_deadline']=(datetime.now(timezone.utc)+timedelta(seconds=50)).isoformat() + elif change=='state': changed['expected_before']='sha256:'+'b'*64 + elif change=='cluster': changed['cluster_uid']='different-estate' + typed=ExecutionContract.parse(changed) + assert approval_binding_digest(request(e,typed)) != pdp._pdp_digest + def check(req,timeout=None): return _opener(pdp.decision(json.loads(req.data)))(req) + with pytest.raises(DecisionError,match='digest'): + authorize_action(cfg,e,'execute-ccr',execution_contract=typed,opener=_opener(claim),pdp_opener=check) + + +@pytest.mark.parametrize('mutation', ['unknown','extra_nested','missing','wrong_actor','boolean_budget','oversize_budget','sibling_endpoint','path_traversal','source_relative','wrong_hash','bad_deadline','method_object']) +def test_closed_schema_refuses(mutation): + raw=descriptor(entry()) + if mutation=='unknown': raw['approved']=True + elif mutation=='extra_nested': raw['backend_budget']['renewable']=True + elif mutation=='missing': del raw['source_bundle']['typed_ccr'] + elif mutation=='wrong_actor': raw['execution_actor']='user:admin' + elif mutation=='boolean_budget': raw['backend_budget']['uses']=True + elif mutation=='oversize_budget': raw['backend_budget']['ttl_seconds']=121 + elif mutation=='sibling_endpoint': raw['operations'][0]['path']='secret/data/x' + elif mutation=='path_traversal': raw['operations'][0]['path']='auth/a/role/../x' + elif mutation=='source_relative': raw['source_bundle']['typed_ccr']['path']='foo.py' + elif mutation=='wrong_hash': raw['expected_after']='unsafe' + elif mutation=='bad_deadline': raw['operation_deadline']='2026-10-11T10:00:00' + elif mutation=='method_object': raw['operations'][0]['method']={} + with pytest.raises(DecisionError): ExecutionContract.parse(raw) + + +@pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr']) +def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action): + e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[] + def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError + contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e)) + with pytest.raises(DecisionError): + authorize_action(cfg,e,action,execution_contract=contract,opener=fetch,pdp_opener=fetch) + assert calls==[] + + +def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path') + cfg=_Cfg(_token(tmp_path)); calls=[] + with pytest.raises(DecisionError,match='catalog'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim')) + assert not calls + e=entry(); cfg.approval_url='' + with pytest.raises(DecisionError,match='human_control'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=ExecutionContract.parse(descriptor(e))) + + +def test_expired_contract_refuses_before_claim(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat() + cfg=_Cfg(_token(tmp_path)); calls=[] + with pytest.raises(DecisionError,match='expired'): + authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim')) + assert not calls + + +@pytest.mark.parametrize('changed', ['subject', 'purpose', 'catalog', 'tenant', 'stage']) +def test_request_correspondence_changes_refuse_before_authorization(tmp_path, changed): + e = entry(); contract = ExecutionContract.parse(descriptor(e)) + cfg = _Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') + pdp = AuthorizationStub(approval_id=AUTH_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) + pdp.bind_request(request(e,contract)); claim = served(pdp) + if changed == 'subject': cfg.authorization_subject_id = 'foreign-service' + elif changed == 'purpose': e = replace(e,approval={**e.approval, 'purpose':'different-purpose'}) + elif changed == 'catalog': e = replace(e,path='changed/path') + elif changed == 'stage': e = replace(e,stage='test') + calls = [] + def check(req,timeout=None): + calls.append('check'); return _opener(pdp.decision(json.loads(req.data)))(req) + with pytest.raises(DecisionError): + if changed == 'tenant': + build_action_request(e,'execute-ccr',subject_id='secrets-engine',subject_type='service',purpose='metadata-test',tenant='tenant:foreign',execution_contract=contract) + else: + authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(claim),pdp_opener=check) + if changed in {'catalog','stage','tenant'}: assert not calls + + +def test_raw_dictionary_and_direct_invalid_type_cannot_bypass_validation(): + e = entry() + with pytest.raises(DecisionError): request(e,descriptor(e)) + with pytest.raises(DecisionError): request(e,ExecutionContract('{}')) + + +def test_deadline_beyond_claim_refuses_before_pdp(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)+timedelta(hours=2)).isoformat() + contract=ExecutionContract.parse(raw); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') + pdp=AuthorizationStub(approval_id=AUTH_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) + pdp.bind_request(request(e,contract)); calls=[] + with pytest.raises(DecisionError,match='exceeds served authority'): + authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(served(pdp)),pdp_opener=lambda *a,**k:calls.append('check')) + assert not calls diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md index 59cac46..6021c72 100644 --- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md @@ -35,6 +35,16 @@ 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. +2026-10-11 source-only preparation: typed execution_contract is staged through +all four request builders. The new descriptor is restricted to execute-ccr, +requires prod/platform/human control and binds the catalog fingerprint before +claim. Existing apply/other actions cannot borrow this descriptor. 99 focused +source checks verify propagation, canonical approval/full consume joins, +immutable copies and changed-binding refusals. Detailed schema/limitations are +in the execution-contract document. No source owner assent, initiating-principal +admission, new policy package, ledger/mediator/backend or native acceptance is +claimed. Independent source review remains required. Task statuses are unchanged. + ## Prevent consumption without execution readiness ```task