Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
296 lines
17 KiB
Python
296 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Owner-run identity capture: metadata plan first, exact bounded execution later.
|
|
|
|
Use the admitted Railiance caller. Flags bind reviewed source/targets; they do
|
|
not grant authority. Private child output and provider diagnostics never print.
|
|
"""
|
|
import argparse
|
|
from datetime import datetime,timezone
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
import tempfile
|
|
import uuid
|
|
|
|
HERE=Path(__file__).resolve().parent
|
|
|
|
|
|
def load(name):
|
|
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
|
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
|
|
|
|
|
bundle=load('identity-bundle');prepare=load('prepare-identity-coverage')
|
|
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
|
|
SOURCES=['capture-identity.py','identity-files.py','identity-bundle.py','prepare-identity-coverage.py','identity-backup-profile.json']
|
|
KUBE=['kubectl']
|
|
|
|
|
|
class Refused(Exception):pass
|
|
|
|
|
|
def command(args,payload=None,timeout=60):
|
|
result=subprocess.run(KUBE+['--request-timeout=30s']+args,input=payload,
|
|
stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=timeout)
|
|
if result.returncode:raise Refused()
|
|
return result.stdout
|
|
|
|
|
|
def get(ns,kind,name):
|
|
scope=['-n',ns] if ns else []
|
|
return json.loads(command(scope+['get',kind,name,'-o','json']))
|
|
|
|
|
|
def selects(selector,labels):
|
|
if any(labels.get(k)!=v for k,v in selector.get('matchLabels',{}).items()):return False
|
|
for expression in selector.get('matchExpressions',[]):
|
|
key=expression['key'];op=expression['operator'];values=expression.get('values',[])
|
|
if op=='In' and labels.get(key) not in values:return False
|
|
if op=='NotIn' and key in labels and labels[key] in values:return False
|
|
if op=='Exists' and key not in labels:return False
|
|
if op=='DoesNotExist' and key in labels:return False
|
|
if op not in {'In','NotIn','Exists','DoesNotExist'}:raise Refused()
|
|
return True
|
|
|
|
|
|
def network_review(run_id,actual_labels=None):
|
|
rows=[]
|
|
for ns in ('sso','mfa'):
|
|
policies=json.loads(command(['-n',ns,'get','networkpolicies','-o','json']))['items']
|
|
for obj in policies:
|
|
m=obj['metadata'];s=obj['spec']
|
|
labels=(actual_labels or {}).get(ns,{'net-kingdom/capture':run_id})
|
|
if selects(s['podSelector'],labels) and (s.get('ingress') or s.get('egress')):
|
|
rows.append({'namespace':ns,'name':m['name'],'uid':m['uid'],
|
|
'spec_sha256':hashlib.sha256(json.dumps(s,sort_keys=True).encode()).hexdigest()})
|
|
return rows
|
|
|
|
|
|
def validate_helper(actual,wanted):
|
|
a=actual['spec'];w=wanted['spec']
|
|
if (any(a.get(k) for k in ('hostNetwork','hostPID','hostIPC','initContainers','ephemeralContainers'))
|
|
or a.get('automountServiceAccountToken') is not False or a.get('activeDeadlineSeconds')!=900
|
|
or len(a['containers'])!=1 or a['volumes']!=w['volumes']):raise Refused()
|
|
c=a['containers'][0];expected=w['containers'][0]
|
|
if any(c.get(k)!=expected[k] for k in ('name','image','command','volumeMounts')):raise Refused()
|
|
if c.get('env') or c.get('envFrom') or c.get('securityContext',{}).get('privileged'):raise Refused()
|
|
if any(c.get('securityContext',{}).get(k)!=v for k,v in expected['securityContext'].items()):raise Refused()
|
|
if any(a.get('securityContext',{}).get(k)!=v for k,v in w['securityContext'].items()):raise Refused()
|
|
|
|
|
|
def manifests(run_id):
|
|
result=[]
|
|
for ns,claims in [('sso',[('lldap','lldap-data'),('authelia','authelia-data'),('keycape','keycape-authentication-policy')]),
|
|
('mfa',[('privacyidea','privacyidea-data')])]:
|
|
label={'net-kingdom/capture':run_id};name='nk49-capture-'+run_id
|
|
result.append({'apiVersion':'networking.k8s.io/v1','kind':'NetworkPolicy',
|
|
'metadata':{'name':name,'namespace':ns},
|
|
'spec':{'podSelector':{'matchLabels':label},'policyTypes':['Ingress','Egress'],'ingress':[],'egress':[]}})
|
|
result.append({'apiVersion':'v1','kind':'Pod','metadata':{'name':name,'namespace':ns,'labels':label},
|
|
'spec':{'restartPolicy':'Never','activeDeadlineSeconds':900,'automountServiceAccountToken':False,
|
|
'securityContext':{'runAsUser':0,'runAsGroup':0},
|
|
'containers':[{'name':'capture','image':IMAGE,'imagePullPolicy':'IfNotPresent',
|
|
'command':['python3','-c','import time; time.sleep(900)'],
|
|
'securityContext':{'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,
|
|
'capabilities':{'drop':['ALL'],'add':['DAC_OVERRIDE']}},
|
|
'resources':{'requests':{'cpu':'50m','memory':'128Mi'},'limits':{'cpu':'1','memory':'2Gi'}},
|
|
'volumeMounts':[{'name':key,'mountPath':'/source/'+key,'readOnly':True} for key,_ in claims]+
|
|
[{'name':'scratch','mountPath':'/scratch'}]}],
|
|
'volumes':[{'name':key,'persistentVolumeClaim':{'claimName':claim,'readOnly':True}} for key,claim in claims]+
|
|
[{'name':'scratch','emptyDir':{'medium':'Memory','sizeLimit':'512Mi'}}]}})
|
|
return result
|
|
|
|
|
|
def plan():
|
|
p=bundle.profile();binding=prepare.prepare(p,get=get)
|
|
cluster=get('databases','cluster','net-kingdom-pg')
|
|
primary=cluster['status']['currentPrimary'];pod=get('databases','pod',primary)
|
|
if (not any(o.get('uid')==cluster['metadata']['uid'] for o in pod['metadata'].get('ownerReferences',[]))
|
|
or not any(c['type']=='Ready' and c['status']=='True' for c in pod['status'].get('conditions',[]))):raise Refused()
|
|
run_id=uuid.uuid4().hex[:12]
|
|
return {'schema':'netkingdom.identity-capture-plan.v1','status':'requires_owner_execution_admission',
|
|
'created_at':datetime.now(timezone.utc).isoformat(),'run_id':run_id,'binding':binding,'kubeconfig':KUBE[2],
|
|
'sources':[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES],
|
|
'database':{'namespace':'databases','cluster':'net-kingdom-pg','cluster_uid':cluster['metadata']['uid'],
|
|
'pod':primary,'pod_uid':pod['metadata']['uid'],'database':'privacyidea_db'},
|
|
'create_resources':manifests(run_id),'service_changes':False,'credential_grants':False,
|
|
'matching_network_allow_policies':network_review(run_id),
|
|
'network_policy_enforcement_requires_owner_confirmation':True,
|
|
'offsite_delivery_included':False,'global_atomic_snapshot_claimed':False}
|
|
|
|
|
|
def checked_plan(path,expected):
|
|
raw=path.read_bytes()
|
|
if hashlib.sha256(raw).hexdigest()!=expected:raise Refused()
|
|
p=json.loads(raw)
|
|
if p.get('schema')!='netkingdom.identity-capture-plan.v1':raise Refused()
|
|
if p.get('kubeconfig')!=KUBE[2]:raise Refused()
|
|
if network_review(p['run_id']):raise Refused()
|
|
if p['sources']!=[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES]:raise Refused()
|
|
if p['create_resources']!=manifests(p['run_id']):raise Refused()
|
|
now=datetime.now(timezone.utc)
|
|
if not 0<=(now-datetime.fromisoformat(p['created_at'])).total_seconds()<3600:raise Refused()
|
|
# Recheck the exact immutable source/target bindings before private reads.
|
|
current=prepare.prepare(bundle.profile(),get=get)
|
|
if current['bindings']!=p['binding']['bindings'] or current['profile_sha256']!=p['binding']['profile_sha256']:raise Refused()
|
|
return p
|
|
|
|
|
|
def delete_created(obj):
|
|
m=obj['metadata'];ns=m['namespace'];name=m['name'];uid=m['uid']
|
|
if obj['kind']=='Pod':path=f'/api/v1/namespaces/{ns}/pods/{name}'
|
|
elif obj['kind']=='NetworkPolicy':path=f'/apis/networking.k8s.io/v1/namespaces/{ns}/networkpolicies/{name}'
|
|
else:raise Refused()
|
|
payload=json.dumps({'apiVersion':'v1','kind':'DeleteOptions','preconditions':{'uid':uid},'gracePeriodSeconds':1}).encode()
|
|
command(['delete','--raw',path,'-f','-'],payload)
|
|
if obj['kind']=='Pod':command(['-n',ns,'wait','--for=delete','pod/'+name,'--timeout=90s'],timeout=100)
|
|
|
|
|
|
def bounded_file(args,path,limit):
|
|
# Stream binary dump to private staging, with a byte bound and suppressed
|
|
# diagnostics. API command argv contains only public resource/database names.
|
|
proc=subprocess.Popen(KUBE+['--request-timeout=600s']+args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
|
try:
|
|
count=0
|
|
with path.open('xb') as out:
|
|
while block:=proc.stdout.read(1024*1024):
|
|
count+=len(block)
|
|
if count>limit:raise Refused()
|
|
out.write(block)
|
|
if proc.wait(timeout=600):raise Refused()
|
|
finally:
|
|
if proc.poll() is None:proc.kill();proc.wait()
|
|
|
|
|
|
def capture(p,output,receipt,decision,checkpoint=lambda:None):
|
|
created=[];receipt.update(status='capture_started',decision_ref=decision,plan_run_id=p['run_id'],
|
|
created_resources=[],unverified_create_outcomes=[],helper_cleanup_verified=False,temporary_plaintext_cleanup_verified=False)
|
|
checkpoint()
|
|
with tempfile.TemporaryDirectory(prefix='nk49-identity-',dir='/tmp') as temporary:
|
|
receipt['temporary_plaintext_directory']=temporary
|
|
checkpoint()
|
|
root=Path(temporary)/'payload';root.mkdir(mode=0o700)
|
|
held={}
|
|
try:
|
|
for c in bundle.profile()['components']:
|
|
for kind,names in [('deployment',[c['deployment']]),('secret',c['secret_refs']),('configmap',c['configmap_refs'])]:
|
|
for name in names:
|
|
obj=get(c['namespace'],kind,name)
|
|
# Keep reconstructable deployed resources inside encryption,
|
|
# without unrelated stale last-applied annotations.
|
|
m=obj['metadata'];obj['metadata']={k:m[k] for k in ['name','namespace','uid','resourceVersion']}
|
|
obj.pop('status',None)
|
|
raw=json.dumps(obj,sort_keys=True).encode();slot=f"resources/{c['namespace']}/{kind}/{name}.json"
|
|
held[(c['namespace'],kind,name)]=(m['uid'],m['resourceVersion'])
|
|
bundle.save(root,slot,raw)
|
|
for proposed in p['create_resources']:
|
|
# create, never adopt/apply an existing resource. Unknown create
|
|
# outcomes stop; only returned exact UIDs are eligible for cleanup.
|
|
pending={'kind':proposed['kind'],**proposed['metadata']}
|
|
receipt['unverified_create_outcomes'].append(pending)
|
|
checkpoint()
|
|
obj=json.loads(command(['create','-f','-','-o','json'],json.dumps(proposed).encode()))
|
|
if (obj['kind']!=proposed['kind'] or obj['metadata']['name']!=proposed['metadata']['name']
|
|
or obj['metadata']['namespace']!=proposed['metadata']['namespace'] or not obj['metadata'].get('uid')):raise Refused()
|
|
created.append(obj)
|
|
receipt['unverified_create_outcomes'].remove(pending)
|
|
receipt['created_resources'].append({'namespace':obj['metadata']['namespace'],
|
|
'kind':obj['kind'],'name':obj['metadata']['name'],'uid':obj['metadata']['uid']})
|
|
checkpoint()
|
|
for ns in ('sso','mfa'):
|
|
name='nk49-capture-'+p['run_id']
|
|
command(['-n',ns,'wait','--for=condition=Ready','pod/'+name,'--timeout=180s'],timeout=190)
|
|
live=get(ns,'pod',name)
|
|
expected=next(o for o in created if o['kind']=='Pod' and o['metadata']['namespace']==ns)
|
|
if live['metadata']['uid']!=expected['metadata']['uid']:raise Refused()
|
|
wanted=next(o for o in p['create_resources'] if o['kind']=='Pod' and o['metadata']['namespace']==ns)
|
|
validate_helper(live,wanted)
|
|
if network_review(p['run_id'],{ns:live['metadata']['labels']}):raise Refused()
|
|
raw=command(['-n',ns,'exec','-i',name,'-c','capture','--','python3','-c',
|
|
(HERE/'identity-files.py').read_text(),ns],timeout=600)
|
|
if len(raw)>bundle.LIMIT+2*1024*1024:raise Refused()
|
|
bundle.import_files(raw,root,ns)
|
|
if get(ns,'pod',name)['metadata']['uid']!=expected['metadata']['uid']:raise Refused()
|
|
db=p['database'];current=get('databases','cluster','net-kingdom-pg')
|
|
if current['metadata']['uid']!=db['cluster_uid'] or current['status']['currentPrimary']!=db['pod']:raise Refused()
|
|
if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused()
|
|
(root/'databases').mkdir(mode=0o700)
|
|
bounded_file(['-n','databases','exec',db['pod'],'-c','postgres','--','pg_dump','-U','postgres',
|
|
'-d','privacyidea_db','-Fc','--no-owner','--no-acl'],root/'databases/privacyidea.dump',bundle.FILE_LIMIT)
|
|
if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused()
|
|
for (ns,kind,name),binding in held.items():
|
|
m=get(ns,kind,name)['metadata']
|
|
if (m['uid'],m['resourceVersion'])!=binding:raise Refused()
|
|
# Re-read original keys after the database dump. No rotation is
|
|
# inferred from file presence; a changed key set refuses publication.
|
|
raw=command(['-n','mfa','exec','-i','nk49-capture-'+p['run_id'],'-c','capture','--','python3','-c',
|
|
(HERE/'identity-files.py').read_text(),'mfa'],timeout=60)
|
|
with tempfile.TemporaryDirectory(prefix='nk49-key-recheck-',dir='/tmp') as keytemp:
|
|
again=Path(keytemp);bundle.import_files(raw,again,'mfa')
|
|
for name in ('enckey','private.pem','public.pem'):
|
|
if (root/'data/privacyidea'/name).read_bytes()!=(again/'data/privacyidea'/name).read_bytes():raise Refused()
|
|
receipt.update(bundle.seal(root,output,{'started_at':receipt['started_at'],
|
|
'finished_at':datetime.now(timezone.utc).isoformat(),'database':db,'decision_ref':decision,
|
|
'cluster_uid':prepare.CLUSTER_UID,'global_atomic_snapshot_claimed':False,
|
|
'omissions':['historical local backups','SQLite WAL/SHM/journal represented by native snapshots',
|
|
'Authelia notification.txt','privacyIDEA service logs; audit data retained in database',
|
|
'OpenBao root/unseal/age private key custody remains independently held']}))
|
|
finally:
|
|
failures=[]
|
|
for obj in reversed(created):
|
|
try:delete_created(obj)
|
|
except Exception:failures.append(obj['metadata']['uid'])
|
|
receipt['helper_cleanup_verified']=not failures and not receipt['unverified_create_outcomes']
|
|
checkpoint()
|
|
if not receipt['helper_cleanup_verified']:raise Refused()
|
|
receipt['temporary_plaintext_cleanup_verified']=not Path(temporary).exists()
|
|
checkpoint()
|
|
if not receipt['temporary_plaintext_cleanup_verified']:raise Refused()
|
|
|
|
|
|
def main():
|
|
global KUBE
|
|
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('action',choices=['plan','capture'])
|
|
parser.add_argument('--kubeconfig',required=True)
|
|
parser.add_argument('--receipt',required=True);parser.add_argument('--plan');parser.add_argument('--plan-sha256')
|
|
parser.add_argument('--output');parser.add_argument('--decision-id');a=parser.parse_args();os.umask(0o077)
|
|
KUBE=['kubectl','--kubeconfig',str(Path(a.kubeconfig).absolute())]
|
|
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
|
os.close(fd)
|
|
r={'status':'failed','started_at':datetime.now(timezone.utc).isoformat(),'secret_values_exported':False}
|
|
def checkpoint():
|
|
fd,temp=tempfile.mkstemp(prefix='.capture-receipt-',dir=Path(a.receipt).absolute().parent)
|
|
try:
|
|
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n');out.flush();os.fsync(out.fileno())
|
|
os.replace(temp,a.receipt)
|
|
finally:Path(temp).unlink(missing_ok=True)
|
|
code=1
|
|
try:
|
|
if a.action=='plan':
|
|
if any([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused()
|
|
r=plan();code=0
|
|
else:
|
|
if not all([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused()
|
|
uuid.UUID(a.decision_id)
|
|
p=checked_plan(Path(a.plan),a.plan_sha256)
|
|
capture(p,Path(a.output),r,a.decision_id,checkpoint);code=0
|
|
except Exception:
|
|
r.update(status='failed',error='capture_refused_or_failed_private_diagnostics_suppressed')
|
|
if r.get('temporary_plaintext_directory') and not Path(r['temporary_plaintext_directory']).exists():r['temporary_plaintext_cleanup_verified']=True
|
|
finally:
|
|
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
|
checkpoint()
|
|
print('OK: identity '+a.action+' complete; see safe receipt.' if code==0 else 'ERROR: identity operation failed; inspect safe receipt before retry.')
|
|
return code
|
|
|
|
|
|
def interrupted(*args):raise Refused()
|
|
|
|
|
|
if __name__=='__main__':
|
|
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,interrupted)
|
|
raise SystemExit(main())
|