diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md index 67cc175..716d0b7 100644 --- a/docs/release/native-password-reset-review-2026-10-30.md +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -220,3 +220,112 @@ including the full non-admin reach established by the native matrix. No grant, reset, app authorization or productive acceptance is contained in this receipt. Do not rerun the completed observer command. Evidence: reset-role-observation- 2026-10-31.json and reset-role-return-verification-2026-10-31.json. + + +## Reviewed role-transition proposal — 2026-10-31 + +**Prepared, not admitted or executed.** Owner preparation is NK-WP-0054-T14; +founder admission/execution is T15; declaration alignment and productive reset plus +fresh identity restore acceptance are T16. T10 and CSOC-WP-0015-T06 remain open. + +Proposal: `sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f`. Exact controller and plan are +`/home/worsch/net-kingdom/sso-mfa/k8s/lldap/reset-role-transition.py` and +`reset-role-plan.json`. The digest binds the controller, existing transport and +observer sources, current role observation, native rights matrix, pinned runtime +identities/configuration and final validation. No new resources or recurring +capacity are requested; no credential rotation, service rename or restart is +included. Both pinned workloads were Ready when observed. + +### Decision and scope + +The proposed change replaces **only svc-authelia's strict-readonly membership** +with **lldap_password_manager**. This restores the native password-write capability +needed by the accepted tegwick recovery flow. The role can change passwords for +**all non-LLDAP-admin directory identities**, including other service accounts and +application administrators outside lldap_admin. It is not limited to Coulomb or to +human accounts. LLDAP administrators are excluded. Seven tested unrelated writes +remain refused; this is not an exhaustive rights audit. Both roles already permit +own-profile/password operations in the pinned [LLDAP access-control source](https://github.com/lldap/lldap/blob/v0.6.2/crates/auth/src/access_control.rs). + +The same dedicated SERVICE account and single Authelia consumer remain. The plan +contains the proposed declaration: directory read USE plus narrowly described +password ADMINISTER scope, without the read-only bind convention. SI-3 permits a +service-owned write account with its narrowest working scope. The canonical +read-only declaration has not been changed to imply approval. Following a verified +grant, T16 must align it and existing bind verification/repair expectations. Do +not rerun the historical read-only bind migration as reset verification. + +**Rollback limit:** membership rollback restores fresh Authelia reset denial, but +it does not revoke previously issued HTTP tokens or undo changed user passwords. +A native test proves a token obtained during the grant can still change an eligible +password after membership rollback; a freshly issued token cannot. The pinned +[LLDAP token implementation](https://github.com/lldap/lldap/blob/v0.6.2/server/src/auth_service.rs) +embeds groups in a token with a one-day expiry. Treat retained token authority as +lasting up to 24 hours unless separately revoked. This packet includes no token +revocation or incident-containment operation. Admission includes this limitation. + +### Execution and recovery + +Run in the founder's workstation terminal only after accepting the scope above: + +```bash +python3 /home/worsch/net-kingdom/sso-mfa/k8s/lldap/reset-role-transition.py \ + --execute --expect-digest sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f +``` + +The hidden prompt requires the human admin password. Never paste it into chat. +After reviewing the printed scope, enter exactly: + +```text +GRANT sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f +``` + +The controller checks the exact live cluster/namespace/deployment identities, +images and Authelia configuration before private authentication and again after +confirmation. It rechecks the human admin role, tegwick's non-admin classification +and svc-authelia's exact baseline before writing. The old role is removed before +the new one is added; a brief directory lookup failure is possible. Every effect +has a durable intent record. All user IDs and group memberships are compared after +each step; unknown concurrent changes fail verification. Profile fields/passwords +are not copied into the receipt or claimed as independently compared. + +Success is `password_manager_role_verified`. Paste only the returned JSON; Codex +will verify the durable mode0600 receipt in +`~/.local/state/net-kingdom/reset-authority/753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f/journal.json`. +An existing attempt refuses replay. Do not delete its state or repeat the grant. +If the operation is interrupted or reports uncertain status, restore read-only +membership with this separate attended recovery: + +```bash +python3 /home/worsch/net-kingdom/sso-mfa/k8s/lldap/reset-role-transition.py \ + --recover --expect-digest sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f +``` + +Confirm `RECOVER sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f`. For an already completed successful +grant, use `--rollback` instead of `--recover` and confirm +`ROLLBACK sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f`. Both restore only the role and retain +previous journal versions. They preserve unrelated subsequent memberships and +user password changes. Recovery tolerates workload unready status but still +requires pinned identities, images and configuration; runtime drift requires +review, not a bypass. If the directory itself is unreachable, recovery cannot +claim completion until the owner restores access. These are role restoration +commands with the token limitation above, not full credential revocation. + +### Evidence and remaining acceptance + +25 unit checks and four native controller checks pass with no skips/failures. +The native checks include real process termination immediately after removal and +addition, successful recovery without replay, and the retained-token boundary. +A separate confined native rehearsal uses **one running Authelia process** through +read-only → password-manager → read-only: reset refuses, succeeds, then refuses; +old/new passwords and consumed challenge denial are checked each time. No restart +is needed in this tested configuration. Synthetic fixtures and generated passwords +are removed; no provider SMTP or productive password reset was used. + +Evidence: `reset-transition-validation-2026-10-31.json`, +`reset-role-refresh-2026-10-31.json` and its source manifest. A successful role +receipt still leaves T16: source alignment, productive tegwick reset/app/MFA and +fresh identity backup/restore evidence. It does not complete the four goal workplans. + +The final consumer `make check` gate passes546 tests (zero failures/skips;69 +existing warnings). Receipt: reset-transition-consumer-gate-2026-10-31.json. diff --git a/docs/release/reset-role-refresh-2026-10-31.json b/docs/release/reset-role-refresh-2026-10-31.json new file mode 100644 index 0000000..81b4e98 --- /dev/null +++ b/docs/release/reset-role-refresh-2026-10-31.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/release/reset-role-refresh-2026-10-31.sources.txt b/docs/release/reset-role-refresh-2026-10-31.sources.txt new file mode 100644 index 0000000..8cc15a6 --- /dev/null +++ b/docs/release/reset-role-refresh-2026-10-31.sources.txt @@ -0,0 +1,3 @@ +scripts/rehearse-reset-role-refresh.py: c84751c3ed2893ef25460401ab760f109ac8502b308aae120990d402f85ed42f +scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d +scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556 diff --git a/docs/release/reset-transition-consumer-gate-2026-10-31.json b/docs/release/reset-transition-consumer-gate-2026-10-31.json new file mode 100644 index 0000000..d1eb5e7 --- /dev/null +++ b/docs/release/reset-transition-consumer-gate-2026-10-31.json @@ -0,0 +1,12 @@ +{ + "schema": "coulomb.reset-transition-consumer-gate.v1", + "command": "make check", + "passed": 546, + "failures": 0, + "skipped": 0, + "warnings": 69, + "elapsed_seconds": 55.15, + "junit_sha256": "717412e60cbe12d52c4a14f124fbe60a59789bc52467c0b938645c2b28c24232", + "proposal_digest": "sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f", + "production_grant_performed": false +} diff --git a/docs/release/reset-transition-validation-2026-10-31.json b/docs/release/reset-transition-validation-2026-10-31.json new file mode 100644 index 0000000..c1710c3 --- /dev/null +++ b/docs/release/reset-transition-validation-2026-10-31.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/scripts/rehearse-reset-role-refresh.py b/scripts/rehearse-reset-role-refresh.py new file mode 100644 index 0000000..8a38167 --- /dev/null +++ b/scripts/rehearse-reset-role-refresh.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Verify role changes with one running Authelia process; synthetic peers only.""" + +import argparse +import hashlib +import json +import re +import runpy +import secrets +import urllib.parse +import uuid +from datetime import UTC, datetime +from pathlib import Path + +HERE = Path(__file__).resolve().parent +BASE = runpy.run_path(str(HERE / 'rehearse-native-password-reset.py')) +docker, container, archive, probe, configuration = ( + BASE[n] for n in ('docker', 'container', 'archive', 'probe', 'configuration')) + + +def set_password(anchor, token, target, password): + assert target in {'alice', 'bob', 'operator', 'appoperator', 'svc-other', 'svc-auth'} + return docker('exec', '-i', anchor, '/bin/sh', '-c', + 'read -r token; read -r LLDAP_USER_PASSWORD; export LLDAP_USER_PASSWORD; ' + 'exec /app/lldap_set_password --base-url http://127.0.0.1:17170 ' + '--token "$token" --username ' + target, + data=(token + '\n' + password + '\n').encode(), check=False).returncode + + +def rehearse(): + if not __debug__: + raise RuntimeError('acceptance_assertions_must_be_enabled') + context = json.loads(docker('context', 'inspect', 'default').stdout)[0] + assert context['Endpoints']['docker']['Host'] == 'unix:///var/run/docker.sock' + for image in (BASE['LLDAP'], BASE['AUTHELIA'], BASE['CLIENT']): + assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image + name = 'csoc-reset-transition-' + uuid.uuid4().hex[:12] + auth = name + '-auth' + values = {key: secrets.token_urlsafe(24) for key in ( + 'admin_password', 'bind_password', 'old_password', 'new_password')} + result = {'scenarios': []} + try: + container(name, BASE['LLDAP'], 'none', { + 'LLDAP_JWT_SECRET_FILE': '/run/fixture/jwt', + 'LLDAP_LDAP_USER_PASS_FILE': '/run/fixture/admin-password', + 'LLDAP_LDAP_BASE_DN': 'dc=example,dc=test', + 'LLDAP_LDAP_HOST': '127.0.0.1', 'LLDAP_HTTP_HOST': '127.0.0.1'}) + docker('exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'jwt': secrets.token_hex(32).encode(), + 'admin-password': values['admin_password'].encode(), + 'lldap.toml': ('database_url = "sqlite:///data/users.db?mode=rwc"\nkey_seed = ' + + json.dumps(secrets.token_hex(32)) + '\n').encode()})) + docker('exec', '-d', name, '/bin/sh', '-c', + 'exec /app/lldap run --config-file /run/fixture/lldap.toml ' + '> /run/fixture/server.log 2>&1') + prepared = probe(name, {**values, 'mode': 'bootstrap', 'authority_matrix': True}) + assert set_password(name, prepared['admin_token'], 'svc-auth', + values['bind_password']) == 0 + container(auth, BASE['AUTHELIA'], 'container:' + name, {}) + docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'configuration.yml': json.dumps(configuration(values['bind_password'])).encode()})) + # One Authelia process for all three phases, including rollback. + probe(name, {**values, 'mode': 'bind_role', 'role': 'lldap_strict_readonly'}) + docker('exec', '-d', auth, '/bin/sh', '-c', + 'exec /app/authelia --config /run/fixture/configuration.yml ' + '> /run/fixture/server.log 2>&1') + process_marker = None + tokens = set() + for index, role in enumerate(['lldap_strict_readonly', 'lldap_password_manager', + 'lldap_strict_readonly']): + if index: + probe(name, {**values, 'mode': 'bind_role', 'role': role}) + assert set_password(name, prepared['admin_token'], 'alice', + values['old_password']) == 0 + docker('exec', auth, 'rm', '-f', '/data/notifications.txt') + requested = probe(name, {**values, 'mode': 'reset_request', 'target': 'alice'}) + marker = docker('exec', auth, '/bin/sh', '-c', + 'for p in /proc/[0-9]*; do ' + 'if [ "$(cat "$p/comm" 2>/dev/null)" = authelia ]; ' + 'then cat "$p/stat"; fi; done').stdout + # /proc stat changes CPU counters; compare PID and start ticks only. + fields = marker.decode().split() + current_marker = (fields[0], fields[21]) + if process_marker is None: + process_marker = current_marker + assert current_marker == process_marker, 'authelia_process_restarted' + notification = docker('exec', auth, 'cat', '/data/notifications.txt').stdout.decode() + links = re.findall(r'https://auth\.recovery\.test/reset-password/step2\?[^\s<>]+', + notification) + assert len(links) == 1, ('one_new_native_challenge_required_phase_' + + str(index) + '_count_' + str(len(links))) + token = urllib.parse.parse_qs(urllib.parse.urlsplit(links[-1]).query)['token'][0] + assert token not in tokens, 'fresh_challenge_required' + tokens.add(token) + settled = probe(name, {**values, 'mode': 'reset_finish', 'target': 'alice', + 'reset_token': token}) + expected = role == 'lldap_password_manager' + assert settled['new_password_accepted_after'] is expected + assert settled['old_password_accepted_after'] is not expected + assert (settled['status'] == 'reset_completed') is expected + result['scenarios'].append({'bind_role': role, 'same_authelia_process': True, + **requested, **settled}) + finally: + for current in [name + '-probe', auth, name]: + docker('rm', '-f', '-v', current, check=False) + result['cleanup_verified'] = all(not docker( + 'ps', '-aq', '--filter', 'name=^/' + n + '$').stdout.strip() + for n in [name + '-probe', auth, name]) + if not result['cleanup_verified']: + raise RuntimeError('fixture_cleanup_requires_review') + assert not any(value in json.dumps(result) for value in values.values()) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + manifest_path = args.output.with_suffix('.sources.txt') + if args.output.exists() or manifest_path.exists(): + raise SystemExit('Fresh receipt and source manifest required') + findings = rehearse() + paths = [Path(__file__), HERE / 'rehearse-native-password-reset.py', + HERE / 'native_recovery/probe.py'] + manifest = ''.join(str(p.relative_to(HERE.parent)) + ': ' + + hashlib.sha256(p.read_bytes()).hexdigest() + '\n' for p in paths).encode() + receipt = {'schema': 'coulomb.native-reset-role-refresh.v1', + 'observed_at': datetime.now(UTC).isoformat(), 'data': 'synthetic', + 'status': 'native_role_refresh_verified', + 'images': {n: BASE[n.upper()] for n in ('lldap', 'authelia', 'client')}, + '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': hashlib.sha256(manifest).hexdigest(), + 'findings': findings} + with manifest_path.open('xb') as stream: + stream.write(manifest) + with args.output.open('x') as stream: + json.dump(receipt, stream, indent=2) + stream.write('\n') + print(json.dumps({'status': receipt['status'], 'roles': len(findings['scenarios']), + 'cleanup_verified': findings['cleanup_verified']})) + + +if __name__ == '__main__': + main() diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index ce411ce..058a24a 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -300,3 +300,13 @@ reset-role-observation-2026-10-31.json and reset-role-return-verification-2026-1 ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. + + +2026-10-31 reset transition preparation: owner T14 prepares the source-bound, +crash-recoverable role controller; T15 explicitly holds founder admission and +execution; T16 holds source alignment, productive reset/app/MFA and identity +backup/restore acceptance. The same running Authelia process picks up grant and +rollback in the confined native fixture. Membership rollback does not revoke +HTTP tokens issued during a grant; the review includes their up-to-24-hour scope. +No production role/password changed. T06 remains progress and consumes owner T10, +whose acceptance is preserved rather than closed by fixture proof.