net-kingdom/sso-mfa/k8s/lldap/lldap_admin.py
tegwick 2cc09ceb05 Preview one uid's directory groups for sign-in diagnosis (NK-WP-0051-T04).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1041694@bnt-lap001
Assistant-Session: 7af0bb68-ac4a-4ce3-8639-24ddd84c0b52
2026-10-14 23:41:32 +02:00

281 lines
13 KiB
Python

#!/usr/bin/env python3
"""Owner-invoked LDAP group maintenance; no credential discovery or authority grant."""
import argparse
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / 'identity-provisioner'))
from provisioner import LLDAPProvisioner, _service_group_request # noqa: E402
BASELINE = ('net-kingdom-users', 'net-kingdom-admins', 'activity-core-operators')
MAX_BODY = 1024 * 1024
class Refused(Exception):
pass
class Parser(argparse.ArgumentParser):
def error(self, message):
raise Refused()
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise Refused()
result[key] = value
return result
def endpoint(value):
if not isinstance(value, str) or not value.isascii() or any(ord(c) <= 32 for c in value):
raise Refused()
parsed = urllib.parse.urlsplit(value)
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
raise Refused()
if value.rstrip('/') in ('https://lldap.coulomb.social', 'https://lldap.coulomb.social:443'):
return value.rstrip('/')
if parsed.hostname not in ('127.0.0.1', '::1') or parsed.scheme not in ('http', 'https') or parsed.port is None:
raise Refused()
if not 1 <= parsed.port <= 65535:
raise Refused()
expected = f'{parsed.scheme}://{("[::1]" if parsed.hostname == "::1" else parsed.hostname)}:{parsed.port}'
if value.rstrip('/') != expected:
raise Refused()
return expected
def private_password(root):
root = Path(os.path.abspath(root))
if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() for p in (root, *root.parents)):
raise Refused()
spec = importlib.util.spec_from_file_location('lldap_custody', ROOT / 'sso-mfa/bootstrap/apply-service-secrets.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with module.custody(root, ['lldap']) as directories:
value = module.read_env(directories['lldap']).get('LLDAP_LDAP_USER_PASS')
if not isinstance(value, str) or not value or len(value.encode()) > 4096 or any(ord(c) < 32 for c in value):
raise Refused()
return value
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
class Transport:
"""Inject bounded, origin-checked transport into the existing provider."""
def __init__(self, base):
self.base = endpoint(base)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile)
self.opener = urllib.request.build_opener(
urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPSHandler(context=context),
)
def __call__(self, request, timeout):
if request.full_url not in {self.base + '/auth/simple/login', self.base + '/api/graphql'}:
raise Refused()
if request.method != 'POST' or not isinstance(request.data, bytes) or len(request.data) > MAX_BODY:
raise Refused()
try:
with self.opener.open(request, timeout=min(timeout, 15)) as response:
if response.status != 200:
raise Refused()
raw = response.read(MAX_BODY + 1)
if len(raw) > MAX_BODY:
raise Refused()
body = json.loads(raw, object_pairs_hook=unique_object)
if not isinstance(body, dict):
raise Refused()
if request.full_url.endswith('/auth/simple/login'):
token = body.get('token')
if not isinstance(token, str) or not 1 <= len(token) <= 16384 or any(not 33 <= ord(c) <= 126 for c in token):
raise Refused()
return io.BytesIO(raw)
except urllib.error.HTTPError as exc:
exc.close()
raise Refused() from None
except Exception:
raise Refused() from None
def snapshot(provider, token):
value = provider._gql(token, 'query { users { id } groups { id displayName users { id } } }', {})
if set(value) != {'users', 'groups'} or not isinstance(value['users'], list) or not isinstance(value['groups'], list):
raise Refused()
if len(value['users']) > 10000 or len(value['groups']) > 1000:
raise Refused()
users = []
for user in value['users']:
uid = user.get('id') if isinstance(user, dict) else None
if not isinstance(uid, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', uid):
raise Refused()
users.append(uid)
if len(set(users)) != len(users):
raise Refused()
groups = {}
ids = set()
for group in value['groups']:
if not isinstance(group, dict):
raise Refused()
name, gid, members = group.get('displayName'), group.get('id'), group.get('users')
if (not isinstance(name, str) or not name or len(name) > 256 or any(ord(c) < 32 for c in name)
or type(gid) is not int or gid < 1 or not isinstance(members, list)
or name in groups or gid in ids or len(members) > 10000):
raise Refused()
member_ids = [member.get('id') if isinstance(member, dict) else None for member in members]
if any(not isinstance(uid, str) or uid not in users for uid in member_ids) or len(set(member_ids)) != len(member_ids):
raise Refused()
groups[name] = {'id': gid, 'members': sorted(member_ids)}
ids.add(gid)
return {'users': sorted(users), 'groups': groups}
def validate_operation(operation, action=None, group=None, user=None):
if operation not in {'bootstrap', 'membership', 'verify', 'groups'}:
raise Refused()
if operation == 'groups':
# Read-only sign-in diagnosis preview (docs/sign-in-diagnosis.md).
if action is not None or group is not None or not isinstance(user, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', user):
raise Refused()
return
if operation != 'membership':
if any(v is not None for v in (action, group, user)):
raise Refused()
return
if action not in {'add', 'remove', 'list'} or not isinstance(group, str):
raise Refused()
if action == 'list':
if user is not None:
raise Refused()
members = {}
else:
members = {group: [user]}
_service_group_request({'groups': [group], 'members': members,
'idempotency_key': 'owner-group-maintenance', 'correlation_id': 'owner-group-maintenance'})
def maintain(provider, base, operation, *, action=None, group=None, user=None, apply=False, expected_digest=None):
validate_operation(operation, action, group, user)
if (apply and (operation in ('verify', 'groups') or action == 'list')) or (expected_digest is not None and not apply):
raise Refused()
if apply and (not isinstance(expected_digest, str) or not re.fullmatch(r'[0-9a-f]{64}', expected_digest)):
raise Refused()
token = provider._login()
before = snapshot(provider, token)
if operation == 'verify':
present = {name: name in before['groups'] for name in BASELINE}
return {'result': 'structure_observed', 'baseline_groups_present': present,
'directory_user_count': len(before['users']),
'break_glass_account_present': 'break-glass' in before['users'],
'break_glass_platform_membership_present': 'break-glass' in before['groups'].get('net-kingdom-admins', {}).get('members', []),
'native_acceptance': 'unverified',
'remaining': ['governed_lifecycle', 'actual_login_and_mfa', 'issued_claims_and_consumer_access',
'session_revocation', 'independent_recovery']}
if operation == 'groups':
if user not in before['users']:
raise Refused()
names = sorted(name for name, item in before['groups'].items() if user in item['members'])
return {'result': 'groups_observed', 'uid': user, 'groups': names, 'native_acceptance': 'unverified'}
if operation == 'membership' and (group not in before['groups'] or (user is not None and user not in before['users'])):
raise Refused()
binding = {'base': endpoint(base), 'operation': operation, 'action': action, 'group': group, 'user': user, 'before': before}
digest = hashlib.sha256(json.dumps(binding, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
if operation == 'bootstrap':
changes = [{'create_group': name} for name in BASELINE if name not in before['groups']]
elif action == 'list':
return {'result': 'membership_observed', 'group': group, 'members': before['groups'][group]['members'], 'native_acceptance': 'unverified'}
else:
member = user in before['groups'][group]['members']
changes = [{'action': action, 'group': group, 'user': user}] if member != (action == 'add') else []
result = {'operation': operation, 'changes': changes, 'snapshot_sha256': digest, 'native_acceptance': 'unverified'}
if not apply:
return dict(result, result='preview')
if digest != expected_digest:
raise Refused()
# LLDAP has no compare-and-swap transaction here. Caller must hold the
# owner-controlled exclusive window; check again before the first mutation.
if snapshot(provider, token) != before:
raise Refused()
if operation == 'bootstrap':
rows = [{'displayName': name, 'id': item['id']} for name, item in before['groups'].items()]
for change in changes:
provider._ensure_group(token, rows, change['create_group'])
elif changes:
method = provider._add_group if action == 'add' else provider._remove_group
method(token, user, before['groups'][group]['id'])
after = snapshot(provider, token)
expected = json.loads(json.dumps(before))
if operation == 'bootstrap':
for change in changes:
name = change['create_group']
if name not in after['groups'] or after['groups'][name]['members']:
raise Refused()
expected['groups'][name] = after['groups'][name]
elif changes:
members = set(expected['groups'][group]['members'])
if action == 'add':
members.add(user)
else:
members.discard(user)
expected['groups'][group]['members'] = sorted(members)
if after != expected:
raise Refused()
return dict(result, result='verified', recovery='no_automatic_rollback')
def main(argv=None):
phase = 'input'
try:
parser = Parser(description=__doc__)
sub = parser.add_subparsers(dest='operation', required=True, parser_class=Parser)
for operation in ('bootstrap', 'membership', 'verify', 'groups'):
command = sub.add_parser(operation)
command.add_argument('--root', required=True, help='Explicit private custody directory outside Git')
command.add_argument('--lldap-url', default='https://lldap.coulomb.social')
if operation == 'groups':
command.add_argument('--user', required=True)
elif operation != 'verify':
command.add_argument('--apply', action='store_true')
command.add_argument('--expected-digest')
if operation == 'membership':
command.add_argument('action', choices=('add', 'remove', 'list'))
command.add_argument('--group', required=True)
command.add_argument('--user')
args = parser.parse_args(argv)
base = endpoint(args.lldap_url)
action, group, user = (getattr(args, name, None) for name in ('action', 'group', 'user'))
validate_operation(args.operation, action, group, user)
apply = getattr(args, 'apply', False)
expected = getattr(args, 'expected_digest', None)
if (expected is not None and not apply) or (apply and (action == 'list' or not isinstance(expected, str) or not re.fullmatch(r'[0-9a-f]{64}', expected))):
raise Refused()
transport = Transport(base)
password = private_password(args.root)
provider = LLDAPProvisioner(base_url=base, admin_password=password, opener=transport)
phase = 'maintenance' if apply else 'observation'
result = maintain(provider, base, args.operation, action=action, group=group, user=user, apply=apply, expected_digest=expected)
print(json.dumps(result, sort_keys=True))
return 2 if args.operation == 'verify' else 0
except Exception:
print(json.dumps({'result': 'unverified', 'phase': phase, 'recovery': 'reconcile_current_state_before_retry'}), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())