"""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 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() 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' 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') self.compiler.before_call(self.plan,0) 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._ordinal self.journal.before_call(self.binding,ordinal,digest);self._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})))