diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index e4dffee..6e485c0 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -324,6 +324,9 @@ | task | NK-WP-0054-T11 | done | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T12 | done | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T13 | done | — | workplans/NK-WP-0054-service-identity-policy.md | +| task | NK-WP-0054-T14 | done | — | workplans/NK-WP-0054-service-identity-policy.md | +| task | NK-WP-0054-T15 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | +| task | NK-WP-0054-T16 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0055-T01 | done | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T02 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T03 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | diff --git a/docs/evidence/2026-10-31-reset-authority/role-refresh.json b/docs/evidence/2026-10-31-reset-authority/role-refresh.json new file mode 100644 index 0000000..81b4e98 --- /dev/null +++ b/docs/evidence/2026-10-31-reset-authority/role-refresh.json @@ -0,0 +1,76 @@ +{ + "schema": "coulomb.native-reset-role-refresh.v1", + "observed_at": "2026-10-31T13:23:16.597535+00:00", + "data": "synthetic", + "status": "native_role_refresh_verified", + "images": { + "lldap": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "authelia": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54", + "client": "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd" + }, + "production_access": false, + "provider_smtp_contacted": false, + "owner_runtime_or_permission_change_performed": false, + "no_ports_host_mounts_or_external_routes": true, + "backend_container_flags_verified_before_fixture_import": true, + "source_manifest_sha256": "f3c6d7b5a286d15d3049bba7e4c543052388f6a95a5e3254414f3b51da1285d0", + "findings": { + "scenarios": [ + { + "bind_role": "lldap_strict_readonly", + "same_authelia_process": true, + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + }, + { + "bind_role": "lldap_password_manager", + "same_authelia_process": true, + "status": "reset_completed", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": null, + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": false, + "new_password_accepted_after": true + }, + { + "bind_role": "lldap_strict_readonly", + "same_authelia_process": true, + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + } + ], + "cleanup_verified": true + } +} diff --git a/docs/evidence/2026-10-31-reset-authority/transition-validation.json b/docs/evidence/2026-10-31-reset-authority/transition-validation.json new file mode 100644 index 0000000..c1710c3 --- /dev/null +++ b/docs/evidence/2026-10-31-reset-authority/transition-validation.json @@ -0,0 +1,74 @@ +{ + "schema": "netkingdom.reset-transition-validation.v1", + "status": "tested_proposal_not_granted", + "unit_checks": 25, + "native_controller_checks": 4, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "26911e26b410c242f3dafb1915301875a4bfd3f5d88770be3b7bec5ce4c5c3d5", + "ruff": "passed", + "native_findings": [ + "grant and rollback preserve all other user IDs and group memberships", + "real process death after removal or addition recovers read-only without replaying grant", + "membership rollback leaves prior HTTP token rights active; freshly issued token loses those rights" + ], + "authelia_findings": "same running process refuses reset before grant, allows after grant, refuses after rollback; three fresh consumed challenges, old/new password checks; all confined containers removed", + "runtime_observation": { + "schema": "netkingdom.reset-runtime.v1", + "kube-system_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "sso_uid": "a4a148d8-92ab-41e6-b975-4e67b73edfc0", + "authelia": { + "uid": "dc40a925-2306-4a3b-89c0-e714187503c3", + "images": [ + "docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54" + ], + "ready": true + }, + "lldap": { + "uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8", + "images": [ + "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d" + ], + "ready": true + }, + "authelia_configuration": { + "uid": "7af1fa0d-1bd4-47e1-8873-0f620788ef3f", + "sha256": "0a8bca50641a4c6f27830f476c2c9f413fc3795711becccb8344ee4d6c376e80", + "bind_is_svc_authelia": true + } + }, + "production_membership_changed": false, + "grant_approved": false, + "productive_acceptance": false, + "sources": [ + { + "path": "sso-mfa/k8s/lldap/reset-role-transition.py", + "sha256": "cc405861bb5cc0adf419a1cf98a90872d8395eecbfc7d4d38947cb4008bee217" + }, + { + "path": "sso-mfa/k8s/lldap/reset-role-plan.json", + "sha256": "703e281ee6a17e1ef0a1dffbad35b8025ab3480d51d3fc2c36323f2bc6227106" + }, + { + "path": "tests/test_reset_role_transition.py", + "sha256": "1437761ac60a7aeba16db70ccf07d48a9f5332617a00e7a325e678951985a08e" + }, + { + "path": "sso-mfa/k8s/lldap/test_reset_role_transition_native.py", + "sha256": "13c53e8a757ed0e954ffd28df14283d51dd9d7d3278c1d7d7d5759b419a2a89a" + }, + { + "path": "sso-mfa/k8s/lldap/test_bind_split.py", + "sha256": "ae69051a3df4596fe220306d88a28b2c12e42fbfe3587e02135e3684eb5f354a" + }, + { + "path": "sso-mfa/k8s/lldap/bind-split-rehearsal.py", + "sha256": "9ab873d1e941807fb19a48fb18c3af246a98bf0788660d8d79a14fb0c0e0a067" + }, + { + "path": "docs/evidence/2026-10-31-reset-authority/role-refresh.json", + "sha256": "325f446960134e9510591a2a27f5833bc80fa858d77e6a88082af41307993b65" + } + ] +} diff --git a/sso-mfa/k8s/lldap/reset-role-plan.json b/sso-mfa/k8s/lldap/reset-role-plan.json new file mode 100644 index 0000000..d08cc74 --- /dev/null +++ b/sso-mfa/k8s/lldap/reset-role-plan.json @@ -0,0 +1,81 @@ +{ + "schema": "netkingdom.reset-role-proposal.v1", + "proposal_id": "NK-WP-0054-reset-authority-20261031-1", + "authority_status": "proposed_not_granted", + "endpoint": "https://lldap.coulomb.social", + "target": "svc-authelia", + "subject": "tegwick", + "before_roles": [ + "lldap_strict_readonly" + ], + "after_roles": [ + "lldap_password_manager" + ], + "runtime": { + "schema": "netkingdom.reset-runtime.v1", + "kube-system_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "sso_uid": "a4a148d8-92ab-41e6-b975-4e67b73edfc0", + "authelia": { + "uid": "dc40a925-2306-4a3b-89c0-e714187503c3", + "images": [ + "docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54" + ], + "ready": true + }, + "lldap": { + "uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8", + "images": [ + "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d" + ], + "ready": true + }, + "authelia_configuration": { + "uid": "7af1fa0d-1bd4-47e1-8873-0f620788ef3f", + "sha256": "0a8bca50641a4c6f27830f476c2c9f413fc3795711becccb8344ee4d6c376e80", + "bind_is_svc_authelia": true + } + }, + "scope": { + "change": "Replace only svc-authelia strict-readonly membership with password-manager membership.", + "password_reach": "Every non-LLDAP-admin directory identity, including non-admin service and application-admin accounts; not limited to Coulomb.", + "excluded": "LLDAP administrators; tested user/group creation, other-profile edits and membership changes remain refused.", + "unchanged": "Credentials, service username, Kubernetes resources, Authelia account filter, MFA policy and all other directory memberships.", + "rollback": "Restore only svc-authelia strict-readonly role; retain any user password changes and unrelated directory updates. Existing HTTP tokens are not revoked: a token issued with password-manager rights can retain them until expiry (up to 24 hours), or an owner-run token revocation.", + "interruption": "Removing the old role precedes adding the new one. Brief lookup failure is possible. Uncertain attempts require explicit attended recovery; never blindly repeat grant.", + "acceptance": "Role postflight is not a user reset, app login, MFA or fresh-backup restore verdict.", + "self_profile": "Both built-in roles permit own-profile/password operations; this proposal does not claim an exhaustive denial of every directory write.", + "recovery_runtime": "Recovery tolerates unready workloads but still requires the reviewed namespace/deployment identities, images and configuration.", + "follow_through": "After a verified grant, align the service declaration and bind verification expectations; obtain the personal reset/app/MFA verdict and fresh identity backup/restore proof under NK-WP-0054-T16." + }, + "proposed_service_identity": { + "principal_type": "SERVICE", + "conventions": [], + "purpose": "Authelia directory lookup and user-requested password recovery", + "grants": [ + { + "mode": "USE", + "scope": "directory", + "read_only": true, + "activation": "STANDING", + "environments": [ + "PRODUCTION" + ] + }, + { + "mode": "ADMINISTER", + "scope": "directory/passwords/non-lldap-admin", + "activation": "STANDING", + "environments": [ + "PRODUCTION" + ], + "note": "Native lldap_password_manager; includes non-admin service identities and application administrators." + } + ], + "consumer": "authelia", + "owner_confirmed": false + }, + "live_declaration_updated": false, + "new_resources": 0, + "credentials_rotated": false, + "requires_founder_admission": true +} diff --git a/sso-mfa/k8s/lldap/reset-role-transition.py b/sso-mfa/k8s/lldap/reset-role-transition.py new file mode 100644 index 0000000..eb76b42 --- /dev/null +++ b/sso-mfa/k8s/lldap/reset-role-transition.py @@ -0,0 +1,398 @@ +#!/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()) diff --git a/sso-mfa/k8s/lldap/test_reset_role_transition_native.py b/sso-mfa/k8s/lldap/test_reset_role_transition_native.py new file mode 100644 index 0000000..3a6f472 --- /dev/null +++ b/sso-mfa/k8s/lldap/test_reset_role_transition_native.py @@ -0,0 +1,117 @@ +"""Real membership changes in disposable, synthetic LLDAP; no production access.""" +import importlib.util +import json +import secrets +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +fixture = load("transition_native_fixture", HERE / "test_bind_split.py") +lab = fixture.lab +pytestmark = fixture.pytestmark +controller = load("transition_native_controller", HERE / "reset-role-transition.py") +DIGEST = "sha256:" + "a" * 64 + + +def prepare(lab): + _, endpoint, password, server = lab + token = server.login("admin", password) + server.create_user(token, "svc-authelia", [controller.OLD]) + server.create_user(token, "tegwick") + server.create_user(token, "unrelated") + return controller.Directory(endpoint, password) + + +def test_native_grant_and_rollback_preserve_memberships(lab, tmp_path): + directory = prepare(lab) + before = directory.snapshot() + journal = controller.Journal(tmp_path / "state") + try: + granted = controller.transition(directory, journal, DIGEST, "grant") + assert granted["status"] == "password_manager_role_verified" + assert directory.snapshot() == controller.expected_state(before, controller.NEW) + restored = controller.transition(directory, journal, DIGEST, "rollback") + assert restored["status"] == "read_only_role_restored_verified" + assert directory.snapshot() == before + finally: + journal.close() + + +@pytest.mark.parametrize("crash_after", ["remove", "add"]) +def test_native_process_death_recovered_without_replaying_grant(lab, tmp_path, crash_after): + directory = prepare(lab) + before = directory.snapshot() + path = tmp_path / "state" + # Synthetic credentials only in the private pipe; actual os._exit bypasses handlers. + code = """ +import importlib.util,json,os,sys +from pathlib import Path +v=json.load(sys.stdin) +s=importlib.util.spec_from_file_location('controller',v['source']) +m=importlib.util.module_from_spec(s);s.loader.exec_module(m) +d=m.Directory(v['endpoint'],v['password']) +original=d.membership +def die(action,group): + original(action,group) + if action==v['crash_after']:os._exit(86) +d.membership=die +j=m.Journal(Path(v['state'])) +m.transition(d,j,v['digest'],'grant') +""" + result = subprocess.run([sys.executable, "-c", code], input=json.dumps({ + "source": str(HERE / "reset-role-transition.py"), "endpoint": lab[1], + "password": lab[2], "state": str(path), "digest": DIGEST, + "crash_after": crash_after}), text=True, capture_output=True, timeout=30, check=False) + assert result.returncode == 86 + assert not result.stdout and not result.stderr + journal = controller.Journal(path) + try: + assert journal.read()["status"] == crash_after + "_intent" + assert controller.memberships(directory.snapshot(), controller.TARGET) == ( + [] if crash_after == "remove" else [controller.NEW]) + with pytest.raises(controller.Refused, match="existing_attempt"): + controller.transition(directory, journal, DIGEST, "grant") + restored = controller.transition(directory, journal, DIGEST, "recover") + assert restored["status"] == "read_only_role_restored_verified" + assert directory.snapshot() == before + assert list(path.glob("history-*.json")) + for file in path.iterdir(): + assert file.stat().st_mode & 0o077 == 0 + assert lab[2] not in file.read_text() + finally: + journal.close() + + +def test_native_role_rollback_does_not_revoke_existing_http_token(lab, tmp_path): + directory = prepare(lab) + server = lab[3] + bind_password, user_password = secrets.token_hex(24), secrets.token_hex(24) + server.set_password(directory.token, controller.TARGET, bind_password) + journal = controller.Journal(tmp_path / 'state') + try: + controller.transition(directory, journal, DIGEST, 'grant') + old_token = server.login(controller.TARGET, bind_password) + assert old_token + controller.transition(directory, journal, DIGEST, 'rollback') + fresh_token = server.login(controller.TARGET, bind_password) + assert fresh_token + # Measure the limitation, not a revocation claim: pre-rollback JWT retains its role. + server.set_password(old_token, 'tegwick', user_password) + assert server.login('tegwick', user_password) + with pytest.raises(RuntimeError): + server.set_password(fresh_token, 'tegwick', secrets.token_hex(24)) + assert server.login('tegwick', user_password) + finally: + journal.close() diff --git a/tests/test_reset_role_transition.py b/tests/test_reset_role_transition.py new file mode 100644 index 0000000..70a1de0 --- /dev/null +++ b/tests/test_reset_role_transition.py @@ -0,0 +1,203 @@ +"""Exact membership effects, durable interruption recovery and attended CLI boundaries.""" + +import copy +import importlib.util +import json +import os +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + 'reset_transition', ROOT / 'sso-mfa/k8s/lldap/reset-role-transition.py') +change = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(change) +DIGEST = 'sha256:' + 'a' * 64 + + +def baseline(): + return {'users': ['admin', 'other', change.TARGET, change.SUBJECT], 'groups': { + change.OLD: {'id': 1, 'members': [change.TARGET]}, + change.NEW: {'id': 2, 'members': []}, + 'lldap_admin': {'id': 3, 'members': ['admin']}, + 'tenant:one:users': {'id': 4, 'members': [change.SUBJECT]}}} + + +class Directory: + def __init__(self, state=None, failure=None, when=1): + self.state = state or baseline() + self.calls = [] + self.failure, self.when = failure, when + + def snapshot(self): + return copy.deepcopy(self.state) + + def membership(self, action, group_id): + self.calls.append((action, group_id)) + failing = len(self.calls) == self.when + if failing and self.failure == 'before': + raise RuntimeError('private-provider-error') + group = next(g for g in self.state['groups'].values() if g['id'] == group_id) + if action == 'add': + group['members'].append(change.TARGET) + group['members'].sort() + else: + group['members'].remove(change.TARGET) + if failing and self.failure == 'drift': + self.state['groups']['tenant:one:users']['members'].insert(0, 'other') + if failing and self.failure == 'after': + raise RuntimeError('private-provider-error') + + +@pytest.fixture +def journal(tmp_path): + tmp_path.chmod(0o700) + value = change.Journal(tmp_path / 'attempt') + try: + yield value + finally: + value.close() + + +def test_grant_and_rollback_preserve_all_other_memberships_and_receipt_history(journal): + directory = Directory() + result = change.transition(directory, journal, DIGEST, 'grant') + assert result['status'] == 'password_manager_role_verified' + assert result['other_directory_memberships_unchanged'] + assert directory.state == change.expected_state(baseline(), change.NEW) + assert directory.calls == [('remove', 1), ('add', 2)] + original_receipt = journal.read() + # Rollback preserves productive changes made after the grant, including subject changes. + directory.state['groups']['tenant:one:users']['members'].insert(0, 'other') + directory.state['groups']['lldap_admin']['members'].append(change.SUBJECT) + before_rollback = directory.snapshot() + result = change.transition(directory, journal, DIGEST, 'rollback') + assert result['status'] == 'read_only_role_restored_verified' + assert directory.state == change.expected_state(before_rollback, change.OLD) + history = [json.loads(p.read_text()) for p in journal.path.glob('history-*.json')] + assert original_receipt in history + with pytest.raises(change.Refused, match='cannot_replay'): + change.transition(directory, journal, DIGEST, 'rollback') + + +@pytest.mark.parametrize('failure,when', [('before', 1), ('after', 1), ('after', 2), + ('drift', 1), ('drift', 2)]) +def test_ambiguous_effects_recover_only_readonly_and_preserve_foreign_changes(journal, failure, when): + directory = Directory(failure=failure, when=when) + with pytest.raises(change.Refused, match='uncertain'): + change.transition(directory, journal, DIGEST, 'grant') + assert journal.read()['status'] == 'uncertain_requires_attended_recovery' + with pytest.raises(change.Refused, match='not_replay'): + change.transition(directory, journal, DIGEST, 'grant') + directory.failure = None + before = directory.snapshot() + result = change.transition(directory, journal, DIGEST, 'recover') + assert result['status'] == 'read_only_role_restored_verified' + assert directory.state == change.expected_state(before, change.OLD) + assert 'private-provider-error' not in json.dumps(journal.read()) + + +@pytest.mark.parametrize('defect', ['foreign-role', 'already-granted', 'no-admin', + 'accepted-admin', 'missing-subject', 'missing-group']) +def test_unreviewed_baseline_refuses_before_any_journal_or_write(journal, defect): + state = baseline() + if defect == 'foreign-role': + state['groups']['tenant:one:users']['members'].insert(0, change.TARGET) + elif defect == 'already-granted': + state = change.expected_state(state, change.NEW) + elif defect == 'no-admin': + state['groups']['lldap_admin']['members'] = [] + elif defect == 'accepted-admin': + state['groups']['lldap_admin']['members'].append(change.SUBJECT) + elif defect == 'missing-subject': + state['users'].remove(change.SUBJECT) + else: + del state['groups'][change.NEW] + directory = Directory(state) + with pytest.raises(change.Refused): + change.transition(directory, journal, DIGEST, 'grant') + assert not directory.calls and journal.read() is None + + +@pytest.mark.parametrize('defect', ['digest', 'group-id', 'foreign-role']) +def test_recovery_refuses_changed_binding(journal, defect): + directory = Directory(failure='after') + with pytest.raises(change.Refused): + change.transition(directory, journal, DIGEST, 'grant') + if defect == 'group-id': + directory.state['groups'][change.NEW]['id'] = 99 + if defect == 'foreign-role': + directory.state['groups']['lldap_admin']['members'].append(change.TARGET) + directory.calls.clear() + with pytest.raises(change.Refused): + change.transition(directory, journal, DIGEST + ('x' if defect == 'digest' else ''), 'recover') + assert not directory.calls + + +@pytest.mark.parametrize('prior', [False, True]) +def test_partial_local_journal_write_is_preserved_and_recovery_never_grants(journal, prior): + directory = Directory(failure='after') + if prior: + with pytest.raises(change.Refused): + change.transition(directory, journal, DIGEST, 'grant') + partial = journal.path / 'journal.next' + partial.write_bytes(b'{"incomplete') + partial.chmod(0o600) + directory.failure = None + result = change.transition(directory, journal, DIGEST, 'recover') + assert result['status'] == 'read_only_role_restored_verified' + assert not partial.exists() + assert b'{"incomplete' in [p.read_bytes() for p in journal.path.glob('history-*.json')] + + +def test_exclusive_lock_and_completed_grant_refuse_reentry(journal): + with pytest.raises(change.Refused, match='active'): + change.Journal(journal.path) + directory = Directory() + change.transition(directory, journal, DIGEST, 'grant') + directory.calls.clear() + with pytest.raises(change.Refused, match='rollback_for_completed'): + change.transition(directory, journal, DIGEST, 'recover') + with pytest.raises(change.Refused, match='not_replay'): + change.transition(directory, journal, DIGEST, 'grant') + assert not directory.calls + + +@pytest.mark.parametrize('defect', ['symlink', 'hardlink', 'public']) +def test_unsafe_existing_journal_refuses(journal, tmp_path, defect): + path = journal.path / 'journal.json' + if defect == 'symlink': + path.symlink_to(tmp_path / 'missing') + else: + path.write_text('{}') + path.chmod(0o600 if defect == 'hardlink' else 0o644) + if defect == 'hardlink': + os.link(path, tmp_path / 'alias') + with pytest.raises(change.Refused): + change.transition(Directory(), journal, DIGEST, 'grant') + + +@pytest.mark.parametrize('defect', ['no-terminal', 'wrong-digest', 'runtime-drift']) +def test_cli_refuses_before_private_input(monkeypatch, defect): + monkeypatch.setattr(change, 'proposal', lambda: ({'runtime': {}, 'scope': {}}, DIGEST)) + monkeypatch.setattr(change.sys.stdin, 'isatty', lambda: defect != 'no-terminal') + monkeypatch.setattr(change.sys.stderr, 'isatty', lambda: True) + monkeypatch.setattr(change, 'observe_runtime', lambda: {'changed': True}) + monkeypatch.setattr(change.getpass, 'getpass', lambda prompt: pytest.fail('private prompt')) + assert change.main(['--execute', '--expect-digest', + DIGEST + ('x' if defect == 'wrong-digest' else '')]) == 1 + + +def test_recovery_allows_unready_peers_but_not_changed_runtime_identity(monkeypatch): + expected = {'authelia': {'uid': 'a', 'ready': True}, + 'lldap': {'uid': 'b', 'ready': True}, 'config': 'pinned'} + actual = copy.deepcopy(expected) + actual['authelia']['ready'] = False + monkeypatch.setattr(change, 'observe_runtime', lambda: copy.deepcopy(actual)) + with pytest.raises(change.Refused, match='runtime_changed'): + change.verify_runtime(expected) + change.verify_runtime(expected, recovering=True) + actual['lldap']['uid'] = 'replacement' + with pytest.raises(change.Refused, match='runtime_changed'): + change.verify_runtime(expected, recovering=True) diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index 450ad0f..aea7e34 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -602,3 +602,78 @@ reset-authority/current-roles.json and consumer reset-role-return-verification- the reviewed password-manager transition for this consumer, retaining explicit non-admin service/application-account reach and directory-admin exclusion. The accepted tegwick flow needs no new directory-admin recovery route on this evidence. + + +## Prepare and rehearse the guarded reset-role transition + +```task +id: NK-WP-0054-T14 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0054-T11, NK-WP-0054-T13] +``` + +Owner: Codex. Prepare the exact svc-authelia role replacement and read-only +recovery; validate the journal, crash recovery and pinned native transport. +Measure role refresh in a single running Authelia process and disclose retained +HTTP-token rights after membership rollback. Completion requires source-bound +validation and an executable reviewed proposal, not a live grant. This extracts +packet preparation from T10; its original operational acceptance remains open. + +Completed 2026-10-31: controller and proposal prepared; 25 unit and four native +checks pass, zero failures/errors/skips, Ruff passes. Real process death after +either LDAP effect recovers read-only without grant replay. Same-process Authelia +rehearsal proves refusal/success/refusal across grant/rollback, with old/new +passwords and consumed challenges. Native token test proves the explicit rollback +limit: old HTTP token rights survive membership removal; fresh tokens lose them. +Evidence: docs/evidence/2026-10-31-reset-authority/transition-validation.json and +role-refresh.json; source and review in coulomb-social/docs/release/native-password- +reset-review-2026-10-30.md. Proposal digest: +sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f. +No production membership, password, declaration or runtime was changed. + +## Admit and execute the reviewed reset-role transition + +```task +id: NK-WP-0054-T15 +status: wait +priority: high +assignee: codex +needs_human: true +depends_on: [NK-WP-0054-T14] +blocked_on: human +blocking_reason: Founder admission of the complete non-admin password-write scope and attended terminal execution are required. +``` + +Owner: founder for admission and private authentication; Codex verifies the +returned durable receipt. The earlier read-only observation authorizes no grant. +Review coulomb-social/docs/release/native-password-reset-review-2026-10-30.md. +Completion requires the exact proposal digest, native role postflight and private +receipt readback. A refused or interrupted operation stays open and follows the +reviewed recovery. Membership rollback does not invalidate pre-existing HTTP +tokens or undo productive password changes. No live token-revocation operation +is approved by this packet; its limitation is part of the admission question. + +## Align the reset authority sources and verify productive recovery + +```task +id: NK-WP-0054-T16 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0054-T15] +blocked_on: dependency +blocking_reason: Requires the reviewed T15 grant and its verified native receipt. +``` + +Owner: Codex prepares/verifies, founder supplies productive verdict and private +inputs. After T15's verified result, align the service identity declaration and +existing bind verification/repair expectations with the admitted role. Keep the +single Authelia consumer and current credential custody; never infer broader +human-admin authority. Verify the accepted tegwick reset, new/old password result, +consumed-link refusal, app access and MFA, then a fresh identity archive and +isolated restore of the same supported behavior. Retain only value-free receipts. +A proposed/source-only grant or fixture pass cannot close this task. It supplies +the remaining acceptance of T10 and therefore CSOC-WP-0015-T06; no completed T05 +or historical October6 acceptance is reopened or counted twice.