feat(execution): prove fixed grant and cleanup against disposable OpenBao
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
69a792c070
commit
99fd21ee37
9 changed files with 720 additions and 9 deletions
229
scripts/task_execution_native_fixture.py
Normal file
229
scripts/task_execution_native_fixture.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""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())<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}
|
||||
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})))
|
||||
Loading…
Add table
Add a link
Reference in a new issue