Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
398 lines
19 KiB
Python
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())
|