diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2857458..2ee98ff 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -322,6 +322,8 @@ | task | NK-WP-0054-T09 | done | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T10 | progress | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T11 | done | — | workplans/NK-WP-0054-service-identity-policy.md | +| task | NK-WP-0054-T12 | done | — | workplans/NK-WP-0054-service-identity-policy.md | +| task | NK-WP-0054-T13 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0055-T01 | done | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T02 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T03 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | diff --git a/docs/evidence/2026-10-31-reset-authority/observer-preparation.json b/docs/evidence/2026-10-31-reset-authority/observer-preparation.json new file mode 100644 index 0000000..dde7d60 --- /dev/null +++ b/docs/evidence/2026-10-31-reset-authority/observer-preparation.json @@ -0,0 +1,77 @@ +{ + "schema": "netkingdom.reset-observer-preparation.v1", + "status": "tested_ready_for_attended_read", + "owner_task": "NK-WP-0054-T12", + "operator_return_task": "NK-WP-0054-T13", + "consumer_task": "CSOC-WP-0015-T06", + "accepted_subject": "tegwick", + "acceptance_source": { + "repo": "coulomb-social", + "path": "history/20261006T185411Z-personal-workspace-acceptance.md", + "sha256": "15ac9e1af4d0cb16b854d1ecf5fa89b634b1479174b02143b5c341cb3b5df2c3" + }, + "technical_binding_decision_id": "8770d4ea-8538-4f88-a9f8-8bcfb0542603", + "queries": [ + "human admin role", + "svc-authelia roles", + "tegwick roles" + ], + "private_input": "human admin password via terminal only", + "directory_mutations_available": false, + "production_observation_performed": false, + "password_reset_attempted": false, + "grant_approved": false, + "target_url": "https://lldap.coulomb.social", + "planned_private_receipt": "/home/worsch/.local/state/net-kingdom/reset-authority/roles-20261031.json", + "gates": { + "unit_and_transport": { + "tests": 92, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "7ee639ba82c7100d7805cc01ca5c2a6c502550eac4abdb252ad3c7d8b1ac232b" + }, + "native_split_regression": { + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "b35645a61c52f45d0e826e3986145212f45d4abc065026b42bd651f6f4b5fb41" + }, + "final_native_observer": { + "tests": 1, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "251831ae820081aa6e935d08c4e7854a1ad5cf840b8a41ae8af4add5f5406f2a" + } + }, + "native_fixture": "pinned owner LLDAP fixture; synthetic credentials, loopback-only published ports; container and volume removal verified", + "lint": "passed", + "sources": [ + { + "path": "sso-mfa/k8s/lldap/reset-authority-observe.py", + "sha256": "9f19c5551e2a93319c04c1b2fd0ddc0f7757fdff96ad18b81d7ee7bb393b75b9" + }, + { + "path": "sso-mfa/k8s/lldap/lldap_admin.py", + "sha256": "5e39e2eac53846a97cd25151c0a4af1ffc0230db881a6325efb66de6a51bfc36" + }, + { + "path": "identity-provisioner/provisioner.py", + "sha256": "a303a25d3b3876485c796a9ba0836ddaf0a0bec155b609790e028882ad983138" + }, + { + "path": "identity-provisioner/group_provenance.py", + "sha256": "405e08eaaab7c21d618dff4db5970607806f32ab6c461effb81c737e32182b10" + }, + { + "path": "tests/test_reset_authority_observer.py", + "sha256": "348fab447a4182357bf3f7d058b05c7d6088ae084d8e433e76ce5c39c513e152" + }, + { + "path": "sso-mfa/k8s/lldap/test_bind_split.py", + "sha256": "ae69051a3df4596fe220306d88a28b2c12e42fbfe3587e02135e3684eb5f354a" + } + ] +} diff --git a/sso-mfa/k8s/lldap/reset-authority-observe.py b/sso-mfa/k8s/lldap/reset-authority-observe.py new file mode 100644 index 0000000..f4c8ac2 --- /dev/null +++ b/sso-mfa/k8s/lldap/reset-authority-observe.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Attended, read-only reset-role classification; no credential discovery or grant.""" + +import argparse +import getpass +import hashlib +import importlib.util +import json +import os +import re +import stat +import sys +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('reset_observer_transport', HERE / 'lldap_admin.py') +transport = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(transport) +QUERY = 'query ResetRoles($id:String!){user(userId:$id){id groups{displayName}}}' +ROLES = ('lldap_admin', 'lldap_password_manager', 'lldap_strict_readonly') +# Coulomb's retained personal-reset acceptance, 2026-10-06. No caller-selected live subject. +ACCEPTED_SUBJECT = 'tegwick' +ROOT = HERE.parents[2] +SOURCES = (Path(__file__), HERE / 'lldap_admin.py', + ROOT / 'identity-provisioner/provisioner.py', + ROOT / 'identity-provisioner/group_provenance.py') + + +class Refused(Exception): + pass + + +class Parser(argparse.ArgumentParser): + def error(self, message): + raise Refused('invalid_arguments') + + +def identifier(value): + if not isinstance(value, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', value): + raise Refused('invalid_subject_identifier') + return value + + +class Observer: + def __init__(self, endpoint, opener=None): + self.base = transport.endpoint(endpoint) + self.opener = opener or transport.Transport(self.base) + + def post(self, path, payload, token=None): + headers = {'Content-Type': 'application/json'} + if token: + headers['Authorization'] = 'Bearer ' + token + request = urllib.request.Request(self.base + path, data=json.dumps(payload).encode(), + headers=headers, method='POST') + return json.load(self.opener(request, timeout=15)) + + def classify(self, token, subject): + body = self.post('/api/graphql', {'query': QUERY, 'variables': {'id': subject}}, token) + if not isinstance(body, dict) or body.get('errors') or set(body) != {'data'}: + raise Refused('classification_unavailable') + data = body['data'] + if not isinstance(data, dict) or set(data) != {'user'}: + raise Refused('invalid_classification_response') + user = data['user'] + if not isinstance(user, dict) or set(user) != {'id', 'groups'} or user['id'] != subject: + raise Refused('subject_not_exactly_observed') + groups = user['groups'] + if not isinstance(groups, list) or len(groups) > 1000: + raise Refused('invalid_group_metadata') + names = [] + for group in groups: + if not isinstance(group, dict) or set(group) != {'displayName'}: + raise Refused('invalid_group_metadata') + name = group['displayName'] + if (not isinstance(name, str) or not 1 <= len(name) <= 256 + or any(ord(c) < 32 for c in name) or name in names): + raise Refused('invalid_group_metadata') + names.append(name) + builtin = {role: role in names for role in ROLES} + return {'builtin_roles': builtin, 'other_group_count': len(set(names) - set(ROLES)), + 'matches_reviewed_service_name_filter': + subject.startswith('svc-') or subject == 'ld-admin', + 'application_authority_assessed': False} + + def observe(self, subject, password): + subject = identifier(subject) + if not isinstance(password, str) or not password or len(password.encode()) > 4096: + raise Refused('invalid_private_input') + body = self.post('/auth/simple/login', {'username': 'admin', 'password': password}) + token = body.get('token') if isinstance(body, dict) else None + if (not isinstance(token, str) or not token or len(token) > 16384 + or any(not 33 <= ord(c) <= 126 for c in token)): + raise Refused('admin_authentication_unavailable') + admin = self.classify(token, 'admin') + if not admin['builtin_roles']['lldap_admin']: + raise Refused('human_admin_role_not_observed') + bind = self.classify(token, 'svc-authelia') + selected = self.classify(token, subject) + return {'schema': 'netkingdom.reset-role-observation.v1', + 'status': 'current_reset_roles_observed', + 'observed_at': datetime.now(UTC).isoformat(), + 'endpoint_sha256': hashlib.sha256(self.base.encode()).hexdigest(), + 'selected_subject_sha256': hashlib.sha256( + ('reset-role-subject-v1:' + subject).encode()).hexdigest(), + 'human_admin_role_verified': True, 'bind': bind, 'selected_subject': selected, + 'bind_has_multiple_builtin_roles': sum(bind['builtin_roles'].values()) > 1, + 'membership_changed': False, 'password_reset_attempted': False, + 'grant_approved': False, 'application_access_verified': False, + 'credential_values_recorded': False, + 'sources': [{'path': str(p.relative_to(ROOT)), + 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()} + for p in SOURCES]} + + +def receipt_path(value): + path = Path(os.path.abspath(Path(value).expanduser())) + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('receipt_symlink_refused') + if any((p / '.git').exists() for p in path.parents): + raise Refused('receipt_must_be_outside_repository') + parent = path.parent.stat() + if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.getuid() or parent.st_mode & 0o077: + raise Refused('private_owned_receipt_directory_required') + if path.exists(): + raise Refused('fresh_receipt_required') + return path + + +def main(argv=None): + parser = Parser(description=__doc__) + parser.add_argument('--endpoint', default='https://lldap.coulomb.social') + parser.add_argument('--receipt', required=True) + try: + args = parser.parse_args(argv) + if not sys.stdin.isatty() or not sys.stderr.isatty(): + raise Refused('human_terminal_required') + path = receipt_path(args.receipt) + observer = Observer(args.endpoint) + password = getpass.getpass('Password of the human admin account (hidden): ') + result = observer.observe(ACCEPTED_SUBJECT, password) + raw = (json.dumps(result, indent=2) + '\n').encode() + 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()) + print(raw.decode(), end='') + return 0 + except Exception as error: # noqa: BLE001 -- every private-input failure must redact its body. + # Never include transport bodies, usernames, credentials or arbitrary exception text. + print(json.dumps({'status': 'observation_refused', + 'reason': str(error) if isinstance(error, Refused) + else 'observation_unavailable'}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/sso-mfa/k8s/lldap/test_bind_split.py b/sso-mfa/k8s/lldap/test_bind_split.py index 37fd9ac..87dc745 100644 --- a/sso-mfa/k8s/lldap/test_bind_split.py +++ b/sso-mfa/k8s/lldap/test_bind_split.py @@ -65,6 +65,39 @@ def admin_fd(password): return read +def test_reset_observer_reads_native_roles_without_directory_changes(lab): + """Actual GraphQL schema/transport; synthetic inputs, no native authority grant.""" + _, endpoint, admin_password, fixture = lab + observer = load("native_reset_observer", HERE / "reset-authority-observe.py") + directory = split.Directory(endpoint) + token = directory.login("admin", admin_password) + try: + for user in ["svc-authelia", "selected-person"]: + directory.gql(token, "mutation($u:CreateUserInput!){createUser(user:$u){id}}", + {"u": {"id": user, "email": user + "@example.test"}}) + _, groups = directory.snapshot(token) + directory.gql(token, "mutation($u:String!,$g:Int!){addUserToGroup(" + "userId:$u,groupId:$g){ok}}", + {"u": "svc-authelia", "g": groups["lldap_strict_readonly"]}) + before = directory.snapshot(token) + reader = observer.Observer(endpoint) + ordinary = reader.observe("selected-person", admin_password) + privileged = reader.observe("admin", admin_password) + assert ordinary["bind"]["builtin_roles"]["lldap_strict_readonly"] + assert not ordinary["selected_subject"]["builtin_roles"]["lldap_admin"] + assert privileged["selected_subject"]["builtin_roles"]["lldap_admin"] + with pytest.raises(observer.Refused): + reader.observe("missing-person", admin_password) + assert directory.snapshot(token) == before + assert admin_password not in json.dumps([ordinary, privileged]) + finally: + fixture.remove() + assert subprocess.run(["docker", "inspect", fixture.name], + capture_output=True, check=False).returncode != 0 + assert subprocess.run(["docker", "volume", "inspect", fixture.volume], + capture_output=True, check=False).returncode != 0 + + def test_full_split_on_a_production_shaped_directory(lab, tmp_path): root, endpoint, admin_password, _ = lab assert split.prepare_custody(root)["changed_keys"][0] == "LLDAP_LDAP_USER_PASS" diff --git a/tests/test_reset_authority_observer.py b/tests/test_reset_authority_observer.py new file mode 100644 index 0000000..626df29 --- /dev/null +++ b/tests/test_reset_authority_observer.py @@ -0,0 +1,169 @@ +"""No-send role observation, credential containment and exact subject binding.""" + +import copy +import importlib.util +import io +import json +import stat +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + 'reset_observer', ROOT / 'sso-mfa/k8s/lldap/reset-authority-observe.py') +observer = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(observer) + + +class Directory: + def __init__(self, subject_roles=(), bind_roles=('lldap_strict_readonly',)): + self.roles = {'admin': ['lldap_admin'], 'svc-authelia': list(bind_roles), + 'private-person': list(subject_roles), 'tegwick': list(subject_roles)} + self.requests = [] + self.transform = lambda value: value + + def __call__(self, request, timeout): + payload = json.loads(request.data) + self.requests.append((request.full_url, payload)) + if request.full_url.endswith('/auth/simple/login'): + assert payload == {'username': 'admin', 'password': 'private-password-marker'} + return io.BytesIO(b'{"token":"private-token-marker"}') + assert request.full_url == 'https://lldap.coulomb.social/api/graphql' + assert payload['query'] == observer.QUERY and not payload['query'].startswith('mutation') + assert request.get_header('Authorization') == 'Bearer private-token-marker' + uid = payload['variables']['id'] + value = {'data': {'user': {'id': uid, + 'groups': [{'displayName': g} for g in self.roles[uid]]}}} + return io.BytesIO(json.dumps(self.transform(value)).encode()) + + +@pytest.mark.parametrize('roles', [[], ['lldap_admin'], ['lldap_password_manager'], + ['private-application-admin-group']]) +def test_exact_classification_only_and_no_mutations(roles): + directory = Directory(roles) + result = observer.Observer('https://lldap.coulomb.social', directory).observe( + 'private-person', 'private-password-marker') + assert [r[1].get('variables', {}).get('id') for r in directory.requests] == [ + None, 'admin', 'svc-authelia', 'private-person'] + assert result['selected_subject']['builtin_roles']['lldap_admin'] == ('lldap_admin' in roles) + assert result['selected_subject']['application_authority_assessed'] is False + assert not result['membership_changed'] and not result['password_reset_attempted'] + assert not result['grant_approved'] and not result['application_access_verified'] + raw = json.dumps(result) + for private in ['private-person', 'private-password-marker', 'private-token-marker', + 'private-application-admin-group']: + assert private not in raw + assert len(result['sources']) == 4 + + +def test_ambiguous_bind_roles_reported_without_effective_permission_claim(): + directory = Directory(bind_roles=['lldap_strict_readonly', 'lldap_password_manager']) + result = observer.Observer('https://lldap.coulomb.social', directory).observe( + 'private-person', 'private-password-marker') + assert result['bind_has_multiple_builtin_roles'] + assert not result['grant_approved'] + + +@pytest.mark.parametrize('defect', ['wrong-subject', 'missing', 'duplicate-group', + 'malformed-group', 'error-body', 'extra-fields']) +def test_untrusted_responses_refuse_without_reporting_observation(defect): + directory = Directory() + + def transform(value): + value = copy.deepcopy(value) + if defect == 'wrong-subject': + value['data']['user']['id'] = 'different-person' + elif defect == 'missing': + value['data']['user'] = None + elif defect == 'duplicate-group': + value['data']['user']['groups'] *= 2 + elif defect == 'malformed-group': + value['data']['user']['groups'] = [{'displayName': 'unsafe\nvalue'}] + elif defect == 'error-body': + value['errors'] = [{'message': 'private-provider-body'}] + else: + value['data']['user']['email'] = 'private@example.test' + return value + + directory.transform = transform + with pytest.raises(observer.Refused): + observer.Observer('https://lldap.coulomb.social', directory).observe( + 'private-person', 'private-password-marker') + + +@pytest.mark.parametrize('subject', ['', 'Bad User', 'alice"}{mutation', 'a' * 129]) +def test_invalid_subject_refuses_before_authentication(subject): + directory = Directory() + with pytest.raises(observer.Refused): + observer.Observer('https://lldap.coulomb.social', directory).observe( + subject, 'private-password-marker') + assert not directory.requests + + +@pytest.mark.parametrize('defect', ['existing', 'symlink', 'public-parent', 'in-repository']) +def test_unsafe_receipt_refused_before_private_prompts(tmp_path, monkeypatch, defect): + tmp_path.chmod(0o700) + path = tmp_path / 'receipt.json' + if defect == 'existing': + path.write_text('preserve') + elif defect == 'symlink': + path.symlink_to(tmp_path / 'missing') + elif defect == 'public-parent': + tmp_path.chmod(0o755) + else: + (tmp_path / '.git').mkdir() + monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: True) + monkeypatch.setattr(observer.sys.stderr, 'isatty', lambda: True) + monkeypatch.setattr(observer.getpass, 'getpass', lambda prompt: pytest.fail('private prompt')) + assert observer.main(['--receipt', str(path)]) == 1 + if defect == 'existing': + assert path.read_text() == 'preserve' + + +def test_nonterminal_refuses_before_private_prompt(tmp_path, monkeypatch): + monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: False) + monkeypatch.setattr(observer.getpass, 'getpass', lambda prompt: pytest.fail('private prompt')) + assert observer.main(['--receipt', str(tmp_path / 'receipt.json')]) == 1 + + +@pytest.mark.parametrize('endpoint', ['https://attacker.invalid', + 'https://lldap.coulomb.social@attacker.invalid']) +def test_origin_refuses_before_private_prompt(tmp_path, monkeypatch, endpoint): + tmp_path.chmod(0o700) + monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: True) + monkeypatch.setattr(observer.sys.stderr, 'isatty', lambda: True) + monkeypatch.setattr(observer.getpass, 'getpass', lambda prompt: pytest.fail('private prompt')) + assert observer.main(['--receipt', str(tmp_path / 'receipt.json'), + '--endpoint', endpoint]) == 1 + + +@pytest.mark.parametrize('fail', [False, True]) +def test_cli_private_receipt_and_sanitized_failure(tmp_path, monkeypatch, capsys, fail): + tmp_path.chmod(0o700) + path = tmp_path / 'receipt.json' + monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: True) + monkeypatch.setattr(observer.sys.stderr, 'isatty', lambda: True) + prompts = [] + def private_input(prompt): + prompts.append(prompt) + return 'private-password-marker' + monkeypatch.setattr(observer.getpass, 'getpass', private_input) + directory = Directory() + monkeypatch.setattr(observer.transport, 'Transport', lambda base: directory) + if fail: + def broken(value): + raise RuntimeError('private-provider-body private-token-marker') + directory.transform = broken + assert observer.main(['--receipt', str(path)]) == int(fail) + assert len(prompts) == 1 + captured = capsys.readouterr() + for private in ['private-person', 'private-password-marker', 'private-token-marker', + 'private-provider-body']: + assert private not in captured.out + captured.err + if fail: + assert not path.exists() + else: + assert directory.requests[-1][1]['variables']['id'] == 'tegwick' + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert json.loads(path.read_text())['status'] == 'current_reset_roles_observed' diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index 216ad82..e472c01 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -492,7 +492,7 @@ role's actual scope on the pinned binaries. Password-manager refuses LLDAP admin but can change non-LDAP-admin service and application-admin passwords. Therefore no one-line role promotion is an execution-ready remedy. Next: value-free current membership/subject classification, owner review of the service write grant and -an explicit supported directory-admin recovery path, then the guarded packet. +a supported directory-admin recovery path if the accepted subject needs it, then the guarded packet. No existing identity grant, declaration, helper or runtime was changed. ## Rehearse native password-reset authority and unrelated write limits @@ -530,3 +530,63 @@ Consumer validation binds the full gate (546 passed, no failures/errors/skips), both original native reset cases and three CLI refusal checks. T10 retains current membership observation, complete grant/recovery design, admission, supervised execution and native backup/restore acceptance; none is closed by this rehearsal. + + +2026-10-31 subject-binding correction: the retained October6 acceptance identifies +`tegwick`. Treating a new recovery route for every directory administrator as a +Coulomb prerequisite before observing that subject would expand the original +contract. Technical decision8770d4ea-8538-4f88-a9f8-8bcfb0542603 binds the check to +tegwick; directory-admin recovery blocks this consumer only if that subject is +in lldap_admin. The denial remains an explicit product/owner boundary and is not +silently waived. Broad password-manager authority still needs reviewed admission. +T12 is the completed read-only observer preparation; T13 owns the actual result. + +## Prepare the fixed-subject reset-role observer + +```task +id: NK-WP-0054-T12 +status: done +priority: high +assignee: codex +``` + +Completed 2026-10-31. `sso-mfa/k8s/lldap/reset-authority-observe.py` reuses the +existing origin-restricted, TLS-verified, no-redirect transport. It queries only +admin, svc-authelia and the accepted personal account tegwick; no mutation or +credential-discovery operation exists. The human admin password is prompted only +in an attended terminal. Receipt creation requires a private owned directory, +refuses an existing file/symlink/repository location and writes mode0600. Receipts +contain role booleans/counts and source/subject hashes, not passwords, bearer +tokens, raw usernames or arbitrary group names. Provider failures are redacted. + +92 unit/transport checks pass, including the reused transport's redirect/origin +controls. All four native bind-split tests pass; the final observer node separately +passes against the pinned LLDAP image, proving ordinary/admin classification, +unknown-user refusal and unchanged directory metadata. Its fixture container and +volume are removed. Ruff passes. Evidence: docs/evidence/2026-10-31-reset-authority/ +observer-preparation.json. The runtime observation remains T13; fixture success +neither grants password-write authority nor proves reset or application access. + +## Obtain the attended current reset-role observation + +```task +id: NK-WP-0054-T13 +status: wait +priority: high +assignee: codex +needs_human: true +wait_kind: human +blocked_on: message-from:founder +blocking_reason: "Prepared observer needs the human admin's private terminal sign-in; no current directory-role result has been supplied." +intervention_note: "Run reset-authority-observe.py for the fixed accepted account tegwick using the commands in coulomb-social/docs/release/native-password-reset-review-2026-10-30.md. Enter the human admin password only at its hidden prompt and return the credential-free JSON. No directory writes or reset are performed." +``` + +T12 is complete. The next result is `netkingdom.reset-role-observation.v1` with +status `current_reset_roles_observed`, source hashes, svc-authelia role flags and +tegwick role flags. Validate the exact target/source binding, current membership +and any multiple-built-in-role ambiguity before constructing the runtime proposal. +A refusal or missing user is a diagnostic outcome, not permission to replace the +accepted subject or widen access. Do not collect the password in chat or retrieve +it from custody. This human step supplies private authentication, not a new policy +approval. The directory-admin route is conditional on the accepted subject's +actual classification; every runtime grant/change remains separately reviewed.