net-kingdom/tests/test_realm_bootstrap.py
tegwick 145ca8e08c
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Identity provider journey acceptance / provider (push) Failing after 12s
Build and Publish identity-provisioner / build-and-push (push) Successful in 50s
NK-WP-0054-T05 phase 2b: each directory consumer binds with its own account
Custody gains LLDAP_SVC_{AUTHELIA,KEYCAPE,PRIVACYIDEA,PROVISIONER}_PASS and
LLDAP_LDAP_USER_PASS becomes ld-admin's. Authelia, KeyCape, the privacyIDEA
resolver and identity-provisioner (new Secret identity-provisioner-ldap) switch
to svc-* accounts; LLDAP's configured super-admin is ld-admin. The LLDAP image
pin waits for the running digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
2026-10-28 20:33:04 +01:00

207 lines
9.8 KiB
Python

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