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:
parent
2387409d08
commit
c07e6ebe34
8 changed files with 1027 additions and 0 deletions
81
sso-mfa/k8s/lldap/reset-role-plan.json
Normal file
81
sso-mfa/k8s/lldap/reset-role-plan.json
Normal 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
|
||||
}
|
||||
398
sso-mfa/k8s/lldap/reset-role-transition.py
Normal file
398
sso-mfa/k8s/lldap/reset-role-transition.py
Normal 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())
|
||||
117
sso-mfa/k8s/lldap/test_reset_role_transition_native.py
Normal file
117
sso-mfa/k8s/lldap/test_reset_role_transition_native.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue