railiance-platform/scripts/task_execution_native_fixture.py
codex e2b832b5d4
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
feat: journal strict task consumption before native fixture effects
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 09:20:33 +02:00

240 lines
15 KiB
Python

"""Fixed native metadata/grant/cleanup adapter for disposable loopback OpenBao.
This adapter deliberately admits only a disposable loopback fixture. It supplies
real native primitive coverage while production grant schema, identity/consume and
origin/source admission are unfinished. It cannot open an estate backend. Owner
credentials/handles stay in memory; no subprocess, redirects or ambient proxy.
Python privacy is not credential isolation; production needs the owner runtime.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import hashlib
import http.client
import json
import subprocess
import time
from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import CleanupResult
from task_execution_journal import Binding, Cleanup, Journal, Refused
from task_execution_plan import Compiler, Plan, cleanup_specs, encoded, content_hash
@dataclass(frozen=True)
class PrivateSession:
token: str = field(repr=False)
accessor: str = field(repr=False)
@dataclass(frozen=True)
class FixtureAdmission:
"""Independently installed owner fixture inputs, not a request schema."""
descriptor: bytes
owner_token: str = field(repr=False)
identity_session: PrivateSession = field(repr=False)
server_process: subprocess.Popen = field(repr=False)
class NativeFixtureAdapter:
def __init__(self, compiler: Compiler, plan: Plan, journal: Journal,
binding: Binding, admission: FixtureAdmission):
if type(admission) is not FixtureAdmission or type(admission.descriptor) is not bytes:
raise Refused('independent fixture admission required')
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:
raise Refused('owner bound plan mismatch')
if binding.operation_id != descriptor['operation_id'] or binding.request_digest != self.meta['request']['request_digest']:
raise Refused('owner operation mismatch')
origin=urlsplit(descriptor['backend_origin'])
if origin.scheme!='http' or origin.hostname!='127.0.0.1' or not origin.port or origin.username or origin.password or origin.path or origin.query or origin.fragment:
raise Refused('disposable loopback backend only')
self.host,self.port=origin.hostname,origin.port
process=admission.server_process
if (type(process) is not subprocess.Popen or process.poll() is not None
or type(process.args) is not list or '-dev' not in process.args
or '-dev-no-store-token' not in process.args
or '-dev-listen-address=127.0.0.1:'+str(self.port) not in process.args):
raise Refused('live owner-spawned disposable dev process required')
budget=descriptor['backend_budget']
if set(budget)!={'ttl_seconds','uses'} or type(budget['ttl_seconds']) is not int or not 30<=budget['ttl_seconds']<=120 or type(budget['uses']) is not int or budget['uses']!=12:
raise Refused('fixed native task budget required')
self.ttl=budget['ttl_seconds']; self.uses=budget['uses']; self.used=0
self.deadline=datetime.fromisoformat(descriptor['operation_deadline'].replace('Z','+00:00'))
if self.deadline.tzinfo is None: raise Refused('aware operation deadline required')
remaining=(self.deadline-datetime.now(timezone.utc)).total_seconds()
self.monotonic_deadline=time.monotonic()+remaining
self.policy_name='task-exec-'+binding.operation_id
calls=[compiler.before_call(plan,i) for i in range(6)]
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
self._cleanup_steps={item.kind:0 for item in self.cleanup}
def _live(self):
if not self.journal._held or self.journal._admission!=self.binding or self.journal._consumed_admission!=self.binding:
raise Refused('same live consumed owner admission required')
def _fresh(self):
self._live()
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
raise Refused('business deadline exhausted')
# 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
# following. Never surface backend body/header or transport exception text.
if self.admission.server_process.poll() is not None:
raise Refused('disposable backend exited; outcome unconfirmed')
conn=http.client.HTTPConnection(self.host,self.port,timeout=2)
try:
conn.request(method,'/v1/'+path,body=body or None,
headers={'X-Vault-Token':token,'Content-Type':'application/json'})
response=conn.getresponse(); raw=response.read(1024*1024+1)
if len(raw)>1024*1024: raise Refused('oversize native response')
data=json.loads(raw) if raw else {}
if type(data) is not dict: raise Refused('invalid native response')
return response.status,data
except Exception:
raise Refused('native transport or response unconfirmed') from None
finally: conn.close()
def _business(self, method, path, token, payload=b'', task=False):
self._fresh()
if task:
if self.used>=self.uses-1: raise Refused('task cleanup use reserved')
self.used+=1
evidence_payload = encoded({'token':'owner-retained-task-token'}) if path=='auth/token/lookup' else payload
digest=content_hash(encoded({'method':method,'path':path,'payload_sha256':'sha256:'+content_hash(evidence_payload),'credential_slot':'task' if task else 'owner'}))
ordinal=self.journal.next_call_ordinal(self.binding)
self.journal.before_call(self.binding,ordinal,digest);self._ordinal=ordinal+1
try: status,data=self._http(method,path,token,payload)
except Exception:
state=self.journal.observe(self.binding)['state']
if state!='unknown': self.journal.transition(self.binding,state,'unknown')
raise
# Native responses may contain tokens/accessors; evidence is closed status
# metadata rather than raw bytes or a guessable secret-value fingerprint.
evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
self.journal.after_call(self.binding,ordinal,digest,evidence)
return status,data
@staticmethod
def _ok(status,data,allowed):
if status not in allowed or data.get('errors'):
raise Refused('native request refused or unverified')
def materialize_grant(self):
self._fresh()
if self.journal.observe(self.binding)['state']!='grant-issuing' or self._policy_attempted:
raise Refused('fresh grant issuing state required')
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<self.ttl+5:
raise Refused('insufficient lifetime for exact fixed task TTL')
root=self.admission.owner_token; path='sys/policies/acl/'+self.policy_name
status,data=self._business('GET',path,root)
if status!=404 or data.get('data'):
raise Refused('per-operation grant policy must be absent')
self._policy_attempted=True
status,data=self._business('PUT',path,root,encoded({'policy':self.hcl}));self._ok(status,data,{200,204})
status,data=self._business('GET',path,root);self._ok(status,data,{200})
if data.get('data',{}).get('policy','').rstrip()!=self.hcl.rstrip(): raise Refused('grant policy readback mismatch')
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',{})
if type(auth.get('client_token')) is not str or not auth['client_token'] or type(auth.get('accessor')) is not str or not auth['accessor']:
raise Refused('native token issue uncertain')
# Retain handles before validation so finally cleanup can revoke even a
# malformed overbroad issue response. Never return token-bearing metadata.
self.task=PrivateSession(auth['client_token'],auth['accessor'])
status,data=self._business('POST','auth/token/lookup',root,encoded({'token':self.task.token}));self._ok(status,data,{200})
info=data.get('data',{})
if (info.get('policies')!=[self.policy_name] or info.get('identity_policies',[]) or info.get('renewable') is not False
or info.get('num_uses')!=self.uses or not 0<info.get('ttl',0)<=self.ttl
or info.get('explicit_max_ttl')!=self.ttl or info.get('orphan') is not False):
raise Refused('native task token bounds mismatch')
self.journal.transition(self.binding,'grant-issuing','grant-issued')
return {'issued':True,'policy_name':self.policy_name,'ttl_ceiling_seconds':self.ttl,'use_ceiling':self.uses}
def metadata_call(self, ordinal):
self._fresh()
if self.task is None or self.journal.observe(self.binding)['state']!='applying' or type(ordinal) is not int or not 0<=ordinal<6 or ordinal!=self._next_metadata:
raise Refused('ordered fixed metadata call required')
call=self.compiler.before_call(self.plan,ordinal)
status,data=self._business(call.method,call.path,self.task.token,call.payload,task=True)
self._next_metadata+=1
self._ok(status,data,{200,204,404} if ordinal in {0,3} else {200,204})
# Metadata only. The trusted owner caller must compare native normalized
# baseline/readback to separately admitted expected state. Not KV values.
return status,data
def _cleanup_admitted(self, kind, digest):
self._live()
expected=next(item for item in self.cleanup if item.kind==kind)
status=self.journal.cleanup_status(self.binding)
actual=next(item for item in status['obligations'] if item['kind']==kind)
if digest!=expected.call_digest or actual['call_digest']!=digest or actual['outcome']!='pending':
raise Refused('exact pending cleanup obligation required')
def _cleanup_http(self,kind,method,path,token,body=b''):
ordinal=next(i for i,item in enumerate(self.cleanup) if item.kind==kind)
obligation=self.cleanup[ordinal];step=self._cleanup_steps[kind]
spec=cleanup_specs(self.binding.operation_id)[ordinal]
call_index=step + (1 if kind=='grant-policy-delete' and not self._policy_attempted else 0)
if call_index>=len(spec['calls']) or spec['calls'][call_index]!={'method':method,'path':path}:
raise Refused('cleanup native method/target differs from commitment')
# Commit shape/slot only: private accessor/token bytes never enter hashes
# or durable evidence. Their exact identity is owner-retained custody.
digest=content_hash(encoded({'obligation_digest':obligation.call_digest,'step':step,
'method':method,'path':path}))
self.journal.before_cleanup_call(self.binding,ordinal,obligation,step,digest)
self._cleanup_steps[kind]+=1
status,data=self._http(method,path,token,body)
evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
self.journal.after_cleanup_call(self.binding,ordinal,obligation,step,digest,evidence)
return status,data
def _revoke(self, kind, digest, session, issue_uncertain=False):
self._cleanup_admitted(kind,digest)
if session is None:
return CleanupResult(not issue_uncertain,content_hash(encoded({'kind':kind,'issue_uncertain':issue_uncertain})))
status,data=self._cleanup_http(kind,'POST','auth/token/revoke-self',session.token)
self._ok(status,data,{200,204})
status,data=self._cleanup_http(kind,'POST','auth/token/lookup-accessor',self.admission.owner_token,encoded({'accessor':session.accessor}))
confirmed=status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
return CleanupResult(confirmed,content_hash(encoded({'kind':kind,'revocation_confirmed':confirmed})))
def revoke_task_token(self, digest):
return self._revoke('task-token-revoke',digest,self.task,self._mint_attempted and self.task is None)
def revoke_identity_token(self, digest):
return self._revoke('identity-token-revoke',digest,self.admission.identity_session)
def delete_grant_policy(self, digest):
self._cleanup_admitted('grant-policy-delete',digest)
path='sys/policies/acl/'+self.policy_name
if self._policy_attempted:
status,data=self._cleanup_http('grant-policy-delete','DELETE',path,self.admission.owner_token);self._ok(status,data,{200,204})
status,data=self._cleanup_http('grant-policy-delete','GET',path,self.admission.owner_token)
confirmed=status==404 and not data.get('data')
return CleanupResult(confirmed,content_hash(encoded({'kind':'grant-policy-delete','absence_confirmed':confirmed})))