net-kingdom/sso-mfa/k8s/lldap/reset-role-transition.py
tegwick c07e6ebe34 Prepare guarded reset-role transition and native recovery proof
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-31 14:26:17 +01:00

398 lines
19 KiB
Python

#!/usr/bin/env python3
"""Fixed-target, attended reset authority transition; preparation never grants access."""
import copy
import fcntl
import getpass
import hashlib
import importlib.util
import json
import os
import shlex
import stat
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from types import SimpleNamespace
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
SPEC = importlib.util.spec_from_file_location('reset_role_observer', HERE / 'reset-authority-observe.py')
observer = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(observer)
OLD = 'lldap_strict_readonly'
NEW = 'lldap_password_manager'
TARGET = 'svc-authelia'
SUBJECT = 'tegwick'
ENDPOINT = 'https://lldap.coulomb.social'
PLAN = HERE / 'reset-role-plan.json'
RUNTIME_CODE = '''import json,subprocess,hashlib,re
def get(kind,name,namespace):
args=['k3s','kubectl','get',kind,name,'-o','json']
if namespace:args+=['-n',namespace]
r=subprocess.run(args,capture_output=True,check=True)
return json.loads(r.stdout)
out={'schema':'netkingdom.reset-runtime.v1'}
for name in ['kube-system','sso']:
out[name+'_uid']=get('namespace',name,None)['metadata']['uid']
for name in ['authelia','lldap']:
d=get('deployment',name,'sso');s=d['status'];m=d['metadata']
out[name]={'uid':m['uid'],'images':[x['image'] for x in d['spec']['template']['spec']['containers']],
'ready':s.get('observedGeneration')==m['generation'] and s.get('readyReplicas')==d['spec'].get('replicas',1)}
c=get('configmap','authelia-config','sso');raw=c['data']['configuration.yml']
out['authelia_configuration']={'uid':c['metadata']['uid'],'sha256':hashlib.sha256(raw.encode()).hexdigest(),
'bind_is_svc_authelia':bool(re.search(r'^\\s+user:\\s*uid=svc-authelia,ou=people,dc=netkingdom,dc=local\\s*$',raw,re.M))}
print(json.dumps(out))
'''
class Refused(Exception):
pass
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(',', ':')).encode()
def digest(value):
return hashlib.sha256(canonical(value)).hexdigest()
def observe_runtime():
result = subprocess.run(['ssh', 'railiance01', 'sudo -n python3 -c ' + shlex.quote(RUNTIME_CODE)],
capture_output=True, timeout=45, check=False)
if result.returncode:
raise Refused('runtime_observation_unavailable')
return json.loads(result.stdout)
def verify_runtime(expected, *, recovering=False):
actual = observe_runtime()
expected = copy.deepcopy(expected)
if recovering:
# Recovery must remain possible during an outage; identities/images/config stay pinned.
for component in ('authelia', 'lldap'):
actual.get(component, {}).pop('ready', None)
expected.get(component, {}).pop('ready', None)
if actual != expected:
raise Refused('reviewed_runtime_changed')
def proposal():
value = json.loads(PLAN.read_bytes())
if (value.get('target') != TARGET or value.get('subject') != SUBJECT
or value.get('endpoint') != ENDPOINT or value.get('before_roles') != [OLD]
or value.get('after_roles') != [NEW]):
raise Refused('fixed_reviewed_scope_required')
paths = [Path(__file__), *observer.SOURCES,
ROOT / 'docs/evidence/2026-10-31-reset-authority/current-roles.json',
ROOT / 'docs/evidence/2026-10-31-reset-authority/native-matrix.json',
ROOT / 'docs/evidence/2026-10-31-reset-authority/transition-validation.json']
value['sources'] = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in paths}
return value, 'sha256:' + digest(value)
class Directory:
def __init__(self, endpoint, password, opener=None):
self.reader = observer.Observer(endpoint, opener)
body = self.reader.post('/auth/simple/login', {'username': 'admin', 'password': password})
self.token = body.get('token') if isinstance(body, dict) else None
if (not isinstance(self.token, str) or not 1 <= len(self.token) <= 16384
or any(not 33 <= ord(c) <= 126 for c in self.token)):
raise Refused('human_admin_authentication_required')
def gql(self, token, query, variables):
value = self.reader.post('/api/graphql', {'query': query, 'variables': variables}, token)
if not isinstance(value, dict) or value.get('errors') or not isinstance(value.get('data'), dict):
raise Refused('directory_request_refused')
return value['data']
def snapshot(self):
return observer.transport.snapshot(SimpleNamespace(_gql=self.gql), self.token)
def membership(self, action, group_id):
if action not in {'add', 'remove'} or type(group_id) is not int:
raise Refused('fixed_membership_operation_required')
operation = 'addUserToGroup' if action == 'add' else 'removeUserFromGroup'
result = self.gql(self.token, 'mutation($u:String!,$g:Int!){' + operation
+ '(userId:$u,groupId:$g){ok}}', {'u': TARGET, 'g': group_id})
if result != {operation: {'ok': True}}:
raise Refused('membership_result_unverified')
def memberships(state, user):
if user not in state['users']:
raise Refused('required_subject_missing')
return sorted(g for g, value in state['groups'].items() if user in value['members'])
def validate(state, granting=True):
for name in (OLD, NEW, 'lldap_admin'):
if name not in state['groups']:
raise Refused('builtin_group_missing')
if 'lldap_admin' not in memberships(state, 'admin'):
raise Refused('human_admin_role_required')
if granting and 'lldap_admin' in memberships(state, SUBJECT):
raise Refused('accepted_subject_now_directory_admin')
if memberships(state, TARGET) not in ([], [OLD], [NEW]):
raise Refused('unexpected_target_membership')
def expected_state(state, target_group):
result = copy.deepcopy(state)
for name in (OLD, NEW):
members = result['groups'][name]['members']
if TARGET in members:
members.remove(TARGET)
if name == target_group:
members.append(TARGET)
members.sort()
return result
class Journal:
"""Private operator state, with an exclusive lock and durable write-before-effect."""
def __init__(self, path):
self.path = Path(os.path.abspath(Path(path).expanduser()))
if any(p.is_symlink() for p in (self.path, *self.path.parents)):
raise Refused('state_symlink_refused')
if any((p / '.git').exists() for p in (self.path, *self.path.parents)):
raise Refused('state_must_be_outside_repository')
parent = self.path.parent.stat()
if parent.st_uid != os.getuid() or parent.st_mode & 0o077:
raise Refused('private_owned_state_parent_required')
self.path.mkdir(mode=0o700, exist_ok=True)
info = self.path.stat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise Refused('private_owned_state_required')
fd = os.open(self.path / 'execute.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1:
os.close(fd)
raise Refused('private_single_link_lock_required')
self.lock = os.fdopen(fd, 'r+')
try:
fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self.lock.close()
raise Refused('another_transition_is_active') from None
def close(self):
self.lock.close()
def read(self):
path = self.path / 'journal.json'
if path.is_symlink():
raise Refused('journal_symlink_refused')
if not path.exists():
return None
info = path.lstat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or info.st_mode & 0o077 or info.st_nlink != 1):
raise Refused('private_single_link_journal_required')
if info.st_size > 65536:
raise Refused('oversized_journal')
return json.loads(path.read_bytes())
def archive(self, raw):
path = self.path / ('history-' + hashlib.sha256(raw).hexdigest() + '.json')
if path.exists() or path.is_symlink():
info = path.lstat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or info.st_mode & 0o077 or info.st_nlink != 1 or path.read_bytes() != raw):
raise Refused('history_integrity_failure')
return
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())
def preserve_interrupted_write(self):
"""Recovery only: retain even a partial local write; never infer an LDAP result."""
path = self.path / 'journal.next'
if not path.exists() and not path.is_symlink():
return
info = path.lstat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 65536):
raise Refused('unsafe_interrupted_journal')
self.archive(path.read_bytes())
path.unlink()
def write(self, value):
prior = self.read()
if prior is not None:
self.archive((self.path / 'journal.json').read_bytes())
temporary = self.path / 'journal.next'
if temporary.exists() or temporary.is_symlink():
raise Refused('interrupted_journal_write_requires_review')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, 'wb') as stream:
stream.write(canonical(value) + b'\n')
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path / 'journal.json')
fd = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def transition(directory, journal, proposal_digest, operation):
if operation not in {'grant', 'rollback', 'recover'}:
raise Refused('fixed_transition_required')
prior = journal.read()
if operation == 'grant' and prior is not None:
raise Refused('existing_attempt_requires_review_not_replay')
if operation == 'recover' and prior is None and (journal.path / 'journal.next').exists():
before = directory.snapshot()
validate(before, granting=False)
if memberships(before, TARGET) != [OLD]:
raise Refused('orphan_write_requires_exact_readonly_state')
journal.preserve_interrupted_write()
if directory.snapshot() != before:
raise Refused('directory_changed_during_orphan_review')
record = {'schema': 'netkingdom.reset-role-transition.v1',
'proposal_digest': proposal_digest, 'target': TARGET, 'operation': operation,
'status': 'read_only_role_restored_verified', 'orphan_write_preserved': True,
'group_ids': {name: before['groups'][name]['id'] for name in (OLD, NEW)},
'before_sha256': digest(before), 'after_sha256': digest(before),
'password_reset_performed': False, 'credentials_changed': False,
'other_directory_memberships_unchanged': True,
'completed_at': datetime.now(UTC).isoformat()}
journal.write(record)
return record
if operation != 'grant':
if (not isinstance(prior, dict) or prior.get('proposal_digest') != proposal_digest
or prior.get('target') != TARGET
or prior.get('schema') != 'netkingdom.reset-role-transition.v1'
or prior.get('status') not in {
'prepared', 'remove_intent', 'add_intent',
'uncertain_requires_attended_recovery', 'password_manager_role_verified',
'read_only_role_restored_verified'}):
raise Refused('matching_prior_attempt_required')
if prior.get('status') == 'read_only_role_restored_verified':
raise Refused('completed_rollback_cannot_replay')
if operation == 'rollback' and prior.get('status') != 'password_manager_role_verified':
raise Refused('use_recover_for_interrupted_attempt')
if operation == 'recover' and prior.get('status') == 'password_manager_role_verified':
raise Refused('use_rollback_for_completed_grant')
before = directory.snapshot()
validate(before, granting=operation == 'grant')
current = memberships(before, TARGET)
if operation == 'grant' and current != [OLD]:
raise Refused('exact_read_only_baseline_required')
if operation == 'rollback' and current != [NEW]:
raise Refused('exact_granted_baseline_required')
ids = {name: before['groups'][name]['id'] for name in (OLD, NEW)}
if prior and prior.get('group_ids') != ids:
raise Refused('builtin_group_identity_changed')
if operation == 'recover':
journal.preserve_interrupted_write()
desired = NEW if operation == 'grant' else OLD
expected = expected_state(before, desired)
record = {'schema': 'netkingdom.reset-role-transition.v1', 'proposal_digest': proposal_digest,
'target': TARGET, 'operation': operation, 'status': 'prepared', 'group_ids': ids,
'started_at': datetime.now(UTC).isoformat(), 'before_sha256': digest(before),
'expected_after_sha256': digest(expected),
'prior_attempt_sha256': digest(prior) if prior else None,
'password_reset_performed': False, 'credentials_changed': False,
'other_directory_memberships_unchanged': False}
journal.write(record)
try:
# There is briefly no elevated read role; never introduce an untested role union.
for role in current:
if role != desired:
record.update(status='remove_intent', role=role)
journal.write(record)
directory.membership('remove', ids[role])
middle = expected_state(before, None)
if directory.snapshot() != middle:
raise Refused('intermediate_directory_drift')
if current != [desired]:
record.update(status='add_intent', role=desired)
journal.write(record)
directory.membership('add', ids[desired])
after = directory.snapshot()
if after != expected:
raise Refused('postflight_directory_drift')
record.update(status=('password_manager_role_verified' if operation == 'grant'
else 'read_only_role_restored_verified'),
completed_at=datetime.now(UTC).isoformat(), after_sha256=digest(after),
other_directory_memberships_unchanged=True)
journal.write(record)
return record
except Exception: # noqa: BLE001 -- retain an uncertain receipt, never leak provider bodies.
record.update(status='uncertain_requires_attended_recovery')
journal.write(record)
raise Refused('attempt_uncertain_use_reviewed_recovery') from None
def main(argv=None):
parser = observer.Parser(description=__doc__)
choices = parser.add_mutually_exclusive_group()
for mode in ('preflight', 'execute', 'rollback', 'recover', 'observe-runtime'):
choices.add_argument('--' + mode, action='store_true')
parser.add_argument('--expect-digest')
journal = None
try:
args = parser.parse_args(argv)
if args.observe_runtime:
print(json.dumps(observe_runtime(), indent=2))
return 0
plan, proposal_digest = proposal()
if args.expect_digest and args.expect_digest != proposal_digest:
raise Refused('reviewed_proposal_changed')
if not any((args.preflight, args.execute, args.rollback, args.recover)):
print(json.dumps({'status': 'prepared', 'proposal_digest': proposal_digest,
'scope': plan['scope'], 'live_changes': False}, indent=2))
return 0
if (not sys.stdin.isatty() or not sys.stderr.isatty()
or args.expect_digest != proposal_digest):
raise Refused('human_terminal_and_exact_digest_required')
verify_runtime(plan['runtime'], recovering=args.rollback or args.recover)
if not args.preflight:
path = Path.home() / '.local/state/net-kingdom/reset-authority' / proposal_digest[7:]
journal = Journal(path)
if args.execute and journal.read() is not None:
raise Refused('existing_attempt_requires_review_not_replay')
password = getpass.getpass('Password of the human admin account (hidden): ')
directory = Directory(ENDPOINT, password)
before = directory.snapshot()
operation = 'grant' if args.execute else 'rollback' if args.rollback else 'recover'
validate(before, granting=args.preflight or operation == 'grant')
if args.preflight:
if memberships(before, TARGET) != [OLD]:
raise Refused('exact_read_only_baseline_required')
print(json.dumps({'status': 'reset_role_preflight_verified',
'proposal_digest': proposal_digest, 'before_sha256': digest(before),
'runtime_sha256': digest(plan['runtime']), 'live_changes': False}))
return 0
print(json.dumps({'proposal_digest': proposal_digest, 'operation': operation,
'scope': plan['scope'], 'before_sha256': digest(before)}, indent=2))
confirmation = operation.upper() + ' ' + proposal_digest
if input('Type ' + confirmation + ' to proceed: ').strip() != confirmation:
raise Refused('not_confirmed_no_membership_change')
verify_runtime(plan['runtime'], recovering=args.rollback or args.recover)
result = transition(directory, journal, proposal_digest, operation)
result['runtime_sha256'] = digest(plan['runtime'])
journal.write(result)
print(json.dumps(result, indent=2))
return 0
except Exception as error: # noqa: BLE001 -- private-input and provider errors must be redacted.
print(json.dumps({'status': 'transition_refused_or_incomplete',
'reason': str(error) if isinstance(error, Refused)
else 'transition_unavailable', 'password_reset_performed': False}),
file=sys.stderr)
return 1
finally:
if journal is not None:
journal.close()
if __name__ == '__main__':
raise SystemExit(main())