"""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())=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})))