"""Configuration-preserving realm repair, private input and real HTTP boundaries.""" import copy import importlib import json import os from pathlib import Path import subprocess import sys import threading from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from urllib.parse import urlsplit import pytest ROOT=Path(__file__).resolve().parents[1] HELPERS=ROOT/'sso-mfa/k8s/privacyidea' sys.path.insert(0,str(HELPERS)) r=importlib.import_module('realm_bootstrap') p=importlib.import_module('pi_api') def snapshot(): return {'resolver':{'type':'ldapresolver','data':dict(r.CORE,**{**r.TUNING,'SIZELIMIT':'700'},BINDPW='__CENSORED__',TLS_VERIFY='True')}, 'realms':{'coulomb':{'resolver':[{'name':r.RESOLVER,'priority':5}], 'default':False}, 'other':{'resolver':[], 'default':True}}} class Fake: def __init__(self,state): self.state=copy.deepcopy(state);self.calls=[];self.fail=None;self.drift=None;self.reads=0 def snapshot(self): self.reads+=1 if self.drift and self.reads==2:self.state['realms']['other']['default']=False return copy.deepcopy(self.state) def call(self,path,payload): self.calls.append((path,copy.deepcopy(payload))) if self.fail==path:raise r.Refused() if path=='/resolver/'+r.RESOLVER: if self.state['resolver'] is None:self.state['resolver']={'type':'ldapresolver','data':{}} self.state['resolver']['data'].update({k:('__CENSORED__' if k=='BINDPW' else v) for k,v in payload.items() if k!='type'}) return 1 if path=='/realm/coulomb': self.state['realms']['coulomb']={'resolver':[{'name':r.RESOLVER,'priority':1}],'default':False} return {'added':[r.RESOLVER],'failed':[]} if path=='/defaultrealm/coulomb':self.state['realms']['coulomb']['default']=True;return 1 raise AssertionError('unexpected write') def test_existing_configuration_is_noop_without_reading_bind_password(): client=Fake(snapshot());before=copy.deepcopy(client.state) def forbidden():raise AssertionError('existing bind must not be requested') report=r.repair(client,forbidden,apply=True) assert report['configuration_writes']==0 and client.state==before and not client.calls def test_missing_tuning_is_minimal_and_preserves_nondefault_values(): client=Fake(snapshot());del client.state['resolver']['data']['TIMEOUT'];before=copy.deepcopy(client.state) report=r.repair(client,lambda:None,apply=True) assert report['configuration_writes']==1 assert client.calls==[('/resolver/'+r.RESOLVER,{'TIMEOUT':'5','type':'ldapresolver'})] before['resolver']['data']['TIMEOUT']='5';assert client.state==before def test_missing_objects_are_created_and_other_default_is_preserved(): state=snapshot();state['resolver']=None;del state['realms']['coulomb'];client=Fake(state) result=r.repair(client,lambda:'fixture-bind',apply=True) assert result['actions']==['create-resolver','create-realm'] assert client.state['realms']['other']==state['realms']['other'] assert all(not route.startswith('/policy') for route,_ in client.calls) def test_first_realm_gets_explicit_initial_default(): client=Fake({'resolver':None,'realms':{}}) result=r.repair(client,lambda:'fixture-bind',apply=True) assert result['configuration_writes']==3 and client.state['realms']['coulomb']['default'] is True def test_preview_has_no_configuration_writes_or_bind_prompt(): client=Fake({'resolver':None,'realms':{}}) report=r.repair(client,lambda:(_ for _ in ()).throw(AssertionError()),apply=False) assert report['result']=='planned' and not client.calls @pytest.mark.parametrize('field,value',[('type','other'),('LDAPURI','ldap://other'),('TIMEOUT','invalid')]) def test_conflicting_profile_refused_before_any_write(field,value): state=snapshot() if field=='type':state['resolver']['type']=value else:state['resolver']['data'][field]=value client=Fake(state) with pytest.raises(r.Refused):r.repair(client,lambda:'fixture-bind',apply=True) assert not client.calls def test_existing_wrong_realm_binding_refused_before_resolver_write(): state=snapshot();state['realms']['coulomb']['resolver']=[];del state['resolver']['data']['TIMEOUT'];client=Fake(state) with pytest.raises(r.Refused):r.repair(client,lambda:None,apply=True) assert not client.calls def test_preflight_drift_refused(): client=Fake(snapshot());del client.state['resolver']['data']['TIMEOUT'];client.drift=True with pytest.raises(r.Refused):r.repair(client,lambda:None,apply=True) assert not client.calls def test_partial_failure_stops_without_policy_or_default_writes(): client=Fake({'resolver':None,'realms':{}});client.fail='/realm/coulomb' with pytest.raises(r.Refused):r.repair(client,lambda:'fixture-bind',apply=True) assert len(client.calls)==2 and client.state['resolver'] is not None @pytest.fixture def server(): state={'requests':[],'response':{'result':{'status':True,'value':{}}},'status':200} class Handler(BaseHTTPRequestHandler): def log_message(self,*_):pass def do_GET(self):self.respond() def do_POST(self):self.respond() def respond(self): body=self.rfile.read(int(self.headers.get('Content-Length',0))) state['requests'].append((self.path,dict(self.headers),body)) self.send_response(state['status']);self.send_header('Location','/leak');self.end_headers() self.wfile.write(state.get('raw',json.dumps(state['response']).encode())) s=ThreadingHTTPServer(('127.0.0.1',0),Handler);t=threading.Thread(target=s.serve_forever,daemon=True);t.start() try:yield f'http://127.0.0.1:{s.server_port}',state finally:s.shutdown();s.server_close();t.join() @pytest.mark.parametrize('status',[301,302,307,308,401,403,500]) def test_transport_does_not_follow_or_render_provider_failure(server,status): url,state=server;state['status']=status;state['raw']=b'private-provider-error' code,body=p.request(url+'/auth',{'password':'synthetic-password'},'synthetic-token') assert code==status and body is None and len(state['requests'])==1 def test_transport_ignores_proxy_and_preserves_bodyless_get(server,monkeypatch): url,state=server for name in ['http_proxy','HTTP_PROXY']:monkeypatch.setenv(name,'http://127.0.0.1:1') for name in ['no_proxy','NO_PROXY']:monkeypatch.setenv(name,'') assert p.request(url+'/realm/',token='fixture-token')[0]==200 assert not state['requests'][0][2] and 'Content-Type' not in state['requests'][0][1] @pytest.mark.parametrize('raw',[b'x'*(p.MAX_BODY+1),b'{"result":{"status":true,"status":false}}',b'bad-json'],ids=['oversized','duplicate','invalid']) def test_transport_invalid_response_is_not_forwarded(server,raw): url,state=server;state['raw']=raw assert p.request(url+'/realm/')[1] is None def test_transport_refuses_unapproved_destination(): assert p.request('https://other.invalid/auth',{'password':'synthetic'})==(0,None) @pytest.fixture def private_root(tmp_path): for component,key in [('privacyidea','PI_ADMIN_PASSWORD'),('lldap','LLDAP_SVC_PRIVACYIDEA_PASS')]: directory=tmp_path/component;directory.mkdir(mode=0o700) f=directory/'secrets.env';f.write_text(key+'=literal-$(never-execute)-quote\'"\\end\n');f.chmod(0o600) return tmp_path def test_private_env_is_literal_not_shell(private_root): a,b=r.private_inputs(private_root);assert a==b=="literal-$(never-execute)-quote'\"\\end" @pytest.mark.parametrize('kind',['symlink','hardlink','fifo','public-file','public-dir','duplicate','invalid']) def test_private_input_rejected_before_network(private_root,server,kind): f=private_root/'privacyidea/secrets.env' if kind=='symlink':f.rename(f.with_suffix('.real'));f.symlink_to(f.with_suffix('.real')) elif kind=='hardlink':os.link(f,f.with_suffix('.link')) elif kind=='fifo':f.unlink();os.mkfifo(f,0o600) elif kind=='public-file':f.chmod(0o644) elif kind=='public-dir':f.parent.chmod(0o755) elif kind=='duplicate':f.write_text('PI_ADMIN_PASSWORD=one\nPI_ADMIN_PASSWORD=two\n') elif kind=='invalid':f.write_text('touch forbidden-marker\n') url,state=server proc=subprocess.run(['bash','-x',str(HELPERS/'bootstrap-realm.sh'),str(private_root),url,'--apply'],capture_output=True,text=True,timeout=10) assert proc.returncode==1 and not proc.stdout and not state['requests'] assert 'literal-' not in proc.stderr and str(f) not in proc.stderr def test_attended_wrapper_refuses_without_a_terminal(): proc=subprocess.run(['bash','-x',str(HELPERS/'repair-realm-live.sh')],stdin=subprocess.DEVNULL,capture_output=True,text=True) assert proc.returncode==1 and not proc.stdout and '"phase": "input"' in proc.stderr def test_real_provider_merge_and_policy_preservation(): pytest.importorskip('privacyidea') proc=subprocess.run([sys.executable,str(ROOT/'tests/realm_bootstrap_native_fixture.py')],capture_output=True,text=True,timeout=120) assert proc.returncode==0,proc.stdout result=json.loads(proc.stdout);assert len(result)==6 and all(v is True for v in result.values()) @pytest.mark.parametrize('role',[None,'user']) def test_login_must_confirm_admin_role(server,role): url,state=server;state['response']={'result':{'status':True,'value':{'token':'fixture-token','role':role}}} with pytest.raises(r.Refused):r.Client(url,'fixture-password') assert len(state['requests'])==1 and state['requests'][0][0]=='/auth' def test_default_readback_refuses_unexpected_other_realm(): class Concurrent(Fake): def call(self,path,payload): value=super().call(path,payload) if path=='/defaultrealm/coulomb':self.state['realms']['unreviewed']={'default':False,'resolver':[]} return value client=Concurrent({'resolver':None,'realms':{}}) with pytest.raises(r.Refused):r.repair(client,lambda:'fixture-bind',apply=True) assert len(client.calls)==3 def legacy(): state=snapshot();state['resolver']['data']['BINDDN']=r.LEGACY_BINDDN;return state def test_legacy_human_bind_is_refused_without_explicit_switch(): with pytest.raises(r.Refused):r.repair(Fake(legacy()),lambda:'unused',apply=True) def test_switch_bind_changes_only_the_bind_and_needs_the_service_password(): client=Fake(legacy());before=copy.deepcopy(client.state) preview=r.repair(Fake(legacy()),lambda:(_ for _ in ()).throw(AssertionError('no prompt in preview')),switch_bind=True) assert preview['actions']==['switch-bind'] and preview['configuration_writes']==0 report=r.repair(client,lambda:'svc-privacyidea-synthetic',apply=True,switch_bind=True) assert report['actions']==['switch-bind'] and report['configuration_writes']==1 assert client.calls==[('/resolver/'+r.RESOLVER,{'type':'ldapresolver','BINDDN':r.CORE['BINDDN'],'BINDPW':'svc-privacyidea-synthetic'})] before['resolver']['data']['BINDDN']=r.CORE['BINDDN'];assert client.state==before assert r.repair(client,lambda:'unused',apply=True,switch_bind=True)['configuration_writes']==0 def test_switch_bind_refuses_other_drift_or_unknown_bind(): state=legacy();del state['resolver']['data']['TIMEOUT'] with pytest.raises(r.Refused):r.repair(Fake(state),lambda:'x',apply=True,switch_bind=True) state=snapshot();state['resolver']['data']['BINDDN']='uid=someone,ou=people,dc=netkingdom,dc=local' with pytest.raises(r.Refused):r.repair(Fake(state),lambda:'x',apply=True,switch_bind=True) def test_rotate_bind_rewrites_only_the_password_on_the_service_bind(): client=Fake(snapshot());before=copy.deepcopy(client.state) preview=r.repair(Fake(snapshot()),lambda:(_ for _ in ()).throw(AssertionError('no read in preview')),rotate_bind=True) assert preview['actions']==['rotate-bind'] and preview['configuration_writes']==0 report=r.repair(client,lambda:'rotated-synthetic',apply=True,rotate_bind=True) assert report['actions']==['rotate-bind'] and report['configuration_writes']==1 assert client.calls==[('/resolver/'+r.RESOLVER,{'type':'ldapresolver','BINDDN':r.CORE['BINDDN'],'BINDPW':'rotated-synthetic'})] assert client.state==before def test_rotate_bind_refuses_legacy_bind_pending_repairs_or_combined_switch(): with pytest.raises(r.Refused):r.repair(Fake(legacy()),lambda:'x',apply=True,rotate_bind=True) state=snapshot();del state['resolver']['data']['TIMEOUT'] with pytest.raises(r.Refused):r.repair(Fake(state),lambda:'x',apply=True,rotate_bind=True) with pytest.raises(r.Refused):r.repair(Fake(snapshot()),lambda:'x',apply=True,rotate_bind=True,switch_bind=True) class Run: def __init__(self,policies=('platform-admin',),path='auth/netkingdom/oidc/callback',data=None): self.out={('token','lookup'):{'data':{'policies':list(policies),'path':path}}, ('read',):{'data':{'data':data if data is not None else {'LDAP_BIND_PASSWORD':'from-openbao-synthetic'}}}} self.argv=[] def __call__(self,argv): self.argv.append(argv) key=('token','lookup') if argv[1:3]==['token','lookup'] else ('read',) if key==('read',):assert argv[-1]==r.BIND_KV return subprocess.CompletedProcess(argv,0,json.dumps(self.out[key]).encode(),b'') def test_openbao_bind_reads_only_under_attended_platform_admin(): assert r.openbao_bind(Run())=='from-openbao-synthetic' for run in (Run(policies=('default',)),Run(policies=('platform-admin','root')),Run(path='auth/kubernetes/login'), Run(data={'LDAP_BIND_PASSWORD':'x','OTHER':'y'})): with pytest.raises(r.Refused):r.openbao_bind(run)