net-kingdom/tests/test_realm_bootstrap.py
tegwick c28651e55b
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0056-T04: svc-privacyidea lane in OpenBao custody with attended write-through
bind-lane svc-privacyidea (CCR-2026-0055) has no workload reader. Setup and
verify prove the value is current by an LLDAP sign-in as svc-privacyidea, fence
coding agents, write KV with CAS=0 and refuse any workload role.
realm_bootstrap.py --rotate-bind rewrites only BINDPW on the established
service bind, --from-openbao reads it in memory under the attended
platform-admin session, and an applied rotation requires --verify-user.
Lane tests: 20; realm_bootstrap tests: 102.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 896183@bnt-lap001
Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2
2026-10-31 11:06:53 +01:00

269 lines
13 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
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)