Verify native reset authority across account classes
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m5s
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:
parent
338340222d
commit
f763379882
9 changed files with 670 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
265
docs/release/reset-authority-matrix-2026-10-31.json
Normal file
265
docs/release/reset-authority-matrix-2026-10-31.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
scripts/rehearse-reset-authority.py: 120d3b8d4e54ec87386f9f2d53abe836feab48c7cc883a13e0d4672bcc347b0c
|
||||
scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d
|
||||
scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556
|
||||
48
docs/release/reset-authority-validation-2026-10-31.json
Normal file
48
docs/release/reset-authority-validation-2026-10-31.json
Normal 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"
|
||||
}
|
||||
62
docs/release/reset-default-regression-2026-10-31.json
Normal file
62
docs/release/reset-default-regression-2026-10-31.json
Normal 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"
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d
|
||||
scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
156
scripts/rehearse-reset-authority.py
Normal file
156
scripts/rehearse-reset-authority.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue