railiance-platform/scripts/task_execution_native_fixture.py

396 lines
23 KiB
Python
Raw Normal View History

"""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
bearer credentials stay in memory; optional owner-private fixture custody persists
accessors for independently admitted cleanup recovery; no 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 contextlib import contextmanager
from functools import wraps
import signal
import math
import threading
from datetime import datetime, timezone, timedelta
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_recovery import _same_policy,_policy_absent,_json_unique,_invalid_constant
from task_execution_journal import Binding, Cleanup, Journal, Refused
from task_execution_plan import Compiler, Plan, cleanup_specs, encoded, content_hash
def _budget_expired(_signum, _frame):
# Keep blocked teardown interruptible after the first execution timeout.
# This is unwind-only grace, never a refreshed native-effect budget.
signal.setitimer(signal.ITIMER_REAL,.05)
raise Refused('absolute native call budget exhausted')
@contextmanager
def _absolute_budget(seconds):
"""Disposable owner-child bound; refuse unsupported/conflicting execution.
SIGALRM interrupts slow source reads/compilation and HTTP header/body drips.
Nested bounds preserve the shorter outer deadline; no timer refresh extends
authority. This fixture assumes its owner main-thread process, not a shared
arbitrary-thread production runtime.
"""
if (threading.current_thread() is not threading.main_thread() or type(seconds) not in {int,float}
or not math.isfinite(seconds) or seconds<=0):
raise Refused('bounded owner main-thread execution required')
previous=signal.getsignal(signal.SIGALRM)
old,interval=signal.getitimer(signal.ITIMER_REAL)
if interval or (old>0 and previous is not _budget_expired):
raise Refused('conflicting owner execution timer')
started=time.monotonic()
try:
signal.signal(signal.SIGALRM,_budget_expired)
signal.setitimer(signal.ITIMER_REAL,min(seconds,old) if old>0 else seconds)
yield
finally:
signal.setitimer(signal.ITIMER_REAL,0)
elapsed=time.monotonic()-started
signal.signal(signal.SIGALRM,previous)
if old>0:
if old<=elapsed:raise Refused('outer native authority budget exhausted')
signal.setitimer(signal.ITIMER_REAL,old-elapsed)
def _business_window(method):
@wraps(method)
def bounded(self,*args,**kwargs):
remaining=self._remaining_business()-5
with _absolute_budget(remaining):return method(self,*args,**kwargs)
return bounded
def _call_window(method):
@wraps(method)
def bounded(self,*args,**kwargs):
with _absolute_budget(2):return method(self,*args,**kwargs)
return bounded
@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
expected_before: bytes
expected_after: 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, *, recovery_custody=None):
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')
# Installed owner snapshots use the existing descriptor commitments. No
# request-supplied expectation or response may authorize itself.
self.expected={}
for phase in ('before','after'):
raw=getattr(admission,'expected_'+phase)
if type(raw) is not bytes or 'sha256:'+content_hash(raw)!=descriptor['expected_'+phase]:
raise Refused('owner expected-state digest mismatch')
value=json.loads(raw)
if type(value) is not dict or set(value)!=set(paths) or encoded(value)!=raw:
raise Refused('canonical exact target snapshot required')
if any(item is not None and type(item) is not dict for item in value.values()):
raise Refused('metadata snapshot object or explicit absence required')
if phase=='after' and any(item is None for item in value.values()):
raise Refused('present final metadata required')
self.expected[phase]=value
self._observed={'before':{},'after':{}}
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}
self._task_wall_deadline=None;self._task_monotonic_deadline=None
self.recovery_custody=recovery_custody
if recovery_custody is not None:
from task_execution_recovery import FixtureRecoveryCustody
if type(recovery_custody) is not FixtureRecoveryCustody or journal.observe(binding) is not None:
raise Refused('fresh independent fixture recovery custody required')
recovery_custody.prepare(binding,self.cleanup,self.port,self.hcl,admission.identity_session.accessor)
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 _remaining_business(self):
bounds=[(self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic()]
task_wall=getattr(self,'_task_wall_deadline',None)
task_mono=getattr(self,'_task_monotonic_deadline',None)
if task_wall is not None:bounds.append((task_wall-datetime.now(timezone.utc)).total_seconds())
if task_mono is not None:bounds.append(task_mono-time.monotonic())
return min(bounds)
def _fresh(self):
self._live()
if self._remaining_business()<=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))
@_call_window
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,object_pairs_hook=_json_unique,parse_constant=_invalid_constant) 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()
@_business_window
@_call_window
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')
@_business_window
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 not _policy_absent(status,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 not _same_policy(data.get('data',{}).get('policy'),self.hcl): 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')
if self.recovery_custody is not None:
self.recovery_custody.checkpoint(self.binding,'mint-intent')
self._mint_attempted=True
minted_wall=datetime.now(timezone.utc);minted_mono=time.monotonic()
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'])
if self.recovery_custody is not None:
self.recovery_custody.checkpoint(self.binding,'issued',self.task.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')
try:
expiry=datetime.fromisoformat(info['expire_time'].replace('Z','+00:00'))
if expiry.tzinfo is None or expiry.utcoffset() is None:raise ValueError()
self._task_wall_deadline=min(expiry,minted_wall+timedelta(seconds=self.ttl))
self._task_monotonic_deadline=min(minted_mono+self.ttl,
time.monotonic()+(self._task_wall_deadline-datetime.now(timezone.utc)).total_seconds())
if self._remaining_business()<=5:raise ValueError()
except (KeyError,TypeError,ValueError,AttributeError):
raise Refused('native task expiry unconfirmed or exhausted') from None
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}
@_business_window
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
try:
self._ok(status,data,{200,204,404} if ordinal in {0,3} else {200,204})
if ordinal in {0,2,3,5}:
phase='before' if ordinal in {0,3} else 'after'
value=self._metadata_snapshot(call.path,status,data)
if encoded(value)!=encoded(self.expected[phase][call.path]):
raise Refused('native exact expected-state mismatch')
self._observed[phase][call.path]=value
except Exception:
state=self.journal.observe(self.binding)['state']
if state!='unknown': self.journal.transition(self.binding,state,'unknown')
raise Refused('native baseline or readback unconfirmed') from None
return status,data
@staticmethod
def _metadata_snapshot(path,status,response):
# HTTP errors/denials are never absence. OpenBao's exact empty 404
# metadata response is the only absent representation admitted here.
if status==404 and response=={'errors':[]}:
return None
if status!=200 or response.get('errors') or type(response.get('data')) is not dict:
raise Refused('metadata read not confirmed')
value=dict(response['data'])
if path.startswith('sys/policies/acl/'):
if set(value)!={'cas_required','modified','name','policy','version'}:
raise Refused('unrecognized policy metadata fields')
modified=value.pop('modified')
if type(modified) is not str or datetime.fromisoformat(modified.replace('Z','+00:00')).tzinfo is None:
raise Refused('invalid policy modification timestamp')
# All role fields, aliases, defaults and unknown additions participate in
# exact canonical JSON comparison. True never equals integer 1 here.
return value
@_business_window
def verify_metadata(self):
self._fresh()
if self._next_metadata!=6 or self.journal.observe(self.binding)['state']!='applying':
raise Refused('complete same-live metadata sequence required')
for phase in ('before','after'):
if encoded(self._observed[phase])!=encoded(self.expected[phase]):
raise Refused('complete expected metadata evidence required')
evidence=content_hash(encoded({'expected_before':'sha256:'+content_hash(self.admission.expected_before),
'expected_after':'sha256:'+content_hash(self.admission.expected_after),
'scope':'per-target-sequential-readback-no-native-cas'}))
self.journal.transition(self.binding,'applying','readback',evidence)
self.journal.transition(self.binding,'readback','verified',evidence)
return evidence
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')
@_call_window
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=_policy_absent(status,data)
return CleanupResult(confirmed,content_hash(encoded({'kind':'grant-policy-delete','absence_confirmed':confirmed})))