"""Local LLDAP + privacyIDEA 3.12.2 bind/cutover/HOTP proof; synthetic data only.""" import contextlib import io import json import logging import os from pathlib import Path import sys import tempfile import threading def provider_run(lldap_url, ldap_url, replacement): for name in list(os.environ): if name.startswith('PRIVACYIDEA_'):del os.environ[name] logging.disable(logging.CRITICAL) sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'sso-mfa/k8s/privacyidea')) import realm_bootstrap as r import resolver_cutover as cut from privacyidea.lib.token import init_token from privacyidea.lib.user import User r.CORE = dict(LDAPURI=ldap_url, LDAPBASE="dc=example,dc=test", BINDDN="uid=admin,ou=people,dc=example,dc=test") from privacyidea.app import create_app from privacyidea.models import db from privacyidea.lib.auth import create_db_admin from privacyidea.lib.policy import PolicyClass,set_policy from privacyidea.lib.resolver import get_resolver_config,save_resolver from privacyidea.lib.realm import set_realm,set_default_realm from werkzeug.serving import make_server with tempfile.TemporaryDirectory(prefix='realm-native-') as directory: root=Path(directory);(root/'enckey').write_bytes(os.urandom(96)) cfg=root/'fixture.cfg' config=dict(SQLALCHEMY_DATABASE_URI='sqlite:///'+str(root/'db.sqlite'),SECRET_KEY='fixture-secret-key', PI_PEPPER='fixture-pepper',PI_ENCFILE=str(root/'enckey'),PI_NO_RESPONSE_SIGN=True, PI_AUDIT_NO_SIGN=True,PI_LOGFILE=str(root/'log'),PI_TRUSTED_JWT=[], PI_AUDIT_SQL_URI='sqlite:///'+str(root/'audit.sqlite')) cfg.write_text('\n'.join(k+'='+repr(v) for k,v in config.items())) app=create_app(config_name='testing',config_file=str(cfg),silent=True) with app.app_context(): assert str(db.engine.url)==config['SQLALCHEMY_DATABASE_URI'];db.create_all() from privacyidea.lib.audit import getAudit from privacyidea.lib.auditmodules.sqlaudit import LogEntry LogEntry.__table__.create(getAudit(app.config).engine,checkfirst=True) create_db_admin('pi-admin',password='fixture-admin-password') policies=PolicyClass().policies server=make_server('127.0.0.1',0,app);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() try: client=r.Client(f'http://127.0.0.1:{server.server_port}','fixture-admin-password') r.repair(client,lambda:'synthetic-invalid-predecessor',apply=True) with app.app_context(): save_resolver({'resolver':r.RESOLVER,'type':'ldapresolver','TIMEOUT':'7','SIZELIMIT':'777','CACHE_TIMEOUT':'0'}) set_realm('other',[]);set_default_realm('other') set_policy(name='mfa-passthru-phase1',scope='authentication',action='passthru',realm='coulomb',active=False) policies=PolicyClass().policies target=dict(helper='update',pi_url=client.url,lldap_url=lldap_url,user='admin') before=client.snapshot() try:cut.native_bind(client,before) except r.Refused:pass else:raise AssertionError('old bind unexpectedly works') preview=cut.run(client,target,'preview') assert client.snapshot()==before report=cut.run(client,target,'apply',replacement=replacement,expected_digest=preview['snapshot_sha256']) assert report['result']=='configured' and report['configuration_writes']==1 assert cut.public_snapshot(client.snapshot())==cut.public_snapshot(before) with app.app_context(): assert get_resolver_config(r.RESOLVER)['BINDPW']==replacement assert PolicyClass().policies==policies token=init_token({'type':'hotp','genkey':1,'pin':''},user=User('admin',r.REALM,r.RESOLVER)) otp=token.get_otp()[2] target['helper']='reconcile' # Fixture has actual PI/LDAP/MFA; cluster and KeyCape gates are explicitly substituted. report=cut.run(client,target,'check',replacement=replacement,predecessor='synthetic-invalid-predecessor', gate=lambda:None,mfa_proof=lambda:cut.verify(client.url,'admin',r.REALM,r.RESOLVER, 'fixture-admin-password',lambda:otp)) assert report['result']=='verified' and report['mfa_verified'] and report['configuration_writes']==0 return {'native_candidate_and_stored_ldap_bind':True,'native_old_bind_refused':True, 'native_minimal_password_update':True,'native_custom_config_and_realms_preserved':True, 'native_policies_preserved':True,'native_exact_user_and_hotp':True, 'synthetic_wrong_predecessor_http_denied':True,'cluster_and_keycape_gates_substituted':True, 'production_acceptance':False} finally:server.shutdown();thread.join();server.server_close() IMAGE = 'sha256:2a8454b668c1aba7157e832eab0e242e1e7eb5fb7591d7e7774ba05286511ca8' def run(): import re import secrets import subprocess import time sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'sso-mfa/k8s/privacyidea')) from resolver_cutover import login def command(args): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=60) if result.returncode:raise RuntimeError('fixture command failed') return result.stdout.strip() contexts=json.loads(command(['context','inspect'])) # DOCKER_CONTEXT takes precedence over DOCKER_HOST. Reject any remote # configured endpoint, including conflicting environment selections. hosts=[contexts[0]['Endpoints']['docker']['Host']] if os.environ.get('DOCKER_HOST'):hosts.append(os.environ['DOCKER_HOST']) assert all(host.startswith('unix://') for host in hosts) assert command(['image','inspect',IMAGE,'--format','{{.Id}}'])==IMAGE container=None try: with tempfile.TemporaryDirectory(prefix='resolver-lldap-native-') as directory: root=Path(directory);password=secrets.token_hex(24) source=root/'container.env' fd=os.open(source,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) with os.fdopen(fd,'w') as stream: stream.write('LLDAP_LDAP_USER_PASS='+password+'\nLLDAP_JWT_SECRET='+secrets.token_hex(32)+'\nLLDAP_LDAP_BASE_DN=dc=example,dc=test\n') container=command(['run','--detach','--rm','--pull=never','--memory=256m','--cpus=0.5','--pids-limit=128', '--publish','127.0.0.1::17170','--publish','127.0.0.1::3890', '--tmpfs','/data:rw,nosuid,nodev,size=32m,uid=1000,gid=1000','--env-file',str(source),IMAGE]) assert re.fullmatch(r'[0-9a-f]{64}',container) ports=json.loads(command(['inspect',container,'--format','{{json .NetworkSettings.Ports}}'])) assert all(ports[p][0]['HostIp']=='127.0.0.1' for p in ('17170/tcp','3890/tcp')) base='http://127.0.0.1:'+ports['17170/tcp'][0]['HostPort'] ldap='ldap://127.0.0.1:'+ports['3890/tcp'][0]['HostPort'] for _ in range(80): try:login(base,password);break except Exception:time.sleep(.25) else:raise RuntimeError('fixture startup failed') result=provider_run(base,ldap,password) finally: if container and re.fullmatch(r'[0-9a-f]{64}',container):command(['rm','--force',container]) result['fixture_removed']=True return result if __name__=='__main__': try: with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):result=run() print(json.dumps(result,sort_keys=True)) except Exception as exc: import traceback frame=traceback.extract_tb(exc.__traceback__)[-1] print(json.dumps({'result':'failed','type':type(exc).__name__,'file':Path(frame.filename).name,'line':frame.lineno})) raise SystemExit(1)