Verify native reset authority across account classes
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m5s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-31 13:40:10 +01:00
parent 338340222d
commit f763379882
9 changed files with 670 additions and 8 deletions

View file

@ -1,6 +1,6 @@
# Native password-reset bind prerequisite
WP-0015-T04 remains open. An isolated native Authelia/LLDAP test identifies a
WP-0015-T06 owns the remaining native authority return. An isolated native Authelia/LLDAP test identifies a
directory permission prerequisite before the full UC-009 browser journey can be
accepted. It does not change the existing personal reset acceptance, prove the
currently installed bind's privileges or authorize any live permission change.
@ -114,3 +114,56 @@ scope. Previous personal reset acceptance does not establish behavior after the
bind changed; no new grant is inferred from either the old approval or the
synthetic password-manager success. Preparation may proceed independently of the
pending application image and backup installation.
## Native authority matrix — October 31
`scripts/rehearse-reset-authority.py` extends the synthetic investigation without
changing the application, canonical bind helper, declaration or live directory.
It uses the same pinned native images and network-none loopback confinement.
Run with `--output` naming a fresh local JSON receipt; adjacent source manifest
and receipt refuse overwrite. Disabled assertions refuse before Docker access.
| Synthetic target | Strict-readonly | Password-manager |
| --- | --- | --- |
| Two ordinary users | Reset refused | Reset succeeds |
| Member of `lldap_admin` | Reset refused | Reset refused |
| Application-admin group member, outside `lldap_admin` | Reset refused | Reset succeeds |
| Other read-only service account, direct directory password API | Refused | Password changes |
The first four user cases are tested through actual Authelia reset/link flows
and direct directory password changes. Old/replacement password authentication
confirms every outcome; HTTP 200 is insufficient. Unverified requests and consumed
link replay refuse. Service interactive login through Authelia is excluded by the
filter in every scenario. This does not prohibit direct LLDAP web login, and that
filter does not constrain a compromised bind credential's directory password API.
For each role, seven valid directory mutations refuse: create/delete user,
edit another user's profile, create/delete group, self-promote to directory admin,
and remove another administrator's membership. Matching admin controls succeed,
and complete user/group metadata remains unchanged after the denied operations.
This bounds the tested operations; it does not enumerate every API right.
The LLDAP-admin restriction agrees with the pinned upstream
[LLDAP v0.6.2 integration guidance](https://github.com/lldap/lldap/blob/v0.6.2/README.md#general-configuration-guide).
The application-admin and service results matter to admission: a password-manager
role is not restricted to Coulomb users or to human accounts. It must not be
presented as restoring all prior reset behavior. No actual account's current
classification has been inferred from these fixtures.
Owner T11 records this completed rehearsal; T10 retains the supported runtime path.
Before an executable proposal, obtain owner-authorized, value-free classification
of the intended reset subjects and current bind memberships. Review a service-owned
password-write grant against SI-3 and its complete non-LLDAP-admin reach, and a
separate supported recovery route for directory administrators. Preserve the
existing human-admin separation and MFA requirements. A role change alone is not
a complete proposal, and no runtime grant or policy exception is approved here.
Evidence: reset-authority-matrix-2026-10-31.json and its source manifest;
reset-authority-validation-2026-10-31.json. Ten direct password cases, eight native
Authelia flows, fourteen unrelated-write denials and fourteen matching admin
controls pass. All disposable containers are removed. Both original native reset
scenarios still pass; the full `make check` gate passes 546 tests with zero
failures/errors/skips. These checks add neither productive acceptance nor a
current membership observation. Existing expiry/browser/app proof retains its
separate scope; this matrix does not retest the complete issuer/MFA journey.

View file

@ -0,0 +1,265 @@
{
"schema": "coulomb.native-reset-authority-matrix.v1",
"observed_at": "2026-10-31T12:37:24.533367+00:00",
"data": "synthetic",
"status": "native_authority_matrix_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": "c982e3a47d9e3aec5095a3c11fea7260fd4f5cb6db57b827b915988b432a18d0",
"target_classes": {
"alice": "ordinary user",
"bob": "other ordinary user",
"operator": "lldap_admin member",
"appoperator": "application-admins member, not lldap_admin",
"svc-other": "other service, lldap_strict_readonly"
},
"findings": {
"scenarios": [
{
"bind_role": "lldap_strict_readonly",
"directory_writes": {
"positive_controls": 7,
"refused_mutations": [
"create_user",
"edit_other_user",
"delete_user",
"create_group",
"delete_group",
"self_promote_admin",
"remove_admin_membership"
],
"directory_metadata_unchanged": true
},
"direct_password_changes": [
{
"target": "alice",
"password_changed": false,
"old_password_accepted": true,
"new_password_accepted": false
},
{
"target": "bob",
"password_changed": false,
"old_password_accepted": true,
"new_password_accepted": false
},
{
"target": "operator",
"password_changed": false,
"old_password_accepted": true,
"new_password_accepted": false
},
{
"target": "appoperator",
"password_changed": false,
"old_password_accepted": true,
"new_password_accepted": false
},
{
"target": "svc-other",
"password_changed": false,
"old_password_accepted": true,
"new_password_accepted": false
}
],
"authelia_resets": [
{
"target": "alice",
"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
},
{
"target": "bob",
"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
},
{
"target": "operator",
"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
},
{
"target": "appoperator",
"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",
"directory_writes": {
"positive_controls": 7,
"refused_mutations": [
"create_user",
"edit_other_user",
"delete_user",
"create_group",
"delete_group",
"self_promote_admin",
"remove_admin_membership"
],
"directory_metadata_unchanged": true
},
"direct_password_changes": [
{
"target": "alice",
"password_changed": true,
"old_password_accepted": false,
"new_password_accepted": true
},
{
"target": "bob",
"password_changed": true,
"old_password_accepted": false,
"new_password_accepted": true
},
{
"target": "operator",
"password_changed": false,
"old_password_accepted": true,
"new_password_accepted": false
},
{
"target": "appoperator",
"password_changed": true,
"old_password_accepted": false,
"new_password_accepted": true
},
{
"target": "svc-other",
"password_changed": true,
"old_password_accepted": false,
"new_password_accepted": true
}
],
"authelia_resets": [
{
"target": "alice",
"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
},
{
"target": "bob",
"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
},
{
"target": "operator",
"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
},
{
"target": "appoperator",
"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
}
]
}
],
"cleanup_verified": true
}
}

View file

@ -0,0 +1,3 @@
scripts/rehearse-reset-authority.py: 120d3b8d4e54ec87386f9f2d53abe836feab48c7cc883a13e0d4672bcc347b0c
scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d
scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556

View file

@ -0,0 +1,48 @@
{
"schema": "coulomb.reset-authority-validation.v1",
"status": "source_and_isolated_native_verified",
"full_gate": {
"command": "make check",
"tests": 546,
"failures": 0,
"errors": 0,
"skipped": 0,
"junit_sha256": "ff99e36bad4e42a1983bdac2749b834a454a22c708065752ecd31f8be5ac963a"
},
"native_matrix": {
"roles": 2,
"direct_password_cases": 10,
"authelia_reset_cases": 8,
"unrelated_write_denials": 14,
"valid_admin_write_controls": 14,
"cleanup_verified": true
},
"legacy_native_reset_regression": "passed both original role scenarios",
"cli_refusals": [
"disabled assertions",
"existing receipt",
"existing source manifest"
],
"current_live_membership_verified": false,
"production_reset_verified": false,
"authority_grant_approved": false,
"sources": [
{
"path": "scripts/rehearse-reset-authority.py",
"sha256": "120d3b8d4e54ec87386f9f2d53abe836feab48c7cc883a13e0d4672bcc347b0c"
},
{
"path": "scripts/native_recovery/probe.py",
"sha256": "eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556"
},
{
"path": "docs/release/reset-authority-matrix-2026-10-31.json",
"sha256": "89c555ac2c580153bd81ae03c662d024a0cdbeba173f93e6ebfa041a1a0261c7"
},
{
"path": "docs/release/reset-default-regression-2026-10-31.json",
"sha256": "82efb79154b5e5003ce4b821ebeba73deb4375a13f4df93eddbd3b8908e705ea"
}
],
"upstream_reference": "https://github.com/lldap/lldap/blob/v0.6.2/README.md#general-configuration-guide"
}

View file

@ -0,0 +1,62 @@
{
"schema": "coulomb.native-reset-prerequisite.v1",
"data": "synthetic",
"status": "native_bind_role_reset_boundary_verified",
"observed_at": "2026-10-31T12:36:56.223367+00:00",
"images": {
"lldap": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d",
"authelia": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54",
"probe": "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd"
},
"production_access": false,
"browser_app_or_mfa_acceptance": false,
"provider_smtp_contacted": false,
"notifier": "private_tmpfs_file",
"no_ports_host_mounts_or_external_routes": true,
"backend_container_flags_verified_before_fixture_import": true,
"owner_runtime_or_permission_change_performed": false,
"findings": {
"scenarios": [
{
"bind_role": "lldap_strict_readonly",
"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,
"directory_group_creation_refused": true,
"native_reset_notification_present": true
},
{
"bind_role": "lldap_password_manager",
"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,
"directory_group_creation_refused": true,
"native_reset_notification_present": true
}
],
"cleanup_verified": true
},
"source_manifest_sha256": "1f3977f01757519dd5d08007b4baffa555f2cad44d9301d5dd484ded46f274e9"
}

View file

@ -0,0 +1,2 @@
scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d
scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556

View file

@ -14,6 +14,7 @@ def run(v):
assert len(Path('/proc/net/route').read_text().splitlines()) == 1
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
cookie = ''
target = v.get('target', 'alice')
def call(port, path, data=None, *, token=None, auth=False):
nonlocal cookie
@ -63,9 +64,22 @@ def run(v):
admin = login('admin', v['admin_password'])
assert admin, 'synthetic_admin_login'
if v['mode'] == 'bootstrap':
for user in ['alice', 'svc-auth']:
for user in (['alice', 'svc-auth', 'bob', 'operator', 'appoperator', 'svc-other',
'victim'] if v.get('authority_matrix') else ['alice', 'svc-auth']):
graphql(admin, 'mutation($u:CreateUserInput!){createUser(user:$u){id}}', {
'u': {'id': user, 'email': user + '@example.test', 'displayName': user}})
if v.get('authority_matrix'):
groups = graphql(admin, '{groups{id displayName}}')['groups']
admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin')
for user, group in [('operator', admin_id), ('svc-other', next(
g['id'] for g in groups if g['displayName'] == 'lldap_strict_readonly'))]:
graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup('
'userId:$u,groupId:$g){ok}}', {'u': user, 'g': group})
app_id = graphql(admin, 'mutation{createGroup(name:"application-admins"){id}}')
graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup('
'userId:$u,groupId:$g){ok}}',
{'u': 'appoperator', 'g': app_id['createGroup']['id']})
graphql(admin, 'mutation{createGroup(name:"victim-group"){id}}')
return {'status': 'prepared', 'admin_token': admin}
if v['mode'] == 'bind_role':
groups = graphql(admin, '{groups{id displayName}}')['groups']
@ -85,6 +99,54 @@ def run(v):
'query': 'mutation{createGroup(name:"unexpected-admin-rights"){id}}'}, token=service)
assert code >= 400 or body.get('errors'), 'directory_group_creation_refused'
return {'status': 'role_set', 'directory_group_creation_refused': True}
if v['mode'] == 'authority_context':
service = login('svc-auth', v['bind_password'])
assert service, 'service_directory_login'
return {'status': 'authority_context', 'service_token': service,
'admin_token': admin}
if v['mode'] == 'directory_authentication':
return {'old_password_accepted': bool(login(v['target'], v['old_password'])),
'new_password_accepted': bool(login(v['target'], v['new_password']))}
if v['mode'] == 'write_boundaries':
service = login('svc-auth', v['bind_password'])
assert service, 'service_directory_login'
# Positive controls distinguish an authorization refusal from invalid queries.
create_user = 'mutation($u:CreateUserInput!){createUser(user:$u){id}}'
update_user = 'mutation($u:UpdateUserInput!){updateUser(user:$u){ok}}'
delete_user = 'mutation($u:String!){deleteUser(userId:$u){ok}}'
create_group = 'mutation($n:String!){createGroup(name:$n){id}}'
delete_group = 'mutation($g:Int!){deleteGroup(groupId:$g){ok}}'
add = 'mutation($u:String!,$g:Int!){addUserToGroup(userId:$u,groupId:$g){ok}}'
remove = 'mutation($u:String!,$g:Int!){removeUserFromGroup(userId:$u,groupId:$g){ok}}'
graphql(admin, create_user, {'u': {'id': 'control', 'email': 'control@example.test'}})
graphql(admin, update_user, {'u': {'id': 'control', 'email': 'changed@example.test'}})
gid = graphql(admin, create_group, {'n': 'control-group'})['createGroup']['id']
graphql(admin, add, {'u': 'control', 'g': gid})
graphql(admin, remove, {'u': 'control', 'g': gid})
graphql(admin, delete_group, {'g': gid})
graphql(admin, delete_user, {'u': 'control'})
snapshot_query = '{users{id email displayName groups{id}} groups{id displayName}}'
before = graphql(admin, snapshot_query)
groups = before['groups']
admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin')
victim_id = next(g['id'] for g in groups if g['displayName'] == 'victim-group')
mutations = [
('create_user', create_user, {'u': {'id': 'unexpected', 'email': 'x@example.test'}}),
('edit_other_user', update_user, {'u': {'id': 'victim', 'email': 'x@example.test'}}),
('delete_user', delete_user, {'u': 'victim'}),
('create_group', create_group, {'n': 'unexpected'}),
('delete_group', delete_group, {'g': victim_id}),
('self_promote_admin', add, {'u': 'svc-auth', 'g': admin_id}),
('remove_admin_membership', remove, {'u': 'operator', 'g': admin_id})]
denials = []
for label, query, variables in mutations:
code, body = call(17170, '/api/graphql', {'query': query, 'variables': variables},
token=service)
assert code >= 400 or body.get('errors'), 'unexpected_directory_write_' + label
denials.append(label)
assert graphql(admin, snapshot_query) == before, 'directory_metadata_changed'
return {'positive_controls': len(mutations), 'refused_mutations': denials,
'directory_metadata_unchanged': True}
ready(9091, '/api/health')
if v['mode'] == 'reset_finish':
code, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True)
@ -102,18 +164,18 @@ def run(v):
'native_identity_finish': True,
'reset_without_verified_challenge_refused': True,
'consumed_identity_token_replay_refused': True,
'old_password_accepted_after': bool(login('alice', v['old_password'])),
'new_password_accepted_after': bool(login('alice', v['new_password']))}
assert login('alice', v['old_password']), 'initial_password'
assert not login('alice', v['new_password']), 'replacement_not_initially_accepted'
'old_password_accepted_after': bool(login(target, v['old_password'])),
'new_password_accepted_after': bool(login(target, v['new_password']))}
assert login(target, v['old_password']), 'initial_password'
assert not login(target, v['new_password']), 'replacement_not_initially_accepted'
code, body = call(9091, '/api/firstfactor', {
'username': 'svc-auth', 'password': v['bind_password'], 'keepMeLoggedIn': False}, auth=True)
assert code != 200 or body.get('status') != 'OK', 'interactive_service_login_refused'
code, body = call(9091, '/api/firstfactor', {
'username': 'alice', 'password': v['old_password'], 'keepMeLoggedIn': False}, auth=True)
'username': target, 'password': v['old_password'], 'keepMeLoggedIn': False}, auth=True)
assert code == 200 and body.get('status') == 'OK', 'native_first_factor'
cookie = ''
code, _ = call(9091, '/api/reset-password/identity/start', {'username': 'alice'}, auth=True)
code, _ = call(9091, '/api/reset-password/identity/start', {'username': target}, auth=True)
assert code == 200, 'reset_start'
return {'status': 'reset_requested', 'only_loopback_interface': True,
'external_routes_absent': True, 'old_password_initially_accepted': True,

View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Measure reset rights on disposable native peers; never contact production."""
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-authority-' + uuid.uuid4().hex[:12]
auth = name + '-auth'
values = {key: secrets.token_urlsafe(24) for key in (
'admin_password', 'bind_password', 'old_password', 'new_password')}
targets = ['alice', 'bob', 'operator', 'appoperator', 'svc-other']
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
for role in ['lldap_strict_readonly', 'lldap_password_manager']:
probe(name, {**values, 'mode': 'bind_role', 'role': role})
context = probe(name, {**values, 'mode': 'authority_context'})
writes = probe(name, {**values, 'mode': 'write_boundaries'})
scenario = {'bind_role': role, 'directory_writes': writes,
'direct_password_changes': [], 'authelia_resets': []}
for target in targets:
assert set_password(name, context['admin_token'], target,
values['old_password']) == 0
before = probe(name, {**values, 'mode': 'directory_authentication',
'target': target})
assert before == {'old_password_accepted': True, 'new_password_accepted': False}
code = set_password(name, context['service_token'], target, values['new_password'])
after = probe(name, {**values, 'mode': 'directory_authentication',
'target': target})
expected = role == 'lldap_password_manager' and target != 'operator'
assert (code == 0) is expected, 'unexpected_direct_password_right'
assert after == {'old_password_accepted': not expected,
'new_password_accepted': expected}
scenario['direct_password_changes'].append({
'target': target, 'password_changed': expected, **after})
# Separate fresh stores make reset/link checks independent of earlier scenarios.
for target in ['alice', 'bob', 'operator', 'appoperator']:
assert set_password(name, context['admin_token'], target,
values['old_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()}))
docker('exec', '-d', auth, '/bin/sh', '-c',
'exec /app/authelia --config /run/fixture/configuration.yml '
'> /run/fixture/server.log 2>&1')
requested = probe(name, {**values, 'mode': 'reset_request', 'target': target})
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_native_challenge_required'
token = urllib.parse.parse_qs(urllib.parse.urlsplit(links[0]).query)['token'][0]
settled = probe(name, {**values, 'mode': 'reset_finish', 'target': target,
'reset_token': token})
expected = role == 'lldap_password_manager' and target != 'operator'
assert settled['new_password_accepted_after'] is expected
assert settled['old_password_accepted_after'] is not expected
assert (settled['status'] == 'reset_completed') is expected
scenario['authelia_resets'].append({'target': target, **requested, **settled})
docker('rm', '-f', '-v', auth)
result['scenarios'].append(scenario)
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-authority-matrix.v1',
'observed_at': datetime.now(UTC).isoformat(), 'data': 'synthetic',
'status': 'native_authority_matrix_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(),
'target_classes': {'alice': 'ordinary user', 'bob': 'other ordinary user',
'operator': 'lldap_admin member',
'appoperator': 'application-admins member, not lldap_admin',
'svc-other': 'other service, lldap_strict_readonly'},
'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()

View file

@ -266,6 +266,17 @@ Evidence: docs/release/reset-owner-return-assessment-2026-10-31.json. No credent
mail, password, directory grant or live configuration was changed. Prior personal
reset acceptance is preserved with its original date and scope.
2026-10-31 authority preparation: owner T11 records the native role matrix;
T10 is progress. Password-manager permits ordinary/application-admin/non-admin
service password changes but refuses LLDAP administrators. Seven other directory
writes per role refuse with valid admin controls. Ten direct cases and eight
Authelia flows pass; all containers removed. Full gate546 and original reset
regression pass. Detailed scope and next admission conditions are in
native-password-reset-review-2026-10-30.md and reset-authority-validation-2026-10-31.json.
A complete runtime proposal still needs current subject classification and a
supported directory-admin recovery route. T06 remains progress, with no live
grant, reset or productive acceptance inferred.
## 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.