diff --git a/sso-mfa/k8s/privacyidea/deploy-factor-recovery.py b/sso-mfa/k8s/privacyidea/deploy-factor-recovery.py new file mode 100644 index 0000000..745d5fd --- /dev/null +++ b/sso-mfa/k8s/privacyidea/deploy-factor-recovery.py @@ -0,0 +1,95 @@ +"""Deploy the private P04 service using existing provider custody and CAS guards.""" +import argparse +import hashlib +import json +import subprocess +from pathlib import Path + +CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786' +PROVIDER='58c7f96d-61cb-4dd4-bca2-54661c0ac375' +PORTAL='e1c4274a-4b6d-4ea2-b619-e09630b745b0' +IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473' + + +def kube(*args, body=None): + r=subprocess.run(['kubectl',*args],input=json.dumps(body).encode() if body is not None else None, + capture_output=True,timeout=45) + if r.returncode:raise RuntimeError('Kubernetes operation failed; inspect resource state') + return json.loads(r.stdout) if r.stdout.strip().startswith(b'{') else None + + +def peer(namespace,name): + return {'namespaceSelector':{'matchLabels':{'kubernetes.io/metadata.name':namespace}}, + 'podSelector':{'matchLabels':{'app.kubernetes.io/name':name}}} + + +def resources(source): + data={name:(source/name).read_text() for name in ('factor_recovery.py','recovery_service.py')} + digest=hashlib.sha256(json.dumps(data,sort_keys=True).encode()).hexdigest()[:16] + name='factor-recovery-'+digest + docs=[{'apiVersion':'v1','kind':'ConfigMap','metadata':{'name':name,'namespace':'mfa'},'immutable':True,'data':data}, + {'apiVersion':'v1','kind':'Service','metadata':{'name':'factor-recovery','namespace':'mfa'}, + 'spec':{'selector':{'app.kubernetes.io/name':'privacyidea'},'ports':[{'name':'recovery','port':8091,'targetPort':8091}]}}] + for ns,selector,direction,remote,port in [ + ('mfa','privacyidea','ingress',peer('user-engine','user-engine'),8091), + ('user-engine','user-engine','egress',peer('mfa','privacyidea'),8091), + ('mfa','privacyidea','egress',peer('sso','keycape'),8080), + ('sso','keycape','ingress',peer('mfa','privacyidea'),8080)]: + docs.append({'apiVersion':'networking.k8s.io/v1','kind':'NetworkPolicy', + 'metadata':{'name':'factor-recovery-'+direction+'-'+str(port),'namespace':ns}, + 'spec':{'podSelector':{'matchLabels':{'app.kubernetes.io/name':selector}}, + 'policyTypes':[direction.title()],direction:[{'from' if direction=='ingress' else 'to':[remote], + 'ports':[{'protocol':'TCP','port':port}]}]}}) + return name,docs + + +def main(): + p=argparse.ArgumentParser() + p.add_argument('--source',type=Path,required=True) + p.add_argument('--apply',action='store_true') + p.add_argument('--portal-image') + a=p.parse_args() + if kube('get','ns','kube-system','-o','json')['metadata']['uid']!=CLUSTER:raise RuntimeError('wrong cluster') + provider=kube('-n','mfa','get','deploy','privacyidea','-o','json') + if provider['metadata']['uid']!=PROVIDER:raise RuntimeError('provider identity changed') + name,docs=resources(a.source) + spec=provider['spec']['template']['spec'] + main=next(c for c in spec['containers'] if c['name']=='privacyidea') + sidecar={'name':'factor-recovery','image':IMAGE,'command':['python3','/recovery/recovery_service.py'], + 'env':[{'name':'PRIVACYIDEA_CONFIGFILE','value':'/etc/privacyidea/pi.cfg'}, + {'name':'PYTHONDONTWRITEBYTECODE','value':'1'}, + {'name':'RECOVERY_ISSUER','value':'https://kc.coulomb.social'}, + {'name':'RECOVERY_AUDIENCE','value':'user-engine-portal'}, + {'name':'RECOVERY_JWKS_URL','value':'http://keycape.sso.svc.cluster.local:8080/jwks'}], + 'envFrom':main['envFrom'], + 'volumeMounts':[dict(m,readOnly=m['name']!='logs') for m in main['volumeMounts']]+[{'name':'factor-recovery-code','mountPath':'/recovery','readOnly':True}], + 'securityContext':{'runAsUser':65534,'runAsGroup':999,'runAsNonRoot':True,'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,'capabilities':{'drop':['ALL']}}, + 'ports':[{'name':'recovery','containerPort':8091}], + 'resources':{'requests':{'cpu':'10m','memory':'128Mi'},'limits':{'cpu':'500m','memory':'384Mi'}}, + 'readinessProbe':{'httpGet':{'path':'/healthz','port':8091},'initialDelaySeconds':10,'periodSeconds':10}, + 'livenessProbe':{'httpGet':{'path':'/healthz','port':8091},'initialDelaySeconds':60,'periodSeconds':20}} + containers=[c for c in spec['containers'] if c['name']!='factor-recovery']+[sidecar] + volumes=[v for v in spec['volumes'] if v['name']!='factor-recovery-code']+[{'name':'factor-recovery-code','configMap':{'name':name}}] + patch=[{'op':'test','path':'/metadata/resourceVersion','value':provider['metadata']['resourceVersion']}, + {'op':'replace','path':'/spec/template/spec/containers','value':containers}, + {'op':'replace','path':'/spec/template/spec/volumes','value':volumes}] + print(json.dumps({'config':name,'resources':[(d['kind'],d['metadata']['name']) for d in docs],'provider_sidecar':True,'portal_update':bool(a.portal_image),'apply':a.apply})) + if not a.apply:return + for doc in docs:kube('apply','-f','-',body=doc) + kube('-n','mfa','patch','deploy','privacyidea','--type=json','-p',json.dumps(patch)) + if a.portal_image: + if not a.portal_image.startswith('forgejo.coulomb.social/coulomb/user-engine@sha256:') or len(a.portal_image.rsplit(':',1)[1])!=64:raise ValueError('digest-pinned portal required') + portal=kube('-n','user-engine','get','deploy','user-engine','-o','json') + if portal['metadata']['uid']!=PORTAL:raise RuntimeError('portal identity changed') + items=portal['spec']['template']['spec']['containers'] + c=next(c for c in items if c['name']=='portal');c['image']=a.portal_image + names={'USER_ENGINE_FACTOR_RECOVERY_URL','USER_ENGINE_MFA_MANAGEMENT_URL'} + c['env']=[e for e in c['env'] if e['name'] not in names]+[ + {'name':'USER_ENGINE_FACTOR_RECOVERY_URL','value':'http://factor-recovery.mfa.svc.cluster.local:8091'}, + {'name':'USER_ENGINE_MFA_MANAGEMENT_URL','value':'https://pink-account.coulomb.social'}] + kube('-n','user-engine','patch','deploy','user-engine','--type=json','-p',json.dumps([ + {'op':'test','path':'/metadata/resourceVersion','value':portal['metadata']['resourceVersion']}, + {'op':'replace','path':'/spec/template/spec/containers','value':items}])) + print('Recovery deployment applied; verify rollout and authenticated acceptance.') + +if __name__=='__main__':main()