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