net-kingdom/sso-mfa/k8s/lldap/reset-authority-observe.py
tegwick 27b4ea6933 Prepare attended read-only classification for accepted reset subject
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-31 13:54:51 +01:00

160 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Attended, read-only reset-role classification; no credential discovery or grant."""
import argparse
import getpass
import hashlib
import importlib.util
import json
import os
import re
import stat
import sys
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
SPEC = importlib.util.spec_from_file_location('reset_observer_transport', HERE / 'lldap_admin.py')
transport = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(transport)
QUERY = 'query ResetRoles($id:String!){user(userId:$id){id groups{displayName}}}'
ROLES = ('lldap_admin', 'lldap_password_manager', 'lldap_strict_readonly')
# Coulomb's retained personal-reset acceptance, 2026-10-06. No caller-selected live subject.
ACCEPTED_SUBJECT = 'tegwick'
ROOT = HERE.parents[2]
SOURCES = (Path(__file__), HERE / 'lldap_admin.py',
ROOT / 'identity-provisioner/provisioner.py',
ROOT / 'identity-provisioner/group_provenance.py')
class Refused(Exception):
pass
class Parser(argparse.ArgumentParser):
def error(self, message):
raise Refused('invalid_arguments')
def identifier(value):
if not isinstance(value, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', value):
raise Refused('invalid_subject_identifier')
return value
class Observer:
def __init__(self, endpoint, opener=None):
self.base = transport.endpoint(endpoint)
self.opener = opener or transport.Transport(self.base)
def post(self, path, payload, token=None):
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = 'Bearer ' + token
request = urllib.request.Request(self.base + path, data=json.dumps(payload).encode(),
headers=headers, method='POST')
return json.load(self.opener(request, timeout=15))
def classify(self, token, subject):
body = self.post('/api/graphql', {'query': QUERY, 'variables': {'id': subject}}, token)
if not isinstance(body, dict) or body.get('errors') or set(body) != {'data'}:
raise Refused('classification_unavailable')
data = body['data']
if not isinstance(data, dict) or set(data) != {'user'}:
raise Refused('invalid_classification_response')
user = data['user']
if not isinstance(user, dict) or set(user) != {'id', 'groups'} or user['id'] != subject:
raise Refused('subject_not_exactly_observed')
groups = user['groups']
if not isinstance(groups, list) or len(groups) > 1000:
raise Refused('invalid_group_metadata')
names = []
for group in groups:
if not isinstance(group, dict) or set(group) != {'displayName'}:
raise Refused('invalid_group_metadata')
name = group['displayName']
if (not isinstance(name, str) or not 1 <= len(name) <= 256
or any(ord(c) < 32 for c in name) or name in names):
raise Refused('invalid_group_metadata')
names.append(name)
builtin = {role: role in names for role in ROLES}
return {'builtin_roles': builtin, 'other_group_count': len(set(names) - set(ROLES)),
'matches_reviewed_service_name_filter':
subject.startswith('svc-') or subject == 'ld-admin',
'application_authority_assessed': False}
def observe(self, subject, password):
subject = identifier(subject)
if not isinstance(password, str) or not password or len(password.encode()) > 4096:
raise Refused('invalid_private_input')
body = self.post('/auth/simple/login', {'username': 'admin', 'password': password})
token = body.get('token') if isinstance(body, dict) else None
if (not isinstance(token, str) or not token or len(token) > 16384
or any(not 33 <= ord(c) <= 126 for c in token)):
raise Refused('admin_authentication_unavailable')
admin = self.classify(token, 'admin')
if not admin['builtin_roles']['lldap_admin']:
raise Refused('human_admin_role_not_observed')
bind = self.classify(token, 'svc-authelia')
selected = self.classify(token, subject)
return {'schema': 'netkingdom.reset-role-observation.v1',
'status': 'current_reset_roles_observed',
'observed_at': datetime.now(UTC).isoformat(),
'endpoint_sha256': hashlib.sha256(self.base.encode()).hexdigest(),
'selected_subject_sha256': hashlib.sha256(
('reset-role-subject-v1:' + subject).encode()).hexdigest(),
'human_admin_role_verified': True, 'bind': bind, 'selected_subject': selected,
'bind_has_multiple_builtin_roles': sum(bind['builtin_roles'].values()) > 1,
'membership_changed': False, 'password_reset_attempted': False,
'grant_approved': False, 'application_access_verified': False,
'credential_values_recorded': False,
'sources': [{'path': str(p.relative_to(ROOT)),
'sha256': hashlib.sha256(p.read_bytes()).hexdigest()}
for p in SOURCES]}
def receipt_path(value):
path = Path(os.path.abspath(Path(value).expanduser()))
if any(p.is_symlink() for p in (path, *path.parents)):
raise Refused('receipt_symlink_refused')
if any((p / '.git').exists() for p in path.parents):
raise Refused('receipt_must_be_outside_repository')
parent = path.parent.stat()
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.getuid() or parent.st_mode & 0o077:
raise Refused('private_owned_receipt_directory_required')
if path.exists():
raise Refused('fresh_receipt_required')
return path
def main(argv=None):
parser = Parser(description=__doc__)
parser.add_argument('--endpoint', default='https://lldap.coulomb.social')
parser.add_argument('--receipt', required=True)
try:
args = parser.parse_args(argv)
if not sys.stdin.isatty() or not sys.stderr.isatty():
raise Refused('human_terminal_required')
path = receipt_path(args.receipt)
observer = Observer(args.endpoint)
password = getpass.getpass('Password of the human admin account (hidden): ')
result = observer.observe(ACCEPTED_SUBJECT, password)
raw = (json.dumps(result, indent=2) + '\n').encode()
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, 'wb') as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
print(raw.decode(), end='')
return 0
except Exception as error: # noqa: BLE001 -- every private-input failure must redact its body.
# Never include transport bodies, usernames, credentials or arbitrary exception text.
print(json.dumps({'status': 'observation_refused',
'reason': str(error) if isinstance(error, Refused)
else 'observation_unavailable'}), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())