railiance-platform/scripts/task_execution_native_fixture.py

230 lines
14 KiB
Python
Raw Normal View History

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