"""Service helpers against synthetic files and a recording Kubernetes API stub.""" import base64 import json import os from pathlib import Path import subprocess import bcrypt import pytest ROOT = Path(__file__).resolve().parents[1] PASSWORD = 'SyntheticServiceCredential12345678901234567890' CHANGED = 'ReplacementSyntheticCredential12345678901234567890' VALUES = { 'privacyidea': ['PI_SECRET_KEY', 'PI_PEPPER', 'PI_DB_PASSWORD'], 'lldap': ['LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS', 'LLDAP_SVC_AUTHELIA_PASS', 'LLDAP_SVC_KEYCAPE_PASS', 'LLDAP_SVC_PRIVACYIDEA_PASS', 'LLDAP_SVC_PROVISIONER_PASS'], 'authelia': ['AUTHELIA_JWT_SECRET', 'AUTHELIA_SESSION_SECRET', 'AUTHELIA_STORAGE_ENCRYPTION_KEY', 'AUTHELIA_OIDC_HMAC_SECRET', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'], } COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia', 'identity-provisioner'] ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'), ('privacyidea','PI_DB_PASSWORD','PI_SQLALCHEMY_DATABASE_URI'), ('lldap','LLDAP_JWT_SECRET','LLDAP_JWT_SECRET'), ('authelia','AUTHELIA_SESSION_SECRET','session_secret'), ('lldap','LLDAP_LDAP_USER_PASS','LLDAP_LDAP_USER_PASS'), ('authelia','LLDAP_SVC_AUTHELIA_PASS','ldap_password')] @pytest.fixture def case(tmp_path): private=tmp_path/'private';private.mkdir(mode=0o700) for component,names in VALUES.items(): directory=private/component;directory.mkdir(mode=0o700) env=directory/'secrets.env';env.write_text(''.join(f'{name}={PASSWORD}\n' for name in names));env.chmod(0o600) binary=tmp_path/'bin';binary.mkdir() (binary/'kubectl').write_text('''#!/usr/bin/python3 import json,os,sys from pathlib import Path args=sys.argv[1:] raw=sys.stdin.read() if '-f' in args or '--patch-file=/dev/stdin' in args else '' with open(os.environ['CALLS'],'a') as f:f.write(json.dumps({'args':args,'stdin':raw})+'\\n') if os.environ.get('FAIL')==args[0]: print(os.environ['MARKER']);print(os.environ['MARKER'],file=sys.stderr);sys.exit(1) p=Path(os.environ['STATE']) if args[0]=='get': if os.environ.get('BAD_GET'):print(os.environ['BAD_GET']) elif p.exists():print(p.read_text()) sys.exit(0) if args[0]=='create' and raw: if p.exists() or os.environ.get('RACE'):sys.exit(1) state=json.loads(raw);state['metadata'].update(uid='fixture-uid',resourceVersion='1') elif args[0]=='patch': if os.environ.get('RACE'):sys.exit(1) state=json.loads(p.read_text()) for op in json.loads(raw): parts=[s.replace('~1','/').replace('~0','~') for s in op['path'].split('/')[1:]] parent=state for part in parts[:-1]:parent=parent[part] if op['op']=='test': if parent.get(parts[-1])!=op['value']:sys.exit(1) elif op['op']=='remove':del parent[parts[-1]] elif op['op']=='replace':parent[parts[-1]]=op['value'] else:sys.exit(1) state['metadata']['resourceVersion']=str(int(state['metadata']['resourceVersion'])+1) else:sys.exit(2) p.write_text(json.dumps(state)) if os.environ.get('BAD_RESPONSE'):state['data']['unexpected']='wrong' print(json.dumps(state)) ''') (binary/'kubectl').chmod(0o700) env={'PATH':str(binary)+':'+os.environ['PATH'],'HOME':str(tmp_path), 'CALLS':str(tmp_path/'calls'),'STATE':str(tmp_path/'state'),'MARKER':PASSWORD} return private,env def invoke(case,component,rotate=None,**extra): args=['bash','-x',str(ROOT/f'sso-mfa/k8s/{component}/create-secrets.sh'),str(case[0])] if rotate:args+=['--rotate-key',rotate] return subprocess.run(args,env={**case[1],**extra},capture_output=True,text=True,timeout=20) def calls(case): p=Path(case[1]['CALLS']);return [json.loads(line) for line in p.read_text().splitlines()] if p.exists() else [] def state(case):return json.loads(Path(case[1]['STATE']).read_text()) def put_state(case,obj):Path(case[1]['STATE']).write_text(json.dumps(obj)) def check_private_result(result): assert PASSWORD not in result.stdout+result.stderr assert CHANGED not in result.stdout+result.stderr assert 'BEGIN PRIVATE KEY' not in result.stdout+result.stderr def key_source(component,key):return 'lldap' if key.startswith('LLDAP_') else source(component) def source(component):return {'postgresql':'privacyidea','identity-provisioner':'lldap'}.get(component,component) @pytest.mark.parametrize('component',COMPONENTS) def test_create_uses_literal_inputs_stdin_and_no_apply_annotation(case,component): result=invoke(case,component);assert result.returncode==0,result.stderr;check_private_result(result) assert PASSWORD not in json.dumps([c['args'] for c in calls(case)]) writes=[c for c in calls(case) if c['args'][0]!='get'] assert len(writes)==1 and writes[0]['args']==['create','-o','json','-f','-'] obj=state(case);assert 'annotations' not in obj['metadata'] values={k:base64.b64decode(v).decode() for k,v in obj['data'].items()} if component=='postgresql':assert values=={'username':'privacyidea','password':PASSWORD} elif component=='privacyidea': assert values['PI_PEPPER']==PASSWORD and values['PI_SECRET_KEY']==PASSWORD assert values['PI_SQLALCHEMY_DATABASE_URI']==f'postgresql://privacyidea:{PASSWORD}@net-kingdom-pg-rw.databases.svc.cluster.local:5432/privacyidea_db' elif component=='lldap':assert values=={k:PASSWORD for k in ('LLDAP_JWT_SECRET','LLDAP_LDAP_USER_PASS')} elif component=='identity-provisioner':assert values=={'username':'svc-provisioner','password':PASSWORD} else: key=case[0]/'authelia/oidc_private_key.pem' assert key.stat().st_mode & 0o077==0 and values['oidc_issuer_private_key']==key.read_text() assert bcrypt.checkpw(PASSWORD.encode(),values['keycape_client_secret_hash'].encode()) assert values['ldap_password']==PASSWORD @pytest.mark.parametrize('component',COMPONENTS) def test_repeated_apply_is_noop_and_preserves_other_data(case,component): assert invoke(case,component).returncode==0 old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained'};put_state(case,old) count=len(calls(case));result=invoke(case,component) assert result.returncode==0;check_private_result(result) assert state(case)==old assert all(c['args'][0]=='get' for c in calls(case)[count:]) @pytest.mark.parametrize('component,key,field',ROTATIONS) def test_explicit_rotation_is_conditional_and_changes_only_named_field(case,component,key,field): assert invoke(case,component).returncode==0 old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':'synthetic-old-copy'};put_state(case,old) file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}')) result=invoke(case,component,rotate=key);assert result.returncode==0,result.stderr;check_private_result(result) updated=state(case) assert updated['metadata']['annotations']=={'owner':'retained'} assert updated['data'][field]!=old['data'][field] assert {k:v for k,v in updated['data'].items() if k!=field}=={k:v for k,v in old['data'].items() if k!=field} patch=next(c for c in reversed(calls(case)) if c['args'][0]=='patch') tests={op['path']:op['value'] for op in json.loads(patch['stdin']) if op['op']=='test'} assert tests['/metadata/uid']==old['metadata']['uid'] and tests['/metadata/resourceVersion']==old['metadata']['resourceVersion'] assert PASSWORD not in json.dumps(patch['args']) and CHANGED not in json.dumps(patch['args']) @pytest.mark.parametrize('component',COMPONENTS) def test_differing_values_require_explicit_rotation(case,component): assert invoke(case,component).returncode==0;old=state(case) file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(PASSWORD,CHANGED)) count=len(calls(case));result=invoke(case,component) assert result.returncode!=0;check_private_result(result);assert state(case)==old assert all(c['args'][0]=='get' for c in calls(case)[count:]) @pytest.mark.parametrize('component',COMPONENTS) @pytest.mark.parametrize('defect',['shell','duplicate','public','symlink','hardlink','public-parent']) def test_bad_private_input_never_reaches_cluster(case,component,defect): file=case[0]/source(component)/'secrets.env';marker=case[0]/'executed' if defect=='shell':file.write_text(file.read_text()+f'$(touch {marker})\n') elif defect=='duplicate':file.write_text(file.read_text()*2) elif defect=='public':file.chmod(0o644) elif defect=='public-parent':file.parent.chmod(0o755) elif defect=='hardlink':os.link(file,file.with_name('alias')) else: target=file.with_name('other');file.rename(target);file.symlink_to(target) result=invoke(case,component);assert result.returncode!=0;check_private_result(result) assert not marker.exists() and not calls(case) @pytest.mark.parametrize('component',COMPONENTS) @pytest.mark.parametrize('failure',['get','create']) def test_failed_child_diagnostics_never_escape(case,component,failure): result=invoke(case,component,FAIL=failure);assert result.returncode!=0;check_private_result(result) assert not Path(case[1]['STATE']).exists() @pytest.mark.parametrize('defect',['owner','eso-label','identity','type','basic-auth','data','immutable','missing-uid']) def test_wrong_or_controller_owned_existing_secret_is_refused(case,defect): assert invoke(case,'lldap').returncode==0;old=state(case) if defect=='owner':old['metadata']['ownerReferences']=[{'kind':'ExternalSecret','name':'fixture'}] elif defect=='eso-label':old['metadata']['labels']={'reconcile.external-secrets.io/managed':'true'} elif defect=='missing-uid':old['metadata']['uid']=None elif defect=='identity':old['metadata']['namespace']='other' elif defect=='type':old['type']='kubernetes.io/tls' elif defect=='basic-auth':old['type']='kubernetes.io/basic-auth' elif defect=='data':old['data']['LLDAP_JWT_SECRET']='invalid-base64' else:old['immutable']=True put_state(case,old);count=len(calls(case));result=invoke(case,'lldap') assert result.returncode!=0 and state(case)==old assert all(c['args'][0]=='get' for c in calls(case)[count:]) @pytest.mark.parametrize('component,key,field',ROTATIONS) def test_concurrent_change_stops_rotation_without_retry(case,component,key,field): assert invoke(case,component).returncode==0;old=state(case) file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}')) count=len(calls(case));result=invoke(case,component,rotate=key,RACE='1') assert result.returncode!=0 and state(case)==old;check_private_result(result) assert sum(c['args'][0]=='patch' for c in calls(case)[count:])==1 def test_create_race_never_switches_to_overwrite(case): result=invoke(case,'lldap',RACE='1');assert result.returncode!=0 assert [c['args'][0] for c in calls(case)]==['get','create'] def test_authelia_failure_retains_same_key_on_retry(case): result=invoke(case,'authelia',FAIL='create');assert result.returncode!=0 key=case[0]/'authelia/oidc_private_key.pem';original=key.read_bytes() assert invoke(case,'authelia').returncode==0 and key.read_bytes()==original def test_rotating_session_never_generates_or_replaces_existing_signing_key(case): assert invoke(case,'authelia').returncode==0;old=state(case) (case[0]/'authelia/oidc_private_key.pem').unlink() file=case[0]/'authelia/secrets.env';file.write_text(file.read_text().replace(f'AUTHELIA_SESSION_SECRET={PASSWORD}',f'AUTHELIA_SESSION_SECRET={CHANGED}')) assert invoke(case,'authelia',rotate='AUTHELIA_SESSION_SECRET').returncode==0 assert state(case)['data']['oidc_issuer_private_key']==old['data']['oidc_issuer_private_key'] assert not (case[0]/'authelia/oidc_private_key.pem').exists() def test_wrong_response_is_not_reported_as_success(case): result=invoke(case,'lldap',BAD_RESPONSE='1');assert result.returncode!=0;check_private_result(result) @pytest.mark.parametrize('annotation',['', 'synthetic-old-copy']) def test_annotation_cleanup_preserves_secret_values_and_other_annotations(case,annotation): assert invoke(case,'lldap').returncode==0;old=state(case) old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':annotation};put_state(case,old) result=invoke(case,'lldap');assert result.returncode==0 assert state(case)['data']==old['data'] assert state(case)['metadata']['annotations']=={'owner':'retained'} @pytest.mark.parametrize('component,key,field',ROTATIONS) def test_rotation_refuses_missing_secret_before_create(case,component,key,field): result=invoke(case,component,rotate=key);assert result.returncode!=0 assert all(c['args'][0]=='get' for c in calls(case)) assert not (case[0]/'authelia/oidc_private_key.pem').exists() def test_rotation_cannot_change_an_unrelated_field(case): assert invoke(case,'privacyidea').returncode==0;old=state(case) file=case[0]/'privacyidea/secrets.env';file.write_text(file.read_text().replace(PASSWORD,CHANGED)) result=invoke(case,'privacyidea',rotate='PI_SECRET_KEY');assert result.returncode!=0 assert state(case)==old assert not any(c['args'][0]=='patch' for c in calls(case)) @pytest.mark.parametrize('defect',['symlink','hardlink','public','public-parent','invalid']) def test_explicit_authelia_key_custody_is_validated_without_overwrite(case,defect): key=case[0]/'authelia/external.pem';key.write_text('synthetic-invalid-key');key.chmod(0o600) if defect=='symlink': target=key.with_name('other');key.rename(target);key.symlink_to(target) elif defect=='hardlink':os.link(key,key.with_name('alias')) elif defect=='public':key.chmod(0o644) elif defect=='public-parent':key.parent.chmod(0o755) file=case[0]/'authelia/secrets.env';file.write_text(file.read_text()+f'AUTHELIA_OIDC_PRIVATE_KEY_FILE={key}\n') result=invoke(case,'authelia');assert result.returncode!=0 assert key.read_text()=='synthetic-invalid-key' assert not any(c['args'][0]!='get' for c in calls(case)) def test_native_kubectl_applies_stdin_patch_and_refuses_stale_version(case,tmp_path): import shutil binary=shutil.which('kubectl') if binary is None:pytest.skip('native kubectl not available') assert invoke(case,'lldap').returncode==0;old=state(case) file=case[0]/'lldap/secrets.env';file.write_text(file.read_text().replace(f'LLDAP_JWT_SECRET={PASSWORD}',f'LLDAP_JWT_SECRET={CHANGED}')) assert invoke(case,'lldap',rotate='LLDAP_JWT_SECRET').returncode==0 patch=next(c['stdin'] for c in reversed(calls(case)) if c['args'][0]=='patch') original=tmp_path/'original.json';original.write_text(json.dumps(old));original.chmod(0o600) env={**os.environ,'KUBECONFIG':str(tmp_path/'no-cluster-config'),'HOME':str(tmp_path)} command=[binary,'patch','--local','--type=json','--patch-file=/dev/stdin','-f',str(original),'-o','json'] result=subprocess.run(command,input=patch,env=env,capture_output=True,text=True,timeout=20) assert result.returncode==0 assert json.loads(result.stdout)['data']==state(case)['data'] old['metadata']['resourceVersion']='999';original.write_text(json.dumps(old)) refused=subprocess.run(command,input=patch,env=env,capture_output=True,text=True,timeout=20) assert refused.returncode!=0