Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
240 lines
15 KiB
Python
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})))
|