602 lines
38 KiB
Diff
602 lines
38 KiB
Diff
diff --git a/docs/task-execution-grant-projection.md b/docs/task-execution-grant-projection.md
|
||
new file mode 100644
|
||
index 0000000..b5c7647
|
||
--- /dev/null
|
||
+++ b/docs/task-execution-grant-projection.md
|
||
@@ -0,0 +1,85 @@
|
||
+# Proposed task-execution-grant immutable projection
|
||
+
|
||
+RPF-WP-0035-T12 source preparation, 2026-10-16. This proposes and implements the
|
||
+owner's pure projection/binding code; it approves no grant and registers no native
|
||
+or legacy delegated applier. Existing production readiness remains refused.
|
||
+
|
||
+Use the existing `task-execution-grant` CCR type proposed by Platform and
|
||
+SECRETS-WP-0013. Use the existing execution descriptor's
|
||
+`execution_grant_ccr_id` and `grant_immutable_sha256` join. No second consumer
|
||
+execution descriptor, authorization token or broker is introduced.
|
||
+
|
||
+`scripts/task_execution_grant.py` defines a closed CCR root with existing identity,
|
||
+status/date/requester/review/target fields, effectful `execution` and `openbao`
|
||
+objects, and an explicitly typed evidence event list. `immutable_digest` uses
|
||
+SHA256 over sorted compact ASCII JSON of the complete validated projection.
|
||
+It includes status, updated, title, requester, review requirements/approvers,
|
||
+target coordinates, all execution inputs, generated HCL, token body and cleanup.
|
||
+It excludes exactly `review.comments` and `evidence.events` by replacing those
|
||
+lists with empty lists. No generic evidence-prefix stripping or caller-selected
|
||
+exclusion exists. Unknown root/review/evidence fields refuse. Fields nested in
|
||
+execution/openbao remain fully hashed and must exactly match independently
|
||
+reconstructed effects when bound. Projection alone is not semantic admission.
|
||
+
|
||
+Reviews have exactly at/reviewer/decision/comment; evidence events have exactly
|
||
+at/kind/result/reference_sha256. These append-only annotations cannot supply a
|
||
+runtime approval or become policy inputs. `require_append_only` independently
|
||
+checks that each old list is an exact prefix of the new list and that the
|
||
+immutable projection is unchanged. Removal, replacement and reordering refuse.
|
||
+The owner must use known history when checking an update; a current file cannot
|
||
+prove its own history. Updating `updated` or status changes the digest and requires
|
||
+a new exact join. There is no guessed exclusion for convenience.
|
||
+
|
||
+The compiler's optional independently installed `OwnerConfig.grant_filename`
|
||
+selects one different CCR file under credential-change-requests. It adds exactly
|
||
+that record and the pure validator source to the closed owner source-pin set.
|
||
+The compiler reads verified bytes and executes the pinned validator source directly,
|
||
+without a module-cache substitute. It reconstructs the entire effectful grant:
|
||
+
|
||
+- The existing descriptor, excluding only its own `grant_immutable_sha256` to avoid
|
||
+ self-reference, plus action/catalogue and the actual owner source pins. The
|
||
+ record's own raw file hash is omitted from its own source-pin projection; all
|
||
+ other source hashes, including the validator, remain included.
|
||
+- Deterministic `cleanup_specs(operation_id)`, its fixed private credential slots,
|
||
+ exact revoke/verification and delete/verification paths/methods, declared
|
||
+ no-issue/no-create branches, one reserved cleanup use and the cleanup obligation
|
||
+ after business expiry.
|
||
+- Exact operation-specific task policy HCL on the two independently compiled
|
||
+ metadata endpoints plus revoke-self, and the exact parented service token body:
|
||
+ nonrenewable, no default policies, no identity policies, 30–120s TTL and twelve
|
||
+ uses. No caller HCL, body, handle, wildcard or mount administration is accepted.
|
||
+
|
||
+It compares those structures with the owner record and compares the resulting
|
||
+immutable SHA and grant ID with the existing descriptor fields before returning
|
||
+a bound Plan. Unknown or changed effectful fields fail equality even if the caller
|
||
+re-pins the record and recomputes its digest. The complete descriptor must still
|
||
+match independently installed owner descriptor bytes. Raw source file hashes and
|
||
+immutable hashes serve different purposes: even an annotation-only file edit
|
||
+requires owner source-pin refresh and Plan reconstruction; it does not silently
|
||
+change the grant immutable digest or restore an admission.
|
||
+
|
||
+The disposable native adapter now refuses missing/unbound or proposed grants,
|
||
+rebuilds the exact bound Plan from pinned owner sources at construction, checks
|
||
+all source pins immediately before each native request, reconstructs each actual
|
||
+metadata body via the compiler and compares actual token/ACL bodies with the bound
|
||
+projection before issuing them. It still requires the same live postconsume
|
||
+journal admission and the live owner-spawned disposable OpenBao process handle.
|
||
+Native fault tests explicitly use synthetic approved copies in temporary directories;
|
||
+no real owner CCR status, policy or grant is created or changed.
|
||
+
|
||
+The normal credential-change CLI deliberately continues to reject this type for
|
||
+execution. Adding it to LANE_MODULES would expose generic applier methods and is
|
||
+not part of this source preparation. The pure validator is actually called by
|
||
+the compiler/native fixture path; it is not a paper-only schema. An owner can
|
||
+review this projection and later integrate source validation/recording into the
|
||
+CCR tooling without enabling execution.
|
||
+
|
||
+Remaining production work is now specific: approve/adopt this owner CCR projection;
|
||
+verify owner source/grant admission and immutable target CCR semantics; integrate
|
||
+authenticated principal, served exact claim/PDP/package and independent signed
|
||
+consume; finish total native baseline/readback normalization/conflict control;
|
||
+install bounded isolated owner runtime, trusted served deadlines/use accounting,
|
||
+audit and protected recovery; then run the exact founder-scoped attended trial.
|
||
+Neither an approved string nor this projection authenticates human approval.
|
||
+Approval replay prevention and uncertain backend-effect reconciliation remain
|
||
+separate. T12 and Secrets WP13 stay open.
|
||
diff --git a/scripts/task_execution_grant.py b/scripts/task_execution_grant.py
|
||
new file mode 100644
|
||
index 0000000..ad5ae87
|
||
--- /dev/null
|
||
+++ b/scripts/task_execution_grant.py
|
||
@@ -0,0 +1,115 @@
|
||
+"""Proposed task-execution-grant CCR immutable projection; no native admission.
|
||
+
|
||
+The only excluded paths are typed append-only review.comments and evidence.events.
|
||
+Status, updated, review requirements, effectful bytes and source revisions remain
|
||
+immutable. A digest is provenance, never served approval or execution authority.
|
||
+"""
|
||
+from __future__ import annotations
|
||
+import copy
|
||
+import hashlib
|
||
+import json
|
||
+import re
|
||
+from datetime import datetime
|
||
+
|
||
+
|
||
+class Refused(Exception): pass
|
||
+
|
||
+ROOT_KEYS={'id','kind','schema_version','request_type','title','status','created','updated',
|
||
+ 'requester','review','target','execution','openbao','evidence'}
|
||
+APPEND_ONLY_PATHS=(('review','comments'),('evidence','events'))
|
||
+HASH=re.compile(r'sha256:[0-9a-f]{64}')
|
||
+
|
||
+
|
||
+def need(value):
|
||
+ if not value:raise Refused('invalid task execution grant')
|
||
+
|
||
+
|
||
+def encoded(value):
|
||
+ def json_value(item):
|
||
+ if type(item) is dict:
|
||
+ need(all(type(key) is str for key in item))
|
||
+ for child in item.values():json_value(child)
|
||
+ elif type(item) is list:
|
||
+ for child in item:json_value(child)
|
||
+ else:need(item is None or type(item) in {str,int,float,bool})
|
||
+ try:
|
||
+ json_value(value)
|
||
+ return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=True,allow_nan=False).encode()
|
||
+ except (TypeError,ValueError,RecursionError):raise Refused('invalid task execution grant') from None
|
||
+
|
||
+
|
||
+def closed(value,keys):
|
||
+ need(type(value) is dict and set(value)==set(keys));return value
|
||
+
|
||
+
|
||
+def text(value):
|
||
+ need(type(value) is str and 0<len(value)<=4096 and '\x00' not in value);return value
|
||
+
|
||
+
|
||
+def timestamp(value):
|
||
+ try:need(datetime.fromisoformat(text(value).replace('Z','+00:00')).tzinfo is not None)
|
||
+ except (ValueError,TypeError):raise Refused('invalid task execution grant') from None
|
||
+
|
||
+
|
||
+def projection(record):
|
||
+ """Validate and capture every field except the two explicit append-only lists.
|
||
+
|
||
+ Unknown keys never become implicitly excluded evidence. The independently
|
||
+ derived exact execution/openbao comparison belongs to bind(), below.
|
||
+ """
|
||
+ closed(record,ROOT_KEYS)
|
||
+ need(record['kind']=='credential-change-request' and type(record['schema_version']) is int
|
||
+ and record['schema_version']==1 and record['request_type']=='task-execution-grant')
|
||
+ need(type(record['id']) is str and re.fullmatch(r'[A-Z0-9][A-Z0-9_.-]*',record['id']))
|
||
+ need(type(record['status']) is str and record['status'] in {'proposed','approved','denied','cancelled','superseded'})
|
||
+ for field in ('title','created','updated'):text(record[field])
|
||
+ requester=closed(record['requester'],{'agent','reason'})
|
||
+ for value in requester.values():text(value)
|
||
+ review=closed(record['review'],{'required','required_approvers','comments'})
|
||
+ need(review['required'] is True and type(review['required_approvers']) is list
|
||
+ and review['required_approvers']==['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'])
|
||
+ need(type(review['comments']) is list and len(review['comments'])<=1024)
|
||
+ for comment in review['comments']:
|
||
+ closed(comment,{'at','reviewer','decision','comment'})
|
||
+ timestamp(comment['at']);text(comment['reviewer']);text(comment['comment'])
|
||
+ need(type(comment['decision']) is str and comment['decision'] in {'approved','changes_requested','declined'})
|
||
+ target=closed(record['target'],{'domain','tenant','workload','environment','purpose'})
|
||
+ need(target['tenant']=='platform' and target['workload']=='secrets-engine' and target['environment']=='production')
|
||
+ for value in target.values():text(value)
|
||
+ need(type(record['execution']) is dict and type(record['openbao']) is dict)
|
||
+ evidence=closed(record['evidence'],{'events'})
|
||
+ need(type(evidence['events']) is list and len(evidence['events'])<=1024)
|
||
+ for event in evidence['events']:
|
||
+ closed(event,{'at','kind','result','reference_sha256'})
|
||
+ timestamp(event['at']);text(event['kind'])
|
||
+ need(type(event['result']) is str and event['result'] in {'pending','passed','failed','unknown'})
|
||
+ need(type(event['reference_sha256']) is str and HASH.fullmatch(event['reference_sha256']))
|
||
+ # Deep capture refuses NaN/non-JSON structures rather than silently coercing.
|
||
+ value=json.loads(encoded(record))
|
||
+ for parent,leaf in APPEND_ONLY_PATHS:value[parent][leaf]=[]
|
||
+ return value
|
||
+
|
||
+
|
||
+def immutable_digest(record):
|
||
+ return 'sha256:'+hashlib.sha256(encoded(projection(record))).hexdigest()
|
||
+
|
||
+
|
||
+def require_append_only(previous,current):
|
||
+ need(immutable_digest(previous)==immutable_digest(current))
|
||
+ for parent,leaf in APPEND_ONLY_PATHS:
|
||
+ old=previous[parent][leaf];new=current[parent][leaf]
|
||
+ need(len(new)>=len(old) and new[:len(old)]==old)
|
||
+
|
||
+
|
||
+def bind(record,*,grant_id,expected_execution,expected_openbao,expected_digest):
|
||
+ """Compare an owner source record with independently reconstructed effects.
|
||
+
|
||
+ Does not trust status/comments as approval, read source files, contact engines,
|
||
+ register a delegated applier or issue any backend request.
|
||
+ """
|
||
+ value=projection(record)
|
||
+ need(record['id']==grant_id and type(expected_digest) is str and HASH.fullmatch(expected_digest))
|
||
+ need(encoded(record['execution'])==encoded(expected_execution))
|
||
+ need(encoded(record['openbao'])==encoded(expected_openbao))
|
||
+ need(immutable_digest(record)==expected_digest)
|
||
+ return value
|
||
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
|
||
index 7a4d247..29d168d 100644
|
||
--- a/scripts/task_execution_native_fixture.py
|
||
+++ b/scripts/task_execution_native_fixture.py
|
||
@@ -14,6 +14,7 @@ import http.client
|
||
import json
|
||
import subprocess
|
||
import time
|
||
+from pathlib import Path
|
||
from urllib.parse import urlsplit
|
||
|
||
from task_execution_cleanup import CleanupResult
|
||
@@ -44,6 +45,8 @@ class NativeFixtureAdapter:
|
||
self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
|
||
self.admission=admission
|
||
self.meta=plan.metadata()
|
||
+ if self.meta.get('execution_grant',{}).get('status')!='approved':
|
||
+ raise Refused('independently source-bound synthetic approved grant required')
|
||
descriptor=json.loads(admission.descriptor)
|
||
fresh=compiler.bind_execution(compiler.compile(self.meta['request']),descriptor,admission.descriptor)
|
||
if fresh.serialized != plan.serialized or binding.plan_digest != plan.content_digest:
|
||
@@ -73,6 +76,8 @@ class NativeFixtureAdapter:
|
||
paths=sorted({call.path for call in calls})
|
||
self.hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
|
||
self.hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
|
||
+ if self.meta['execution_grant']['openbao']['policy']!={'name':self.policy_name,'hcl':self.hcl}:
|
||
+ raise Refused('native grant body differs from bound owner projection')
|
||
self.cleanup=tuple(Cleanup(item['kind'],item['call_digest']) for item in cleanup_specs(binding.operation_id))
|
||
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
|
||
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
|
||
@@ -86,7 +91,11 @@ class NativeFixtureAdapter:
|
||
self._live()
|
||
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
|
||
raise Refused('business deadline exhausted')
|
||
- self.compiler.before_call(self.plan,0)
|
||
+ # The constructor independently rebuilt the complete immutable Plan and
|
||
+ # grant. Recheck every pinned byte before each native effect. Metadata
|
||
+ # dispatch additionally reconstructs its exact ordinal/body below; grant
|
||
+ # creation reconstructs and compares its fixed body before issuance.
|
||
+ for path in self.compiler.pins:self.compiler.read(Path(path))
|
||
|
||
def _http(self, method, path, token, body=b''):
|
||
# Fixed caller-generated paths only; http.client has no proxy or redirect
|
||
@@ -148,6 +157,8 @@ class NativeFixtureAdapter:
|
||
body={'policies':[self.policy_name],'no_default_policy':True,'renewable':False,
|
||
'ttl':str(self.ttl)+'s','explicit_max_ttl':str(self.ttl)+'s','num_uses':self.uses,
|
||
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
|
||
+ if body!=self.meta['execution_grant']['openbao']['token_create_body']:
|
||
+ raise Refused('native token body differs from bound owner projection')
|
||
self._mint_attempted=True
|
||
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
|
||
auth=data.get('auth',{})
|
||
diff --git a/scripts/task_execution_plan.py b/scripts/task_execution_plan.py
|
||
index f3234fc..4c086b4 100644
|
||
--- a/scripts/task_execution_plan.py
|
||
+++ b/scripts/task_execution_plan.py
|
||
@@ -53,6 +53,7 @@ class OwnerConfig:
|
||
catalog_directory: Path
|
||
# Exact path/hash pairs, configured independently by the owner. No request paths.
|
||
source_pins: tuple[tuple[str,str], ...]
|
||
+ grant_filename: str | None = None
|
||
|
||
@dataclass(frozen=True)
|
||
class Plan:
|
||
@@ -111,11 +112,18 @@ class Compiler:
|
||
self.validator=config.platform_repo/'scripts/credential-change.py'
|
||
self.catalog_code=config.secrets_repo/'src/secrets_engine/catalog.py'
|
||
self.dependencies=[config.secrets_repo/'src/secrets_engine'/name for name in ('__init__.py','errors.py','redact.py')]
|
||
+ self.grant_record=None;self.grant_validator=None
|
||
+ if config.grant_filename is not None:
|
||
+ need(type(config.grant_filename) is str and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*\.yaml',config.grant_filename)
|
||
+ and config.grant_filename!=config.ccr_filename,'owner_grant_filename_invalid')
|
||
+ self.grant_record=config.platform_repo/'credential-change-requests'/config.grant_filename
|
||
+ self.grant_validator=Path(__file__).resolve().parent/'task_execution_grant.py'
|
||
pins=dict(config.source_pins)
|
||
self.pins=types.MappingProxyType(pins)
|
||
need(len(self.pins)==len(config.source_pins) and all(type(k) is str and digest_ok(v) for k,v in self.pins.items()),'owner_pins_invalid')
|
||
# Compiler source is part of the owner plan, not just generated body hashes.
|
||
required={str(p) for p in [Path(__file__).resolve(),self.ccr,self.catalog,self.validator,self.catalog_code,*self.dependencies]}
|
||
+ if self.grant_record is not None:required.update({str(self.grant_record),str(self.grant_validator)})
|
||
need(required <= set(self.pins),'owner_source_pin_missing')
|
||
extra=set(self.pins)-required
|
||
need(len(extra)==1,'closed_owner_source_set_required')
|
||
@@ -254,6 +262,51 @@ class Compiler:
|
||
'source_pins':dict(self.pins),'task_token_max_ttl':120,'task_token_max_uses':12,
|
||
'cleanup_uses_reserved':1,'cleanup':cleanup_specs(request['operation_id'])}))
|
||
|
||
+ def grant_effects(self,plan,descriptor):
|
||
+ """Independently derive the one proposed grant's effectful immutable body."""
|
||
+ need(type(plan) is Plan and type(descriptor) is dict,'typed_grant_inputs_required')
|
||
+ meta=plan.metadata();req=meta['request']
|
||
+ need(self.compile(req).serialized==plan.serialized,'immutable_plan_changed')
|
||
+ _,_,calls=self._derive()
|
||
+ budget=descriptor['backend_budget']
|
||
+ need(type(budget) is dict and set(budget)=={'ttl_seconds','uses'}
|
||
+ and type(budget['ttl_seconds']) is int and 30<=budget['ttl_seconds']<=120
|
||
+ and type(budget['uses']) is int and budget['uses']==12,'fixed_grant_budget_required')
|
||
+ operation_id=req['operation_id'];policy_name='task-exec-'+operation_id
|
||
+ paths=sorted({call.path for call in calls})
|
||
+ hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
|
||
+ hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
|
||
+ token_body={'policies':[policy_name],'no_default_policy':True,'renewable':False,
|
||
+ 'ttl':str(budget['ttl_seconds'])+'s','explicit_max_ttl':str(budget['ttl_seconds'])+'s',
|
||
+ 'num_uses':12,'type':'service','display_name':policy_name,'no_parent':False}
|
||
+ # A grant cannot include its own immutable hash or raw file SHA. The
|
||
+ # existing descriptor joins it by ID+immutable SHA after this projection.
|
||
+ descriptor_binding={k:v for k,v in descriptor.items() if k!='grant_immutable_sha256'}
|
||
+ pins={path:digest for path,digest in self.pins.items() if self.grant_record is None or path!=str(self.grant_record)}
|
||
+ execution={'action':'execute-ccr','catalog_id':req['catalog_id'],
|
||
+ 'descriptor_binding':descriptor_binding,'source_pins':pins,
|
||
+ 'cleanup':cleanup_specs(operation_id),'cleanup_uses_reserved':1,
|
||
+ 'cleanup_after_business_deadline':True}
|
||
+ openbao={'policy':{'name':policy_name,'hcl':hcl},'token_create_body':token_body,
|
||
+ 'owner_parent_custody':'platform','identity_policies':[]}
|
||
+ return execution,openbao
|
||
+
|
||
+ def _bind_grant(self,plan,descriptor):
|
||
+ need(self.grant_record is not None,'owner_grant_source_required')
|
||
+ # Execute exactly the pinned pure projection source, never a cached module.
|
||
+ module=types.ModuleType('_owner_task_execution_grant')
|
||
+ raw=self.read(self.grant_validator)
|
||
+ exec(compile(raw,str(self.grant_validator),'exec'),module.__dict__)
|
||
+ record=yaml.load(self.read(self.grant_record),Loader=UniqueLoader)
|
||
+ execution,openbao=self.grant_effects(plan,descriptor)
|
||
+ try:
|
||
+ projection=module.bind(record,grant_id=descriptor['execution_grant_ccr_id'],
|
||
+ expected_execution=execution,expected_openbao=openbao,
|
||
+ expected_digest=descriptor['grant_immutable_sha256'])
|
||
+ except Exception:raise Refused('owner_grant_binding_mismatch') from None
|
||
+ need(self.read(self.grant_validator)==raw,'owner_grant_source_changed')
|
||
+ return projection
|
||
+
|
||
def bind_execution(self, plan, descriptor, owner_descriptor):
|
||
"""Bind exact engine descriptor to owner plan; no authorization is created.
|
||
|
||
@@ -275,9 +328,11 @@ class Compiler:
|
||
need(all(descriptor.get(k)==v for k,v in expected.items()),'execution_descriptor_plan_mismatch')
|
||
bound = dict(meta, execution_descriptor=descriptor,
|
||
execution_descriptor_sha256=tagged(owner_descriptor))
|
||
- # The full descriptor's independently admitted grant immutable digest must
|
||
- # cover these cleanup commitments. No grant schema exists here yet, so this
|
||
- # source binding cannot itself admit a grant/native production execution.
|
||
+ if self.grant_record is not None:
|
||
+ bound['execution_grant']=self._bind_grant(plan,descriptor)
|
||
+ bound['grant_immutable_sha256']=descriptor['grant_immutable_sha256']
|
||
+ # Without an independently pinned grant this remains preparation only.
|
||
+ # The native fixture adapter refuses to materialize such an unbound plan.
|
||
return Plan(encoded(bound))
|
||
|
||
def before_call(self,plan,ordinal):
|
||
diff --git a/tests/test_task_execution_grant.py b/tests/test_task_execution_grant.py
|
||
new file mode 100644
|
||
index 0000000..13298e8
|
||
--- /dev/null
|
||
+++ b/tests/test_task_execution_grant.py
|
||
@@ -0,0 +1,98 @@
|
||
+"""Pure proposed grant projection: source provenance, never approval/native proof."""
|
||
+import copy
|
||
+from pathlib import Path
|
||
+import sys
|
||
+import pytest
|
||
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
|
||
+import task_execution_grant as grant
|
||
+
|
||
+
|
||
+@pytest.fixture
|
||
+def record():
|
||
+ return {'id':'CCR-FIXTURE-GRANT','kind':'credential-change-request','schema_version':1,
|
||
+ 'request_type':'task-execution-grant','title':'Proposed source fixture','status':'proposed',
|
||
+ 'created':'2026-10-16','updated':'2026-10-16','requester':{'agent':'fixture','reason':'Local proof'},
|
||
+ 'review':{'required':True,'required_approvers':['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'],'comments':[]},
|
||
+ 'target':{'domain':'infotech','tenant':'platform','workload':'secrets-engine','environment':'production','purpose':'fixture'},
|
||
+ 'execution':{'action':'execute-ccr','descriptor_binding':{'operation_deadline':'2026-10-16T12:00:00Z','ccr_immutable_sha256':'sha256:'+'a'*64},
|
||
+ 'source_pins':{'/fixture/source':'sha256:'+'b'*64},'cleanup':[{'kind':'task-token-revoke','call_digest':'c'*64}]},
|
||
+ 'openbao':{'policy':{'name':'task-exec-fixture','hcl':'exact owner generated HCL'},
|
||
+ 'token_create_body':{'ttl':'120s','num_uses':12,'renewable':False}},'evidence':{'events':[]}}
|
||
+
|
||
+
|
||
+def comment():return {'at':'2026-10-16T12:00:00Z','reviewer':'railiance-platform-owner','decision':'approved','comment':'Source only; no execution authority'}
|
||
+def event():return {'at':'2026-10-16T12:00:00Z','kind':'test','result':'unknown','reference_sha256':'sha256:'+'e'*64}
|
||
+
|
||
+
|
||
+def test_only_typed_review_comments_and_evidence_events_are_excluded(record):
|
||
+ changed=copy.deepcopy(record);changed['review']['comments'].append(comment());changed['evidence']['events'].append(event())
|
||
+ assert grant.immutable_digest(changed)==grant.immutable_digest(record)
|
||
+ grant.require_append_only(record,changed)
|
||
+ assert record['review']['comments']==[] # detached immutable projection
|
||
+
|
||
+
|
||
+@pytest.mark.parametrize('field',['status','updated','title','requester','target','execution','openbao','review'])
|
||
+def test_every_other_root_or_effectful_field_changes_digest(record,field):
|
||
+ changed=copy.deepcopy(record)
|
||
+ if field=='status':changed[field]='approved'
|
||
+ elif field=='updated':changed[field]='2026-10-17'
|
||
+ elif field=='title':changed[field]='different title'
|
||
+ elif field=='requester':changed[field]['reason']='different requester reason'
|
||
+ elif field=='target':changed[field]['purpose']='different purpose'
|
||
+ elif field=='execution':changed[field]['source_pins']['/fixture/source']='sha256:'+'f'*64
|
||
+ elif field=='openbao':changed[field]['policy']['hcl']='different HCL'
|
||
+ elif field=='review':changed[field]['required_approvers'].reverse()
|
||
+ if field=='review':
|
||
+ with pytest.raises(grant.Refused):grant.immutable_digest(changed)
|
||
+ else:
|
||
+ assert grant.immutable_digest(changed)!=grant.immutable_digest(record)
|
||
+ with pytest.raises(grant.Refused):grant.require_append_only(record,changed)
|
||
+
|
||
+
|
||
+@pytest.mark.parametrize('location,key',[('root','audit'),('root','receipts'),('root','evidence_exclusions'),('evidence','files'),('evidence','policy'),('review','approval_ref'),('review','exclusions')])
|
||
+def test_guessed_evidence_exclusions_or_unknown_fields_refuse(record,location,key):
|
||
+ changed=copy.deepcopy(record)
|
||
+ (changed if location=='root' else changed[location])[key]=[]
|
||
+ with pytest.raises(grant.Refused):grant.immutable_digest(changed)
|
||
+
|
||
+
|
||
+def test_append_only_rejects_removal_rewrite_and_reordering(record):
|
||
+ previous=copy.deepcopy(record);previous['review']['comments']=[comment(),dict(comment(),comment='Second')]
|
||
+ previous['evidence']['events']=[event()]
|
||
+ for field,value in [('comments',[]),('comments',[dict(comment(),decision='declined')]),('comments',list(reversed(previous['review']['comments']))),('events',[])]:
|
||
+ changed=copy.deepcopy(previous);changed['review' if field=='comments' else 'evidence'][field]=value
|
||
+ assert grant.immutable_digest(changed)==grant.immutable_digest(previous)
|
||
+ with pytest.raises(grant.Refused):grant.require_append_only(previous,changed)
|
||
+
|
||
+
|
||
+@pytest.mark.parametrize('change',['policy','token_budget','cleanup','source','deadline','target_ccr','wrong_hash','grant_id'])
|
||
+def test_bind_reconstructs_effects_and_exact_existing_descriptor_join(record,change):
|
||
+ expected=copy.deepcopy(record);changed=copy.deepcopy(record);digest=grant.immutable_digest(record);grant_id=record['id']
|
||
+ if change=='policy':changed['openbao']['policy']['hcl']='path "*" {}'
|
||
+ elif change=='token_budget':changed['openbao']['token_create_body']['num_uses']=13
|
||
+ elif change=='cleanup':changed['execution']['cleanup'][0]['kind']='kv-delete'
|
||
+ elif change=='source':changed['execution']['source_pins']['/fixture/source']='sha256:'+'f'*64
|
||
+ elif change=='deadline':changed['execution']['descriptor_binding']['operation_deadline']='2027-01-01T00:00:00Z'
|
||
+ elif change=='target_ccr':changed['execution']['descriptor_binding']['ccr_immutable_sha256']='sha256:'+'f'*64
|
||
+ elif change=='wrong_hash':digest='sha256:'+'f'*64
|
||
+ elif change=='grant_id':grant_id='CCR-OTHER'
|
||
+ with pytest.raises(grant.Refused):grant.bind(changed,grant_id=grant_id,expected_execution=expected['execution'],expected_openbao=expected['openbao'],expected_digest=digest)
|
||
+
|
||
+
|
||
+def test_proposed_source_record_can_bind_without_becoming_approved(record):
|
||
+ projection=grant.bind(record,grant_id=record['id'],expected_execution=record['execution'],expected_openbao=record['openbao'],expected_digest=grant.immutable_digest(record))
|
||
+ assert projection['status']=='proposed'
|
||
+
|
||
+
|
||
+@pytest.mark.parametrize('change',['schema_bool','comment_extra','event_extra','unaware_comment','event_missing','nan','tuple','integer_key','status_list'])
|
||
+def test_closed_types_refuse_instead_of_silent_coercion(record,change):
|
||
+ if change=='schema_bool':record['schema_version']=True
|
||
+ elif change=='comment_extra':record['review']['comments']=[dict(comment(),policy='unreviewed')]
|
||
+ elif change=='event_extra':record['evidence']['events']=[dict(event(),token='never accepted')]
|
||
+ elif change=='unaware_comment':record['review']['comments']=[dict(comment(),at='2026-10-16T12:00:00')]
|
||
+ elif change=='event_missing':record['evidence']['events']=[{'kind':'test'}]
|
||
+ elif change=='nan':record['execution']['value']=float('nan')
|
||
+ elif change=='tuple':record['execution']['value']=(1,2)
|
||
+ elif change=='integer_key':record['execution'][1]='invalid'
|
||
+ elif change=='status_list':record['status']=[]
|
||
+ with pytest.raises(grant.Refused):grant.immutable_digest(record)
|
||
diff --git a/tests/test_task_execution_native_fixture.py b/tests/test_task_execution_native_fixture.py
|
||
index 382b236..2a8e419 100644
|
||
--- a/tests/test_task_execution_native_fixture.py
|
||
+++ b/tests/test_task_execution_native_fixture.py
|
||
@@ -3,6 +3,8 @@
|
||
These tests do not contact estate OpenBao or claim production owner acceptance.
|
||
"""
|
||
import hashlib
|
||
+from dataclasses import replace
|
||
+import yaml
|
||
import http.client
|
||
import json
|
||
import shutil
|
||
@@ -20,6 +22,7 @@ from test_task_execution_plan import compiler,request,m
|
||
from task_execution_journal import Binding,Cleanup,Journal,Refused
|
||
from task_execution_cleanup import run_cleanup
|
||
from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter,PrivateSession
|
||
+import task_execution_grant as grant
|
||
|
||
|
||
@pytest.fixture
|
||
@@ -79,8 +82,29 @@ def owner(compiler,bao_dev,tmp_path):
|
||
# grant/expected-state digests remain deliberately synthetic owner fixtures.
|
||
from secrets_engine.execution_contract import ExecutionContract
|
||
descriptor=ExecutionContract.parse(descriptor).as_dict()
|
||
+ # Install an independently pinned synthetic grant source and its pure
|
||
+ # validator. No real CCR is written or approved in the owner checkout.
|
||
+ grantfile=compiler.config.platform_repo/'credential-change-requests/CCR-FIXTURE-GRANT.yaml'
|
||
+ grantfile.write_text('placeholder: true\n')
|
||
+ grantcode=Path(grant.__file__).resolve()
|
||
+ pins=dict(compiler.config.source_pins)
|
||
+ pins.update({str(grantfile):m.tagged(grantfile.read_bytes()),str(grantcode):m.tagged(grantcode.read_bytes())})
|
||
+ compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items()),grant_filename=grantfile.name))
|
||
+ plan=compiler.compile(req)
|
||
+ expected_execution,expected_openbao=compiler.grant_effects(plan,descriptor)
|
||
+ record={'id':descriptor['execution_grant_ccr_id'],'kind':'credential-change-request',
|
||
+ 'schema_version':1,'request_type':'task-execution-grant','title':'Synthetic disposable fixture grant',
|
||
+ 'status':'approved','created':'2026-10-16','updated':'2026-10-16',
|
||
+ 'requester':{'agent':'fixture','reason':'Synthetic disposable proof; no founder approval'},
|
||
+ 'review':{'required':True,'required_approvers':['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'],'comments':[]},
|
||
+ 'target':{'domain':'infotech','tenant':'platform','workload':'secrets-engine','environment':'production','purpose':'Synthetic metadata primitive proof'},
|
||
+ 'execution':expected_execution,'openbao':expected_openbao,'evidence':{'events':[]}}
|
||
+ grantfile.write_text(yaml.safe_dump(record,sort_keys=False))
|
||
+ pins[str(grantfile)]=m.tagged(grantfile.read_bytes())
|
||
+ compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
|
||
+ descriptor['grant_immutable_sha256']=grant.immutable_digest(record)
|
||
installed=m.encoded(descriptor)
|
||
- plan=compiler.bind_execution(plan,descriptor,installed)
|
||
+ plan=compiler.bind_execution(compiler.compile(req),descriptor,installed)
|
||
binding=Binding('SYNTHETIC-APPROVAL-NOT-PRODUCTION',req['request_digest'],req['operation_id'],plan.content_digest)
|
||
state=tmp_path/'private-journal';state.mkdir(mode=0o700);journal=Journal(state)
|
||
admission=FixtureAdmission(installed,root,identity,process)
|
||
@@ -225,8 +249,62 @@ def test_actual_native_policy_retirement_does_not_prove_failed_token_revoke(owne
|
||
def test_nonloopback_estate_origin_is_refused_before_native_transport(owner):
|
||
native,journal,binding,call,compiler,descriptor=owner
|
||
changed=dict(descriptor,backend_origin='https://estate.invalid:8200')
|
||
+ # The owner grant now also binds origin. Build a matching synthetic grant so
|
||
+ # this case reaches the adapter's independent nonloopback refusal; ordinary
|
||
+ # origin substitution is already refused earlier by the grant join.
|
||
+ record=yaml.safe_load(compiler.grant_record.read_text())
|
||
+ record['execution'],record['openbao']=compiler.grant_effects(compiler.compile(native.meta['request']),changed)
|
||
+ compiler.grant_record.write_text(yaml.safe_dump(record))
|
||
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
|
||
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
|
||
+ changed['grant_immutable_sha256']=grant.immutable_digest(record)
|
||
installed=m.encoded(changed)
|
||
- plan=compiler.bind_execution(compiler.compile(native.meta['request']),changed,installed)
|
||
+ plan=changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed,installed)
|
||
changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
|
||
admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
|
||
- with pytest.raises(Refused):NativeFixtureAdapter(compiler,plan,journal,changed_binding,admission)
|
||
+ with pytest.raises(Refused):NativeFixtureAdapter(changed_compiler,plan,journal,changed_binding,admission)
|
||
+
|
||
+
|
||
+def test_changed_grant_immutable_join_refuses_before_any_native_grant(owner):
|
||
+ native,journal,binding,call,compiler,descriptor=owner
|
||
+ changed=dict(descriptor,grant_immutable_sha256='sha256:'+'0'*64)
|
||
+ with pytest.raises(m.Refused):compiler.bind_execution(compiler.compile(native.meta['request']),changed,m.encoded(changed))
|
||
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
|
||
+
|
||
+
|
||
+def test_grant_body_repin_cannot_replace_independently_derived_effects(owner):
|
||
+ native,journal,binding,call,compiler,descriptor=owner
|
||
+ record=yaml.safe_load(compiler.grant_record.read_text())
|
||
+ record['openbao']['policy']['hcl']='path "*" { capabilities = ["sudo"] }'
|
||
+ compiler.grant_record.write_text(yaml.safe_dump(record))
|
||
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
|
||
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
|
||
+ changed_descriptor=dict(descriptor,grant_immutable_sha256=grant.immutable_digest(record))
|
||
+ with pytest.raises(m.Refused):changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed_descriptor,m.encoded(changed_descriptor))
|
||
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
|
||
+
|
||
+
|
||
+def test_proposed_source_grant_never_materializes_even_with_matching_digest(owner):
|
||
+ native,journal,binding,call,compiler,descriptor=owner
|
||
+ record=yaml.safe_load(compiler.grant_record.read_text());record['status']='proposed'
|
||
+ compiler.grant_record.write_text(yaml.safe_dump(record))
|
||
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
|
||
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
|
||
+ changed_descriptor=dict(descriptor,grant_immutable_sha256=grant.immutable_digest(record))
|
||
+ installed=m.encoded(changed_descriptor)
|
||
+ plan=changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed_descriptor,installed)
|
||
+ changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
|
||
+ admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
|
||
+ with pytest.raises(Refused):NativeFixtureAdapter(changed_compiler,plan,journal,changed_binding,admission)
|
||
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
|
||
+
|
||
+
|
||
+def test_grant_source_drift_after_admission_prevents_protected_effect(owner):
|
||
+ native,journal,binding,call,compiler,*_=owner
|
||
+ with journal.locked():
|
||
+ admit(native,journal,binding)
|
||
+ record=yaml.safe_load(compiler.grant_record.read_text());record['execution']['cleanup'][0]['call_digest']='f'*64
|
||
+ compiler.grant_record.write_text(yaml.safe_dump(record))
|
||
+ with pytest.raises(m.Refused):native.materialize_grant()
|
||
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
|
||
+ assert run_cleanup(journal,binding,native)['confirmed']
|
||
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||
index 0fd7ccc..f9af000 100644
|
||
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||
@@ -1251,3 +1251,14 @@ identity/approval/consume/grant/state/source admission in this fixture is synthe
|
||
no production authority or complete native owner acceptance is claimed. Exact
|
||
remaining owner schema/admission and mediator work: docs/task-execution-native-fixture.md.
|
||
T12 stays progress; no live grant, credential/default change or owner sync performed.
|
||
+
|
||
+2026-10-16 T12 third source package: implemented proposed task-execution-grant
|
||
+immutable projection and exact compiler/native-fixture integration through the
|
||
+existing descriptor grant ID/SHA fields. Only typed review.comments/evidence.events
|
||
+are excluded; separate append-only checks reject removal/rewrite/reordering and
|
||
+unknown guessed evidence exclusions refuse. Owner-pinned grant bytes and validator
|
||
+source must match independently reconstructed descriptor/source/cleanup/ACL/token
|
||
+bodies before a native fixture can materialize. Proposed source records gain no
|
||
+execution authority; no real grant, policy or legacy applier registration added.
|
||
+Details and remaining production gates: docs/task-execution-grant-projection.md.
|
||
+T12 remains progress; no owner sync or production effect performed by this package.
|