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
281 lines
13 KiB
Python
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())
|