Prepare guarded reset-role transition and native recovery proof

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-31 14:26:17 +01:00
parent 2387409d08
commit c07e6ebe34
8 changed files with 1027 additions and 0 deletions

View file

@ -324,6 +324,9 @@
| task | NK-WP-0054-T11 | done | — | workplans/NK-WP-0054-service-identity-policy.md | | 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-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-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-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-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 | | task | NK-WP-0055-T03 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md |

View file

@ -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
}
}

View file

@ -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"
}
]
}

View file

@ -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
}

View file

@ -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())

View file

@ -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()

View file

@ -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)

View file

@ -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 the reviewed password-manager transition for this consumer, retaining explicit
non-admin service/application-account reach and directory-admin exclusion. The non-admin service/application-account reach and directory-admin exclusion. The
accepted tegwick flow needs no new directory-admin recovery route on this evidence. 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.