messagefoundry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,1323 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """AuthService — orchestrates authentication, sessions, role resolution, and first-run bootstrap.
4
+
5
+ Pure engine-side code (no FastAPI): the API layer composes it. It ties together the store (users,
6
+ roles, sessions, audit), password hashing/policy, opaque session tokens, and the LDAP/Kerberos
7
+ authenticators. Local and AD users share one identity model; an AD user's roles are re-synced from
8
+ their directory groups on every login, so :meth:`identity_for_token` can resolve everyone uniformly
9
+ from ``user_roles``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import ipaddress
16
+ import json
17
+ import logging
18
+ import os
19
+ import secrets
20
+ import time
21
+ from collections.abc import Callable, Iterable, Sequence
22
+ from dataclasses import dataclass
23
+ from typing import Any, TypeVar
24
+ from uuid import uuid4
25
+
26
+ from messagefoundry.auth import totp
27
+ from messagefoundry.auth.identity import AuthProvider, Identity
28
+ from messagefoundry.auth.ldap import AdPrincipal, LdapAuthenticator, LdapError, kerberos_principal
29
+ from messagefoundry.auth.notifications import (
30
+ ACCOUNT_DISABLED,
31
+ ACCOUNT_LOCKED,
32
+ ADMIN_NEW_IP,
33
+ EMAIL_CHANGED,
34
+ LOGIN_AFTER_FAILURES,
35
+ MFA_DISABLED,
36
+ MFA_ENABLED,
37
+ PASSWORD_CHANGED,
38
+ PASSWORD_RESET,
39
+ ROLES_CHANGED,
40
+ SUSPICIOUS_LOGIN_FAILURE_THRESHOLD,
41
+ SecurityEvent,
42
+ SecurityNotifier,
43
+ )
44
+ from messagefoundry.auth.passwords import hash_password, needs_rehash, verify_password
45
+ from messagefoundry.auth.permissions import ROLE_METADATA, Permission, Role
46
+ from messagefoundry.auth.policy import PasswordPolicy, _operator_corpus
47
+ from messagefoundry.auth.ratelimit import SlidingWindowRateLimiter
48
+ from messagefoundry.auth.tokens import hash_token, mint_token
49
+ from messagefoundry.config.settings import AuthSettings
50
+ from messagefoundry.store.base import AdminStore
51
+ from messagefoundry.store.store import SessionRecord, UserRecord
52
+
53
+ _log = logging.getLogger(__name__)
54
+
55
+ #: The account created on first run when the store has no users (HIPAA unique-user bootstrap).
56
+ BOOTSTRAP_USERNAME = "admin"
57
+
58
+
59
+ def _warn_if_corpus_unreadable(path: str | None) -> None:
60
+ """Eagerly load (and cache) an operator breach corpus at startup so a misconfigured path surfaces
61
+ as a clear warning rather than silently disabling the check on every later password change."""
62
+ if not path:
63
+ return
64
+ try:
65
+ entries, hashed = _operator_corpus(path)
66
+ except OSError as exc:
67
+ _log.warning(
68
+ "password_breach_corpus_file %r could not be read (%s); the larger breach corpus is "
69
+ "disabled (the bundled top-10k list still applies)",
70
+ path,
71
+ exc,
72
+ )
73
+ return
74
+ _log.info(
75
+ "loaded operator breach corpus from %r (%d %s entries)",
76
+ path,
77
+ len(entries),
78
+ "hashed" if hashed else "plaintext",
79
+ )
80
+
81
+
82
+ #: A fixed argon2 hash used to equalize login timing for unknown/disabled accounts (anti-enumeration).
83
+ _DUMMY_PASSWORD_HASH = hash_password("mf-login-timing-equalizer")
84
+
85
+ #: Cap on concurrent argon2 hashes/verifies so an unauthenticated login flood can't exhaust the
86
+ #: thread-pool executor (and starve all login/AD/password work). Argon2 is deliberately CPU-heavy.
87
+ _ARGON2_MAX_CONCURRENCY = max(2, min(8, os.cpu_count() or 2))
88
+
89
+ # Bound on the per-process new-client-IP dedup cache (WP-L3-13). It only debounces the audit/notify
90
+ # side effects of the 8.4.2 signal; the step-up decision never depends on it, so eviction is harmless.
91
+ _NEW_IP_DEDUP_MAX = 4096
92
+
93
+ _T = TypeVar("_T")
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class LoginOutcome:
98
+ """Result of a login attempt. ``error`` is for logs/audit — never leak the reason to clients."""
99
+
100
+ ok: bool
101
+ token: str | None = None
102
+ identity: Identity | None = None
103
+ must_change_password: bool = False
104
+ error: str | None = None
105
+ #: The password was accepted but the session still needs a second factor (TOTP / recovery code)
106
+ #: before it may perform step-up (sensitive) operations — the client should prompt for a code and
107
+ #: call ``POST /auth/mfa-verify`` (WP-14, ASVS 6.3.3). Always False for an MFA-delegated AD login.
108
+ mfa_required: bool = False
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class MfaEnrollment:
113
+ """A staged (not-yet-confirmed) TOTP enrollment: the base32 secret to render as a QR + the
114
+ ``otpauth://`` URI. Returned **once**; confirmed by proving a live code."""
115
+
116
+ secret: str
117
+ otpauth_uri: str
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class MfaStatus:
122
+ """A local user's current MFA posture, for ``GET /me/mfa``."""
123
+
124
+ enabled: bool
125
+ enrolled_at: float | None
126
+ recovery_codes_remaining: int
127
+ required: bool
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class BootstrapAdmin:
132
+ """Credentials for the one-time bootstrap admin (printed once, then must be changed)."""
133
+
134
+ username: str
135
+ password: str
136
+
137
+
138
+ def _roles_from_ids(ids: Iterable[str]) -> frozenset[Role]:
139
+ """Map stored role ids to :class:`Role`; silently drop unknown ids (deny-by-default)."""
140
+ out: set[Role] = set()
141
+ for rid in ids:
142
+ try:
143
+ out.add(Role(rid))
144
+ except ValueError:
145
+ continue
146
+ return frozenset(out)
147
+
148
+
149
+ def _json(obj: Any) -> str:
150
+ return json.dumps(obj, sort_keys=True)
151
+
152
+
153
+ def _allowed_channels(user: UserRecord, roles: frozenset[Role]) -> frozenset[str] | None:
154
+ """Resolve a user's per-channel RBAC scope to a frozenset, or ``None`` for all channels.
155
+
156
+ Administrators are always all-channels. A NULL ``channel_scope`` is all; a JSON list is exactly
157
+ those connections; anything malformed is treated as **no** channels (deny-by-default)."""
158
+ if Role.ADMINISTRATOR in roles or user.channel_scope is None:
159
+ return None
160
+ try:
161
+ names = json.loads(user.channel_scope)
162
+ except (ValueError, TypeError):
163
+ return frozenset()
164
+ if not isinstance(names, list):
165
+ return frozenset()
166
+ return frozenset(str(n) for n in names)
167
+
168
+
169
+ class AuthService:
170
+ """Authentication + RBAC orchestration over an :class:`AuthStore` and the configured directory."""
171
+
172
+ def __init__(
173
+ self,
174
+ store: AdminStore,
175
+ settings: AuthSettings,
176
+ *,
177
+ ldap: LdapAuthenticator | None = None,
178
+ security_notifier: SecurityNotifier | None = None,
179
+ ) -> None:
180
+ self._store = store
181
+ self._settings = settings
182
+ self.enabled = settings.enabled
183
+ # Out-of-band security-event push (ASVS 6.3.5/6.3.7), injected by the API lifespan. None = no
184
+ # email push (the audited /me/security-events feed still records everything). Best-effort.
185
+ self._security_notifier = security_notifier
186
+ self._policy = PasswordPolicy(
187
+ min_length=settings.password_min_length,
188
+ require_uppercase=settings.password_require_uppercase,
189
+ require_lowercase=settings.password_require_lowercase,
190
+ require_digit=settings.password_require_digit,
191
+ require_symbol=settings.password_require_symbol,
192
+ check_breached=settings.password_check_breached,
193
+ check_context=settings.password_check_context,
194
+ check_username=settings.password_check_username,
195
+ breach_corpus_file=settings.password_breach_corpus_file,
196
+ lockout_threshold=settings.lockout_threshold,
197
+ lockout_minutes=settings.lockout_minutes,
198
+ )
199
+ _warn_if_corpus_unreadable(settings.password_breach_corpus_file)
200
+ if ldap is not None:
201
+ self._ldap: LdapAuthenticator | None = ldap
202
+ elif settings.ad_enabled:
203
+ self._ldap = LdapAuthenticator(settings)
204
+ else:
205
+ self._ldap = None
206
+ # Instance-scoped (one event loop per AuthService) so it never crosses loops in tests.
207
+ self._argon2_sem = asyncio.Semaphore(_ARGON2_MAX_CONCURRENCY)
208
+ self._login_limiter: SlidingWindowRateLimiter | None = (
209
+ SlidingWindowRateLimiter(
210
+ per_key=settings.login_rate_limit_per_ip,
211
+ glob=settings.login_rate_limit_global,
212
+ window_seconds=settings.login_rate_limit_window_seconds,
213
+ )
214
+ if settings.login_rate_limit_enabled
215
+ else None
216
+ )
217
+ # Per-actor anti-automation throttle for the PHI-read endpoints (WP-8, ASVS 2.4.1).
218
+ self._phi_read_limiter: SlidingWindowRateLimiter | None = (
219
+ SlidingWindowRateLimiter(
220
+ per_key=settings.phi_read_rate_limit_per_actor,
221
+ glob=settings.phi_read_rate_limit_global,
222
+ window_seconds=settings.phi_read_rate_limit_window_seconds,
223
+ )
224
+ if settings.phi_read_rate_limit_enabled
225
+ else None
226
+ )
227
+ # Per-process dedup of the WP-L3-13 new-client-IP audit/notify side effects: token_hash → the
228
+ # last new client address already flagged for that session. Bounded (_NEW_IP_DEDUP_MAX).
229
+ self._new_ip_seen: dict[str, str] = {}
230
+
231
+ async def _argon2(self, fn: Callable[..., _T], *args: Any) -> _T:
232
+ """Run a (CPU-heavy) argon2 hash/verify off-thread under the concurrency cap."""
233
+ async with self._argon2_sem:
234
+ return await asyncio.to_thread(fn, *args)
235
+
236
+ def allow_login_attempt(self, client: str | None) -> bool:
237
+ """Rate-limit gate for the unauthenticated auth surface (AUTH-RATE). True = proceed."""
238
+ if self._login_limiter is None:
239
+ return True
240
+ return self._login_limiter.allow(client or "unknown")
241
+
242
+ def allow_phi_read(self, actor: str) -> bool:
243
+ """Per-actor anti-automation gate for the PHI-read endpoints (WP-8, ASVS 2.4.1). True =
244
+ proceed; False = throttle. Always True when the limiter is disabled."""
245
+ if self._phi_read_limiter is None:
246
+ return True
247
+ return self._phi_read_limiter.allow(actor)
248
+
249
+ @property
250
+ def policy(self) -> PasswordPolicy:
251
+ return self._policy
252
+
253
+ @property
254
+ def ad_enabled(self) -> bool:
255
+ return self._ldap is not None
256
+
257
+ @property
258
+ def kerberos_enabled(self) -> bool:
259
+ return self._settings.kerberos_enabled and self._ldap is not None
260
+
261
+ # --- lifecycle -----------------------------------------------------------
262
+
263
+ async def initialize(self) -> BootstrapAdmin | None:
264
+ """Seed the built-in roles and, on an empty store, create the bootstrap admin. Also retires an
265
+ unclaimed bootstrap that became superseded/expired while the service was down (WP-3)."""
266
+ await self._seed_roles()
267
+ created = await self._ensure_bootstrap_admin()
268
+ await self._retire_superseded_bootstrap()
269
+ return created
270
+
271
+ async def _seed_roles(self) -> None:
272
+ for role in Role:
273
+ label, description = ROLE_METADATA[role]
274
+ await self._store.upsert_role(
275
+ role_id=role.value, display_name=label, description=description, builtin=True
276
+ )
277
+
278
+ async def _ensure_bootstrap_admin(self) -> BootstrapAdmin | None:
279
+ if await self._store.count_users() > 0:
280
+ return None
281
+ password = self._generate_policy_password()
282
+ user_id = uuid4().hex
283
+ await self._store.create_user(
284
+ user_id=user_id,
285
+ username=BOOTSTRAP_USERNAME,
286
+ auth_provider=AuthProvider.LOCAL.value,
287
+ display_name="Bootstrap Administrator",
288
+ password_hash=await self._argon2(hash_password, password),
289
+ must_change_password=True,
290
+ )
291
+ await self._store.set_user_roles(
292
+ user_id, [Role.ADMINISTRATOR.value], assigned_by="bootstrap"
293
+ )
294
+ await self._audit("auth.bootstrap_admin_created", actor="bootstrap")
295
+ return BootstrapAdmin(username=BOOTSTRAP_USERNAME, password=password)
296
+
297
+ def _generate_policy_password(self) -> str:
298
+ """A random password that satisfies the active policy — so the printed bootstrap credential
299
+ is held to the same bar operators are. ``token_urlsafe(n)`` yields ~1.33·n chars (so length is
300
+ guaranteed ≥ ``min_length``); the loop covers the astronomically-unlikely breach/context hit
301
+ or an opt-in character-class requirement a given token happens to miss."""
302
+ length = max(16, self._policy.min_length)
303
+ for _ in range(16):
304
+ candidate = secrets.token_urlsafe(length)
305
+ if not self._policy.violations(candidate):
306
+ return candidate
307
+ return secrets.token_urlsafe(length) + "aA1!" # defensive: satisfies any class requirement
308
+
309
+ async def _other_enabled_admin_exists(self, exclude_id: str) -> bool:
310
+ """True iff some enabled administrator other than ``exclude_id`` exists."""
311
+ for user in await self._store.list_users():
312
+ if user.disabled or user.id == exclude_id:
313
+ continue
314
+ if Role.ADMINISTRATOR.value in await self._store.get_user_role_ids(user.id):
315
+ return True
316
+ return False
317
+
318
+ async def _retire_superseded_bootstrap(self, now: float | None = None) -> None:
319
+ """Disable the first-run bootstrap admin once it's no longer needed (WP-3): when a **second**
320
+ administrator exists, or — while still **unclaimed** (never password-changed) — once its expiry
321
+ window lapses. Only ever touches an unclaimed bootstrap (``must_change_password`` still set): if
322
+ the operator changed its password it is a normal admin account and is left alone, so this can't
323
+ lock out a legitimate single-admin deployment."""
324
+ now = time.time() if now is None else now
325
+ boot = await self._store.get_user_by_username(BOOTSTRAP_USERNAME)
326
+ if boot is None or boot.disabled or not boot.must_change_password:
327
+ return # gone, already disabled, or claimed (a real account now)
328
+ expiry_hours = self._settings.bootstrap_expiry_hours
329
+ expired = expiry_hours > 0 and now >= boot.created_at + expiry_hours * 3600
330
+ superseded = await self._other_enabled_admin_exists(boot.id)
331
+ if not (expired or superseded):
332
+ return
333
+ await self._store.set_user_disabled(boot.id, disabled=True)
334
+ await self._store.revoke_user_sessions(boot.id)
335
+ await self._audit(
336
+ "auth.bootstrap_admin_retired",
337
+ actor="system",
338
+ detail=_json({"reason": "superseded" if superseded else "expired"}),
339
+ )
340
+
341
+ # --- login ---------------------------------------------------------------
342
+
343
+ async def login(
344
+ self,
345
+ username: str,
346
+ password: str,
347
+ *,
348
+ provider: AuthProvider = AuthProvider.LOCAL,
349
+ client: str | None = None,
350
+ ) -> LoginOutcome:
351
+ if provider is AuthProvider.AD:
352
+ return await self._login_ad(username, password, client=client)
353
+ return await self._login_local(username, password, client=client)
354
+
355
+ async def _login_local(
356
+ self, username: str, password: str, *, client: str | None
357
+ ) -> LoginOutcome:
358
+ # Enforce bootstrap expiry/supersession before the credential check: an unclaimed bootstrap
359
+ # that lapsed (or was superseded) is disabled here, so the disabled-account path below refuses
360
+ # it like any other invalid login (WP-3). Scoped to the bootstrap username to keep normal
361
+ # logins free of the extra lookups.
362
+ if username == BOOTSTRAP_USERNAME:
363
+ await self._retire_superseded_bootstrap()
364
+ user = await self._store.get_user_by_username(username)
365
+ if user is None or user.auth_provider != AuthProvider.LOCAL.value or user.disabled:
366
+ # Equalize timing with the real-password path so a missing/disabled/AD account is not
367
+ # distinguishable from a wrong password (defeats username enumeration via latency).
368
+ await self._argon2(verify_password, _DUMMY_PASSWORD_HASH, password)
369
+ await self._audit(
370
+ "auth.login_failed",
371
+ actor=username,
372
+ detail=_json({"provider": "local", "reason": "unknown_or_disabled"}),
373
+ )
374
+ return LoginOutcome(ok=False, error="invalid credentials")
375
+ now = time.time()
376
+ if user.locked_until is not None and now < user.locked_until:
377
+ await self._argon2(verify_password, _DUMMY_PASSWORD_HASH, password)
378
+ await self._audit("auth.login_locked", actor=username)
379
+ return LoginOutcome(ok=False, error="account locked")
380
+ if user.password_hash is None or not await self._argon2(
381
+ verify_password, user.password_hash, password
382
+ ):
383
+ attempts, just_locked = await self._register_failure(user, now)
384
+ await self._audit(
385
+ "auth.login_failed",
386
+ actor=username,
387
+ detail=_json({"provider": "local", "reason": "bad_password"}),
388
+ )
389
+ if just_locked:
390
+ await self._notify_security(
391
+ ACCOUNT_LOCKED,
392
+ username=user.username,
393
+ email=user.email,
394
+ client=client,
395
+ detail={"failed_attempts": attempts},
396
+ )
397
+ return LoginOutcome(ok=False, error="invalid credentials")
398
+ if await asyncio.to_thread(needs_rehash, user.password_hash):
399
+ await self._store.set_password(
400
+ user.id,
401
+ password_hash=await self._argon2(hash_password, password),
402
+ must_change_password=user.must_change_password,
403
+ )
404
+ prior_failures = user.failed_attempts # captured before record_login_success resets it
405
+ await self._store.record_login_success(user.id, now=now)
406
+ identity = await self._build_identity(user)
407
+ # A second factor (TOTP / recovery code) is pending for an enrolled user — or an Administrator
408
+ # when require_mfa is on. Issue the session un-MFA'd; the client completes via /auth/mfa-verify.
409
+ mfa_required = self._mfa_required_for(user, identity.roles)
410
+ token = await self._issue_session(user.id, client, mfa_verified=not mfa_required)
411
+ await self._audit(
412
+ "auth.login_success",
413
+ actor=user.username,
414
+ detail=_json({"provider": "local", "mfa_required": mfa_required}),
415
+ )
416
+ if prior_failures >= SUSPICIOUS_LOGIN_FAILURE_THRESHOLD:
417
+ # A successful login right after a run of failures is the classic compromised/attacked
418
+ # signal (ASVS 6.3.5) — notify the owner out-of-band so they can react if it wasn't them.
419
+ await self._notify_security(
420
+ LOGIN_AFTER_FAILURES,
421
+ username=user.username,
422
+ email=user.email,
423
+ client=client,
424
+ detail={"failed_attempts": prior_failures},
425
+ )
426
+ return LoginOutcome(
427
+ ok=True,
428
+ token=token,
429
+ identity=identity,
430
+ must_change_password=user.must_change_password,
431
+ mfa_required=mfa_required,
432
+ )
433
+
434
+ async def _register_failure(self, user: UserRecord, now: float) -> tuple[int, bool]:
435
+ """Record a failed attempt; return ``(attempts, just_locked)``. ``just_locked`` is True only on
436
+ the attempt that crosses the threshold (the caller reaches here only when not already locked),
437
+ so it fires exactly one lockout notification per lockout."""
438
+ # A lapsed lockout window restarts the counter, so one post-lockout failure cannot re-lock
439
+ # immediately (and the stale lock is cleared whenever the count is back below threshold).
440
+ prior = (
441
+ 0
442
+ if (user.locked_until is not None and now >= user.locked_until)
443
+ else user.failed_attempts
444
+ )
445
+ attempts = prior + 1
446
+ locked_until = (
447
+ now + self._policy.lockout_minutes * 60
448
+ if attempts >= self._policy.lockout_threshold
449
+ else None
450
+ )
451
+ await self._store.record_login_failure(
452
+ user.id, failed_attempts=attempts, locked_until=locked_until, now=now
453
+ )
454
+ return attempts, locked_until is not None
455
+
456
+ async def _login_ad(self, username: str, password: str, *, client: str | None) -> LoginOutcome:
457
+ if self._ldap is None:
458
+ return LoginOutcome(ok=False, error="AD authentication is not configured")
459
+ try:
460
+ principal = await asyncio.to_thread(self._ldap.authenticate, username, password)
461
+ except LdapError as exc:
462
+ await self._audit(
463
+ "auth.login_error",
464
+ actor=username,
465
+ detail=_json({"provider": "ad", "error": str(exc)}),
466
+ )
467
+ return LoginOutcome(ok=False, error="directory unavailable")
468
+ if principal is None:
469
+ await self._audit("auth.login_failed", actor=username, detail=_json({"provider": "ad"}))
470
+ return LoginOutcome(ok=False, error="invalid credentials")
471
+ return await self._complete_ad_login(principal, client)
472
+
473
+ async def authenticate_kerberos(
474
+ self, token: bytes, *, client: str | None = None
475
+ ) -> LoginOutcome:
476
+ # Audit every reject path so blocked/failed Windows-SSO attempts are not invisible to a
477
+ # defender (AUTH-K-AUDIT). A sentinel actor is used until the principal is known.
478
+ if self._ldap is None or not self._settings.kerberos_enabled:
479
+ await self._kerberos_reject_audit("<kerberos>", "not_configured")
480
+ return LoginOutcome(ok=False, error="Windows SSO is not configured")
481
+ try:
482
+ username = await asyncio.to_thread(kerberos_principal, token, self._settings)
483
+ if username is None:
484
+ await self._kerberos_reject_audit("<kerberos>", "no_principal")
485
+ return LoginOutcome(ok=False, error="SSO authentication failed")
486
+ principal = await asyncio.to_thread(self._ldap.resolve_principal, username)
487
+ except LdapError as exc:
488
+ await self._audit(
489
+ "auth.login_error",
490
+ actor="<kerberos>",
491
+ detail=_json({"provider": "ad", "mech": "kerberos", "error": str(exc)}),
492
+ )
493
+ return LoginOutcome(ok=False, error="directory unavailable")
494
+ if principal is None:
495
+ await self._kerberos_reject_audit(username, "not_in_directory")
496
+ return LoginOutcome(ok=False, error="user not found in directory")
497
+ return await self._complete_ad_login(principal, client)
498
+
499
+ async def _kerberos_reject_audit(self, actor: str, reason: str) -> None:
500
+ await self._audit(
501
+ "auth.login_failed",
502
+ actor=actor,
503
+ detail=_json({"provider": "ad", "mech": "kerberos", "reason": reason}),
504
+ )
505
+
506
+ async def _complete_ad_login(self, principal: AdPrincipal, client: str | None) -> LoginOutcome:
507
+ existing = await self._store.get_user_by_username(principal.username)
508
+ if existing is not None and existing.auth_provider != AuthProvider.AD.value:
509
+ # Never let an AD login adopt/overwrite a like-named LOCAL account (provider confusion).
510
+ await self._audit(
511
+ "auth.login_failed",
512
+ actor=principal.username,
513
+ detail=_json({"provider": "ad", "reason": "local_account_conflict"}),
514
+ )
515
+ return LoginOutcome(ok=False, error="account conflict")
516
+ user = await self._upsert_ad_user(principal)
517
+ role_ids = sorted(await self._store.roles_for_ad_groups(principal.groups))
518
+ previous = set(await self._store.get_user_role_ids(user.id))
519
+ await self._store.set_user_roles(user.id, role_ids, assigned_by="ad-sync")
520
+ if set(role_ids) != previous:
521
+ # Directory-side role change (often a downgrade): revoke the user's other live sessions
522
+ # so stale elevated tokens don't linger until expiry (AUTH-AD-REVOKE). The new session
523
+ # is issued below, after this, so the current login is unaffected.
524
+ await self._store.revoke_user_sessions(user.id)
525
+ await self._audit(
526
+ "auth.ad_roles_resynced",
527
+ actor=user.username,
528
+ detail=_json({"from": sorted(previous), "to": role_ids}),
529
+ )
530
+ # A directory-pushed privilege change is the same privilege change to the same user as a
531
+ # local one, so notify the affected user out-of-band too (ASVS 6.3.7), matching set_roles().
532
+ # Best-effort; the change is also visible in the audited /me/security-events feed.
533
+ await self._notify_security(
534
+ ROLES_CHANGED,
535
+ username=user.username,
536
+ email=user.email,
537
+ client=client,
538
+ detail={"roles": role_ids},
539
+ )
540
+ await self._store.record_login_success(user.id)
541
+ ad_roles = _roles_from_ids(role_ids)
542
+ user = await self._sync_ad_channel_scope(user, ad_roles, principal.groups)
543
+ identity = Identity.build(
544
+ user_id=user.id,
545
+ username=user.username,
546
+ auth_provider=AuthProvider.AD,
547
+ roles=ad_roles,
548
+ allowed_channels=_allowed_channels(user, ad_roles),
549
+ )
550
+ # AD/Kerberos MFA is delegated to the directory (Entra Conditional Access / MFA proxy), so the
551
+ # session is MFA-satisfied at issuance — an engine TOTP is never prompted for a directory login.
552
+ token = await self._issue_session(user.id, client, mfa_verified=True)
553
+ await self._audit(
554
+ "auth.login_success",
555
+ actor=user.username,
556
+ detail=_json({"provider": "ad", "roles": role_ids}),
557
+ )
558
+ return LoginOutcome(ok=True, token=token, identity=identity)
559
+
560
+ async def _sync_ad_channel_scope(
561
+ self, user: UserRecord, roles: frozenset[Role], groups: Iterable[str]
562
+ ) -> UserRecord:
563
+ """Persist a user's AD-group-derived per-channel scope (C3) so it's durable for later
564
+ requests (mirrors role sync). Administrators are always all-channels. If no group mapping
565
+ matches, the per-user scope is left untouched — opt-in, so it never clobbers a manual scope
566
+ or the all-channels default. Returns the (possibly refreshed) user record."""
567
+ if Role.ADMINISTRATOR in roles:
568
+ return user
569
+ channels = await self._store.channels_for_ad_groups(groups)
570
+ if not channels:
571
+ return user
572
+ specific = sorted(c for c in channels if c != "*")
573
+ scope_json = None if "*" in channels else _json(specific)
574
+ if user.channel_scope == scope_json:
575
+ return user
576
+ await self._store.set_user_channel_scope(user.id, scope_json)
577
+ await self._store.revoke_user_sessions(
578
+ user.id
579
+ ) # drop stale-scope tokens (new one issued after)
580
+ await self._audit(
581
+ "auth.ad_scope_resynced",
582
+ actor=user.username,
583
+ detail=_json({"channels": "*" if scope_json is None else specific}),
584
+ )
585
+ return await self._store.get_user(user.id) or user
586
+
587
+ async def _upsert_ad_user(self, principal: AdPrincipal) -> UserRecord:
588
+ existing = await self._store.get_user_by_username(principal.username)
589
+ if existing is None:
590
+ user_id = uuid4().hex
591
+ await self._store.create_user(
592
+ user_id=user_id,
593
+ username=principal.username,
594
+ auth_provider=AuthProvider.AD.value,
595
+ display_name=principal.display_name,
596
+ email=principal.email,
597
+ )
598
+ else:
599
+ user_id = existing.id
600
+ await self._store.update_user_profile(
601
+ user_id, display_name=principal.display_name, email=principal.email
602
+ )
603
+ user = await self._store.get_user(user_id)
604
+ assert user is not None # just upserted
605
+ return user
606
+
607
+ # --- sessions ------------------------------------------------------------
608
+
609
+ async def _issue_session(self, user_id: str, client: str | None, *, mfa_verified: bool) -> str:
610
+ token = mint_token()
611
+ token_hash = hash_token(token)
612
+ expires_at = time.time() + self._settings.session_absolute_hours * 3600
613
+ await self._store.create_session(
614
+ token_hash=token_hash,
615
+ user_id=user_id,
616
+ expires_at=expires_at,
617
+ client=client,
618
+ # Seed the step-up window from login ONLY for a fully-authenticated session. An MFA-pending
619
+ # session gets no step-up freshness, so enrolling a first authenticator (or any step-up op)
620
+ # requires an explicit password re-verify — a stolen pre-MFA token can't ride login's
621
+ # freshness to bind an attacker-controlled authenticator (WP-14).
622
+ seed_reauth=mfa_verified,
623
+ )
624
+ if mfa_verified:
625
+ # No second factor pending (MFA not required for this user, or delegated to AD/Kerberos):
626
+ # mark the session's 2nd factor satisfied at issuance so the step-up gate never blocks it.
627
+ # An MFA-required local login leaves it NULL until POST /auth/mfa-verify (WP-14).
628
+ await self._store.mark_session_mfa_verified(token_hash)
629
+ cap = self._settings.max_sessions_per_user
630
+ if cap and cap > 0:
631
+ # Evict the oldest sessions beyond the cap (the just-created one is newest, so survives).
632
+ await self._store.enforce_session_cap(user_id, keep=cap)
633
+ return token
634
+
635
+ async def identity_for_token(
636
+ self, token: str | None, *, activity: bool = True
637
+ ) -> Identity | None:
638
+ """Validate a bearer token (existence, revocation, clock, absolute + idle timeout) and
639
+ resolve the caller's :class:`Identity`.
640
+
641
+ ``activity=True`` (the default, for user-driven requests) refreshes the session's idle
642
+ clock; pass ``activity=False`` for background re-checks (e.g. a long-lived WebSocket) so a
643
+ passively-polled token still ages out against real user activity (AUTH-IDLE).
644
+ """
645
+ if not token:
646
+ return None
647
+ session = await self._store.get_session(hash_token(token))
648
+ if session is None or session.revoked_at is not None:
649
+ return None
650
+ now = time.time()
651
+ # Fail closed on a backward wall-clock step (NTP step-back, VM snapshot revert): a session
652
+ # stamped in the "future" can't be aged correctly, so revoke rather than silently revive an
653
+ # already-expired one or reset its idle window (AUTH-CLOCK).
654
+ if now < session.created_at or now < session.last_used_at:
655
+ await self._store.revoke_session(session.token_hash, now=now)
656
+ return None
657
+ if now > session.expires_at:
658
+ await self._store.revoke_session(session.token_hash, now=now)
659
+ return None
660
+ if now - session.last_used_at > self._settings.session_idle_timeout_minutes * 60:
661
+ await self._store.revoke_session(session.token_hash, now=now)
662
+ return None
663
+ if activity:
664
+ await self._store.touch_session(session.token_hash, now=now)
665
+ user = await self._store.get_user(session.user_id)
666
+ if user is None or user.disabled:
667
+ return None
668
+ return await self._build_identity(user)
669
+
670
+ async def logout(self, token: str | None, *, actor: str | None = None) -> None:
671
+ if token:
672
+ await self._store.revoke_session(hash_token(token))
673
+ # Emit the documented auth.logout event (SECURITY.md, ASVS 16.3.3) — previously the
674
+ # session was revoked silently, contradicting the doc and leaving a gap in the trail.
675
+ await self._audit("auth.logout", actor=actor)
676
+
677
+ # --- session inventory + targeted revoke (WP-10, ASVS 7.5.2/7.4.5) -------
678
+
679
+ async def list_sessions(self, user_id: str) -> list[SessionRecord]:
680
+ """A user's active sessions — the self-service session inventory."""
681
+ return await self._store.list_sessions(user_id)
682
+
683
+ async def revoke_own_session(self, identity: Identity, session_id: str, *, actor: str) -> bool:
684
+ """Revoke one of ``identity``'s **own** sessions by id (its ``token_hash``). Returns ``False``
685
+ if the session doesn't exist or isn't the caller's — so the API answers 404 without revealing
686
+ or letting a user touch another's session. Audited."""
687
+ session = await self._store.get_session(session_id)
688
+ if session is None or session.user_id != identity.user_id:
689
+ return False
690
+ await self._store.revoke_session(session_id)
691
+ await self._audit(
692
+ "auth.session_revoked",
693
+ actor=actor,
694
+ detail=_json({"scope": "self", "session": session_id[:12]}),
695
+ )
696
+ return True
697
+
698
+ async def revoke_other_sessions(
699
+ self, identity: Identity, current_token_hash: str, *, actor: str
700
+ ) -> int:
701
+ """Revoke all of ``identity``'s sessions **except** the caller's current one ("sign out
702
+ everywhere else"). Returns the count revoked. Audited when any were revoked."""
703
+ revoked = await self._store.revoke_user_sessions(
704
+ identity.user_id, except_token_hash=current_token_hash
705
+ )
706
+ if revoked:
707
+ await self._audit(
708
+ "auth.session_revoked",
709
+ actor=actor,
710
+ detail=_json({"scope": "self_others", "count": revoked}),
711
+ )
712
+ return revoked
713
+
714
+ async def revoke_sessions_for_user(self, user_id: str, *, actor: str) -> int:
715
+ """Admin: revoke **all** of a user's sessions (force sign-out everywhere). Returns the count.
716
+ Audited."""
717
+ revoked = await self._store.revoke_user_sessions(user_id)
718
+ await self._audit(
719
+ "auth.session_revoked",
720
+ actor=actor,
721
+ detail=_json({"scope": "admin", "user_id": user_id, "count": revoked}),
722
+ )
723
+ return revoked
724
+
725
+ async def _build_identity(self, user: UserRecord) -> Identity:
726
+ role_ids = await self._store.get_user_role_ids(user.id)
727
+ roles = _roles_from_ids(role_ids)
728
+ provider = (
729
+ AuthProvider(user.auth_provider)
730
+ if user.auth_provider in (AuthProvider.LOCAL.value, AuthProvider.AD.value)
731
+ else AuthProvider.LOCAL
732
+ )
733
+ return Identity.build(
734
+ user_id=user.id,
735
+ username=user.username,
736
+ auth_provider=provider,
737
+ roles=roles,
738
+ must_change_password=user.must_change_password,
739
+ allowed_channels=_allowed_channels(user, roles),
740
+ )
741
+
742
+ # --- password management -------------------------------------------------
743
+
744
+ def password_violations(self, password: str, *, username: str | None = None) -> list[str]:
745
+ return self._policy.violations(password, username=username)
746
+
747
+ async def verify_current_password(self, identity: Identity, password: str) -> bool:
748
+ """True iff ``password`` matches the local user's stored hash (self-service reauth)."""
749
+ user = await self._store.get_user(identity.user_id)
750
+ if user is None or user.password_hash is None:
751
+ return False
752
+ return await self._argon2(verify_password, user.password_hash, password)
753
+
754
+ async def reauth(
755
+ self, identity: Identity, password: str, *, token: str, client: str | None = None
756
+ ) -> bool:
757
+ """Step-up re-verification (ASVS 7.5.3): re-prove the caller's credential and, on success,
758
+ refresh the current session's ``reauth_at`` so it may perform highly sensitive operations for
759
+ the configured window. Local accounts re-verify the password (argon2); **AD accounts do a live
760
+ re-bind** against the directory so AD operators aren't locked out. Always audited."""
761
+ if identity.auth_provider is AuthProvider.AD:
762
+ ok = await self._reauth_ad(identity.username, password)
763
+ else:
764
+ ok = await self.verify_current_password(identity, password)
765
+ if ok:
766
+ # Re-anchor the session to the address it re-verified from, so a forced step-up triggered
767
+ # by a roamed/new client IP (WP-L3-13) clears once the caller re-proves from there.
768
+ await self._store.mark_session_reauthed(hash_token(token), client=client)
769
+ await self._audit(
770
+ "auth.reauth",
771
+ actor=identity.username,
772
+ detail=_json({"ok": ok, "provider": identity.auth_provider.value}),
773
+ )
774
+ return ok
775
+
776
+ async def _reauth_ad(self, username: str, password: str) -> bool:
777
+ """Re-verify an AD credential via a live directory re-bind (no session adopted)."""
778
+ if self._ldap is None:
779
+ return False
780
+ try:
781
+ principal = await asyncio.to_thread(self._ldap.authenticate, username, password)
782
+ except LdapError:
783
+ return False
784
+ return principal is not None
785
+
786
+ async def has_recent_step_up(self, token: str | None) -> bool:
787
+ """Whether the caller's session re-verified its credential within
788
+ ``[auth].step_up_max_age_seconds`` (login is the first verification) — the gate for sensitive
789
+ operations (ASVS 7.5.3)."""
790
+ if not token:
791
+ return False
792
+ session = await self._store.get_session(hash_token(token))
793
+ if session is None or session.reauth_at is None:
794
+ return False
795
+ return (time.time() - session.reauth_at) <= self._settings.step_up_max_age_seconds
796
+
797
+ @staticmethod
798
+ def _same_host(a: str, b: str) -> bool:
799
+ """Whether two client addresses denote the same host: an exact match, **or** both loopback (so a
800
+ dual-stack box that presents ``::1`` on one connection and ``127.0.0.1`` on another is treated as
801
+ one host — this keeps the loopback default a genuine no-op rather than a string mismatch).
802
+ Unparseable values fall back to exact match."""
803
+ if a == b:
804
+ return True
805
+ try:
806
+ return ipaddress.ip_address(a).is_loopback and ipaddress.ip_address(b).is_loopback
807
+ except ValueError:
808
+ return False
809
+
810
+ def _remember_new_ip(self, token_hash: str, client_ip: str) -> None:
811
+ """Record the last new client IP flagged for a session — best-effort, per-process dedup of the
812
+ audit/notify side effects only. Bounded so session/address churn can't grow it without limit;
813
+ the step-up decision never depends on this cache (eviction only risks one extra audit row)."""
814
+ if len(self._new_ip_seen) >= _NEW_IP_DEDUP_MAX and token_hash not in self._new_ip_seen:
815
+ self._new_ip_seen.pop(next(iter(self._new_ip_seen)))
816
+ self._new_ip_seen[token_hash] = client_ip
817
+
818
+ async def flag_new_client_ip(
819
+ self, token: str | None, client_ip: str | None, *, path: str
820
+ ) -> bool:
821
+ """Admin-interface contextual-risk signal (ASVS 8.4.2, WP-L3-13): return ``True`` when this
822
+ sensitive request arrives from a client address that differs from the one the caller's session
823
+ last verified from. On the **first** observation of a given (session, address) it emits an
824
+ ``auth.admin_action_new_ip`` audit event + a best-effort out-of-band notice; **repeat** hits from
825
+ the same un-cleared address still return ``True`` (so the step-up stays forced) but only log to
826
+ the rotating ops log — so a token replayed in a tight loop from one address cannot inflate the
827
+ audit table / notification channel (mirrors the ``_rate_limited`` precedent). The step-up
828
+ dependencies treat ``True`` as "force a fresh step-up"; a successful re-verify (``POST
829
+ /me/reauth`` **or** ``/auth/mfa-verify``) re-anchors the session to the new address (see
830
+ :meth:`reauth` / :meth:`verify_mfa`), so the signal clears and the caller proceeds. It is
831
+ **advisory + step-up-forcing only** — it never changes an authorization decision and never
832
+ blocks the non-admin request path.
833
+
834
+ Disabled (returns ``False`` with no side effects) unless ``[auth].admin_new_ip_step_up`` is on,
835
+ so loopback behavior is byte-identical by default; and even on, a single-host loopback session
836
+ never trips it because the request and the session resolve to the same loopback host (IPv4 or
837
+ IPv6 — see :meth:`_same_host`)."""
838
+ if not self._settings.admin_new_ip_step_up or not token:
839
+ return False
840
+ token_hash = hash_token(token)
841
+ session = await self._store.get_session(token_hash)
842
+ if session is None or session.revoked_at is not None:
843
+ return False
844
+ # No baseline address (older session / unknown login source) or the same host → not new. A
845
+ # session with no recorded address is not penalized, to avoid spurious admin friction.
846
+ if not session.client or not client_ip or self._same_host(client_ip, session.client):
847
+ return False
848
+ # New address → force a step-up (return True unconditionally). Emit the audit + notice once per
849
+ # (session, address); suppress repeats from the same un-cleared address so a replayed token
850
+ # cannot amplify the audit log / notifications.
851
+ if self._new_ip_seen.get(token_hash) == client_ip:
852
+ _log.warning(
853
+ "admin action from already-flagged new client IP (repeat suppressed): path=%s", path
854
+ )
855
+ return True
856
+ self._remember_new_ip(token_hash, client_ip)
857
+ user = await self._store.get_user(session.user_id)
858
+ username = user.username if user is not None else session.user_id
859
+ await self._audit(
860
+ "auth.admin_action_new_ip",
861
+ actor=username,
862
+ detail=_json({"path": path, "known_ip": session.client, "seen_ip": client_ip}),
863
+ )
864
+ await self._notify_security(
865
+ ADMIN_NEW_IP,
866
+ username=username,
867
+ email=user.email if user is not None else None,
868
+ client=client_ip,
869
+ detail={"known_ip": session.client},
870
+ )
871
+ return True
872
+
873
+ async def change_password(
874
+ self,
875
+ identity: Identity,
876
+ new_password: str,
877
+ *,
878
+ must_change: bool = False,
879
+ client: str | None = None,
880
+ ) -> list[str]:
881
+ """Set a local user's password (after policy check) and revoke their other sessions.
882
+
883
+ Returns policy violations (empty list = changed). No-op-safe for AD identities at the API
884
+ layer, which rejects password changes for AD users before calling this.
885
+ """
886
+ violations = self._policy.violations(new_password, username=identity.username)
887
+ if violations:
888
+ return violations
889
+ await self._store.set_password(
890
+ identity.user_id,
891
+ password_hash=await self._argon2(hash_password, new_password),
892
+ must_change_password=must_change,
893
+ )
894
+ await self._store.revoke_user_sessions(identity.user_id)
895
+ await self._audit("auth.password_changed", actor=identity.username)
896
+ user = await self._store.get_user(identity.user_id)
897
+ await self._notify_security(
898
+ PASSWORD_CHANGED,
899
+ username=identity.username,
900
+ email=user.email if user is not None else None,
901
+ client=client,
902
+ )
903
+ return []
904
+
905
+ # --- MFA: native TOTP second factor (local accounts, WP-14, ASVS 6.3.3) --
906
+
907
+ def _mfa_required_for(self, user: UserRecord, roles: frozenset[Role]) -> bool:
908
+ """Whether ``user`` must satisfy a second factor. **Local accounts only** — AD/Kerberos MFA is
909
+ delegated to the directory. An enrolled user always must; an un-enrolled user must when
910
+ ``[auth].require_mfa`` is on and they hold the Administrator role (the chosen enforcement
911
+ target — regular users opt in by enrolling)."""
912
+ if user.auth_provider != AuthProvider.LOCAL.value:
913
+ return False
914
+ if user.totp_enabled:
915
+ return True
916
+ return self._settings.require_mfa and Role.ADMINISTRATOR in roles
917
+
918
+ async def mfa_satisfied(self, token: str | None) -> bool:
919
+ """Whether the caller's session has met its second-factor requirement — True when the session
920
+ is MFA-verified, **or** when MFA isn't required for this user. Composed with
921
+ :meth:`has_recent_step_up` by the API to gate sensitive operations (WP-14). A required-but-
922
+ unverified session returns False, so the step-up routes 403 until ``POST /auth/mfa-verify``."""
923
+ if not token:
924
+ return False
925
+ session = await self._store.get_session(hash_token(token))
926
+ if session is None or session.revoked_at is not None:
927
+ return False
928
+ if session.mfa_verified_at is not None:
929
+ return True
930
+ user = await self._store.get_user(session.user_id)
931
+ if user is None:
932
+ return False
933
+ roles = _roles_from_ids(await self._store.get_user_role_ids(user.id))
934
+ return not self._mfa_required_for(user, roles)
935
+
936
+ async def begin_mfa_enrollment(self, identity: Identity) -> MfaEnrollment:
937
+ """Stage a fresh TOTP secret for a local user and return it + the ``otpauth://`` URI for the
938
+ QR. Not active until proven via :meth:`confirm_mfa_enrollment`. Raises :class:`ValueError` for
939
+ an AD account or when MFA is already enabled (disable it first to re-enroll)."""
940
+ user = await self._store.get_user(identity.user_id)
941
+ if user is None or user.auth_provider != AuthProvider.LOCAL.value:
942
+ raise ValueError("only local users can enroll a TOTP authenticator")
943
+ if user.totp_enabled:
944
+ raise ValueError("MFA is already enabled; disable it before re-enrolling")
945
+ secret = totp.generate_secret()
946
+ await self._store.set_totp_secret(identity.user_id, secret=secret)
947
+ await self._audit("auth.mfa_enroll_started", actor=identity.username)
948
+ return MfaEnrollment(secret=secret, otpauth_uri=totp.otpauth_uri(secret, identity.username))
949
+
950
+ async def confirm_mfa_enrollment(
951
+ self, identity: Identity, code: str, *, token: str, client: str | None = None
952
+ ) -> list[str] | None:
953
+ """Confirm a staged enrollment by proving a live TOTP code. On success: activate MFA, mint the
954
+ single-use recovery codes (returned **once**, plaintext, for the user to save), mark the
955
+ current session MFA-verified, audit + notify. Returns the recovery codes, or ``None`` when the
956
+ code was wrong. Raises :class:`ValueError` if no enrollment is staged / the user isn't local."""
957
+ user = await self._store.get_user(identity.user_id)
958
+ if user is None or user.auth_provider != AuthProvider.LOCAL.value:
959
+ raise ValueError("only local users can enroll a TOTP authenticator")
960
+ secret = await self._store.get_totp_secret(identity.user_id)
961
+ if not secret:
962
+ raise ValueError("no enrollment in progress")
963
+ if not totp.verify_totp(secret, code.strip()):
964
+ await self._audit(
965
+ "auth.mfa_failed", actor=identity.username, detail=_json({"phase": "enroll"})
966
+ )
967
+ return None
968
+ plain = totp.generate_recovery_codes(self._settings.mfa_recovery_code_count)
969
+ hashes = [await self._argon2(hash_password, c) for c in plain]
970
+ await self._store.enable_totp(identity.user_id, recovery_code_hashes=hashes)
971
+ await self._store.mark_session_mfa_verified(hash_token(token))
972
+ await self._audit("auth.mfa_enrolled", actor=identity.username)
973
+ await self._notify_security(
974
+ MFA_ENABLED, username=user.username, email=user.email, client=client
975
+ )
976
+ return plain
977
+
978
+ async def verify_mfa(self, token: str | None, code: str, *, client: str | None = None) -> bool:
979
+ """Validate a TOTP code (or a single-use recovery code) for the caller's session and, on
980
+ success, mark the session's second factor satisfied. Always audited; the API gates this behind
981
+ the login rate limiter. Returns False (never raises) for any invalid input."""
982
+ if not token:
983
+ return False
984
+ session = await self._store.get_session(hash_token(token))
985
+ if session is None or session.revoked_at is not None:
986
+ return False
987
+ user = await self._store.get_user(session.user_id)
988
+ if user is None or user.disabled or not user.totp_enabled:
989
+ return False
990
+ now = time.time()
991
+ # Per-account lockout covers the SECOND factor too (parity with the password path): a run of
992
+ # wrong codes locks the account, so MFA guessing isn't bounded only by the shared per-IP login
993
+ # limiter (which IP-rotation can sidestep). A locked account is refused before any verify.
994
+ if user.locked_until is not None and now < user.locked_until:
995
+ await self._audit(
996
+ "auth.mfa_failed", actor=user.username, detail=_json({"reason": "locked"})
997
+ )
998
+ return False
999
+ if await self._verify_second_factor(user, code):
1000
+ # The 2nd factor is now satisfied; also seed the step-up window (the session has completed
1001
+ # password + MFA) and clear the failure counter. (Initial enrollment has no factor to verify,
1002
+ # so this never fires there — keeping the enrollment step-up gate honest, WP-14.)
1003
+ await self._store.mark_session_mfa_verified(hash_token(token))
1004
+ # Re-anchor the session to the address that completed the second factor (parity with
1005
+ # reauth), so an MFA-required admin who roamed clears the WP-L3-13 new-client-IP signal with
1006
+ # one credential proof rather than being forced into a separate password step-up.
1007
+ await self._store.mark_session_reauthed(hash_token(token), client=client)
1008
+ await self._store.record_login_success(user.id, now=now)
1009
+ await self._audit("auth.mfa_verified", actor=user.username)
1010
+ return True
1011
+ # Wrong code: register the failure through the SAME machinery the password path uses, so the
1012
+ # per-account lockout + ACCOUNT_LOCKED notification fire on sustained MFA guessing.
1013
+ attempts, just_locked = await self._register_failure(user, now)
1014
+ await self._audit("auth.mfa_failed", actor=user.username)
1015
+ if just_locked:
1016
+ await self._notify_security(
1017
+ ACCOUNT_LOCKED,
1018
+ username=user.username,
1019
+ email=user.email,
1020
+ client=client,
1021
+ detail={"failed_attempts": attempts},
1022
+ )
1023
+ return False
1024
+
1025
+ async def _verify_second_factor(self, user: UserRecord, code: str) -> bool:
1026
+ """True iff ``code`` is the user's current TOTP **or** an unused recovery code (consumed on
1027
+ match). TOTP is checked first (fast, no argon2); recovery codes are argon2id-hashed and
1028
+ single-use. Codes never collide (TOTP is 6 digits; recovery codes are dashed alphanumerics)."""
1029
+ code = code.strip()
1030
+ if not code:
1031
+ return False
1032
+ secret = await self._store.get_totp_secret(user.id)
1033
+ if secret:
1034
+ matched_step = totp.verify_totp_step(secret, code)
1035
+ if matched_step is not None:
1036
+ # Single-use within the step window (ASVS 6.5.1): the store advances the user's
1037
+ # highest-consumed time-step atomically, so a code captured and replayed inside its
1038
+ # ~30 s verify window resolves to a non-greater step and is rejected. Mirrors the
1039
+ # recovery-code compare-and-set; a genuine code always advances the step as time
1040
+ # moves forward, so a legitimate later login is unaffected.
1041
+ return await self._store.consume_totp_step(user.id, matched_step)
1042
+ normalized = code.upper() # recovery codes are minted uppercase
1043
+ hashes = await self._store.get_recovery_code_hashes(user.id)
1044
+ for h in hashes:
1045
+ if await self._argon2(verify_password, h, normalized):
1046
+ # Atomic compare-and-delete: only the caller that actually removes the hash wins, so a
1047
+ # concurrent verify of the same single-use code can't double-spend it (WP-14).
1048
+ return await self._store.consume_recovery_code_hash(user.id, h)
1049
+ return False
1050
+
1051
+ async def disable_mfa(self, identity: Identity, *, client: str | None = None) -> None:
1052
+ """Self-service: turn off the caller's TOTP MFA (the API gates this behind step-up). Audited +
1053
+ the user is notified out-of-band (ASVS 6.3.7)."""
1054
+ user = await self._store.get_user(identity.user_id)
1055
+ await self._store.disable_totp(identity.user_id)
1056
+ await self._audit(
1057
+ "auth.mfa_disabled", actor=identity.username, detail=_json({"scope": "self"})
1058
+ )
1059
+ await self._notify_security(
1060
+ MFA_DISABLED,
1061
+ username=identity.username,
1062
+ email=user.email if user is not None else None,
1063
+ client=client,
1064
+ )
1065
+
1066
+ async def admin_reset_mfa(self, user_id: str, *, actor: str) -> None:
1067
+ """Admin: clear a user's TOTP MFA (lost authenticator + no recovery codes) and revoke their
1068
+ sessions so they re-enroll. Raises :class:`ValueError` for an unknown or non-local user."""
1069
+ user = await self._store.get_user(user_id)
1070
+ if user is None:
1071
+ raise ValueError("no such user")
1072
+ if user.auth_provider != AuthProvider.LOCAL.value:
1073
+ raise ValueError("only local users have MFA to reset")
1074
+ await self._store.disable_totp(user_id)
1075
+ await self._store.revoke_user_sessions(user_id)
1076
+ await self._audit(
1077
+ "auth.mfa_reset",
1078
+ actor=actor,
1079
+ detail=_json({"user_id": user_id, "username": user.username}),
1080
+ )
1081
+ await self._notify_security(
1082
+ MFA_DISABLED, username=user.username, email=user.email, detail={"reset": True}
1083
+ )
1084
+
1085
+ async def mfa_status(self, identity: Identity) -> MfaStatus:
1086
+ """The caller's current MFA posture for ``GET /me/mfa``."""
1087
+ user = await self._store.get_user(identity.user_id)
1088
+ if user is None:
1089
+ return MfaStatus(
1090
+ enabled=False, enrolled_at=None, recovery_codes_remaining=0, required=False
1091
+ )
1092
+ remaining = (
1093
+ len(await self._store.get_recovery_code_hashes(identity.user_id))
1094
+ if user.totp_enabled
1095
+ else 0
1096
+ )
1097
+ return MfaStatus(
1098
+ enabled=user.totp_enabled,
1099
+ enrolled_at=user.totp_enrolled_at,
1100
+ recovery_codes_remaining=remaining,
1101
+ required=self._mfa_required_for(user, identity.roles),
1102
+ )
1103
+
1104
+ # --- administration (audited) -------------------------------------------
1105
+
1106
+ @property
1107
+ def store(self) -> AdminStore:
1108
+ """Read access to the backing store for admin list/read endpoints (users + audit)."""
1109
+ return self._store
1110
+
1111
+ async def security_events_for(self, username: str, *, limit: int = 100) -> list[dict[str, Any]]:
1112
+ """The caller's own security-event history (audited ``auth.*`` actions, most-recent-first) for
1113
+ ``GET /me/security-events`` — normalized to plain dicts so the API doesn't see backend Row
1114
+ types. PHI-free (the audit ``detail`` carries metadata only)."""
1115
+ rows = await self._store.security_events_for_user(username, limit=limit)
1116
+ return [
1117
+ {"ts": float(r["ts"]), "action": str(r["action"]), "detail": r["detail"]} for r in rows
1118
+ ]
1119
+
1120
+ async def create_local_user(
1121
+ self,
1122
+ *,
1123
+ username: str,
1124
+ password: str,
1125
+ display_name: str | None,
1126
+ email: str | None,
1127
+ roles: Sequence[str],
1128
+ actor: str,
1129
+ ) -> str:
1130
+ user_id = uuid4().hex
1131
+ await self._store.create_user(
1132
+ user_id=user_id,
1133
+ username=username,
1134
+ auth_provider=AuthProvider.LOCAL.value,
1135
+ display_name=display_name,
1136
+ email=email,
1137
+ password_hash=await self._argon2(hash_password, password),
1138
+ # Admin-set the credential is a one-time temp: force rotation on first login so the
1139
+ # operator never sets a lasting password the user keeps (ASVS 6.4.6 / WP-L3-12).
1140
+ must_change_password=True,
1141
+ )
1142
+ await self._store.set_user_roles(user_id, roles, assigned_by=actor)
1143
+ await self._audit(
1144
+ "user.created", actor=actor, detail=_json({"username": username, "roles": list(roles)})
1145
+ )
1146
+ # If this created a second administrator, retire the now-redundant bootstrap admin (WP-3).
1147
+ await self._retire_superseded_bootstrap()
1148
+ return user_id
1149
+
1150
+ async def update_user(
1151
+ self,
1152
+ user_id: str,
1153
+ *,
1154
+ display_name: str | None,
1155
+ email: str | None,
1156
+ disabled: bool | None,
1157
+ actor: str,
1158
+ ) -> None:
1159
+ before = await self._store.get_user(user_id) # capture old email/disabled for notifications
1160
+ await self._store.update_user_profile(user_id, display_name=display_name, email=email)
1161
+ if disabled is not None:
1162
+ await self._store.set_user_disabled(user_id, disabled=disabled)
1163
+ if disabled:
1164
+ await self._store.revoke_user_sessions(user_id)
1165
+ await self._audit("user.updated", actor=actor, detail=_json({"user_id": user_id}))
1166
+ if before is not None:
1167
+ if email is not None and email != before.email:
1168
+ # Notify the OLD address — so the legitimate owner is alerted even if an attacker (or a
1169
+ # mistaken admin) repointed the account's email to one they control (ASVS 6.3.7).
1170
+ await self._notify_security(
1171
+ EMAIL_CHANGED,
1172
+ username=before.username,
1173
+ email=before.email,
1174
+ detail={"new_email": email},
1175
+ )
1176
+ if disabled and not before.disabled:
1177
+ await self._notify_security(
1178
+ ACCOUNT_DISABLED, username=before.username, email=before.email
1179
+ )
1180
+
1181
+ async def delete_user(self, user_id: str, *, actor: str) -> None:
1182
+ await self._store.delete_user(user_id)
1183
+ await self._audit("user.deleted", actor=actor, detail=_json({"user_id": user_id}))
1184
+
1185
+ async def set_roles(self, user_id: str, roles: Sequence[str], *, actor: str) -> None:
1186
+ user = await self._store.get_user(user_id) # for the notification address
1187
+ await self._store.set_user_roles(user_id, roles, assigned_by=actor)
1188
+ await self._store.revoke_user_sessions(user_id) # re-resolve permissions on next login
1189
+ await self._audit(
1190
+ "user.roles_changed",
1191
+ actor=actor,
1192
+ detail=_json({"user_id": user_id, "roles": list(roles)}),
1193
+ )
1194
+ if user is not None:
1195
+ await self._notify_security(
1196
+ ROLES_CHANGED,
1197
+ username=user.username,
1198
+ email=user.email,
1199
+ detail={"roles": list(roles)},
1200
+ )
1201
+
1202
+ async def admin_reset_password(self, user_id: str, *, actor: str) -> str:
1203
+ """Admin-initiated password reset (ASVS 6.4.6 / WP-L3-12). Generate a CSPRNG one-time password
1204
+ through the active policy, set it with ``must_change_password`` (forces a change on first
1205
+ login), and revoke the user's sessions. Returns the one-time credential **once** so the caller
1206
+ can convey it out-of-band — the administrator never sets a lasting password the user keeps. The
1207
+ affected user is also notified out-of-band by email. Raises :class:`ValueError` for an unknown
1208
+ user or a non-local (AD) account; the API maps these to 4xx."""
1209
+ user = await self._store.get_user(user_id)
1210
+ if user is None:
1211
+ raise ValueError("no such user")
1212
+ if user.auth_provider != AuthProvider.LOCAL.value:
1213
+ raise ValueError("only local users have a password to reset")
1214
+ temp = self._generate_policy_password()
1215
+ await self._store.set_password(
1216
+ user_id,
1217
+ password_hash=await self._argon2(hash_password, temp),
1218
+ must_change_password=True,
1219
+ )
1220
+ await self._store.revoke_user_sessions(user_id) # invalidate any live sessions on reset
1221
+ await self._audit(
1222
+ "auth.password_reset",
1223
+ actor=actor,
1224
+ detail=_json({"user_id": user_id, "username": user.username}),
1225
+ )
1226
+ await self._notify_security(PASSWORD_RESET, username=user.username, email=user.email)
1227
+ return temp
1228
+
1229
+ async def set_channel_scope(
1230
+ self, user_id: str, channels: Sequence[str] | None, *, actor: str
1231
+ ) -> None:
1232
+ """Set a user's per-channel RBAC scope (``None`` = all). Revokes their sessions so the new
1233
+ scope takes effect immediately, and audits the change."""
1234
+ scope_json = None if channels is None else _json(sorted(set(channels)))
1235
+ await self._store.set_user_channel_scope(user_id, scope_json)
1236
+ await self._store.revoke_user_sessions(user_id)
1237
+ await self._audit(
1238
+ "user.channel_scope_changed",
1239
+ actor=actor,
1240
+ detail=_json(
1241
+ {
1242
+ "user_id": user_id,
1243
+ "channels": None if channels is None else sorted(set(channels)),
1244
+ }
1245
+ ),
1246
+ )
1247
+
1248
+ async def is_last_enabled_admin(self, user_id: str) -> bool:
1249
+ """True iff ``user_id`` is an enabled administrator and the only one remaining.
1250
+
1251
+ Guards the role-removal path so the deployment can never be left with no usable admin
1252
+ account (the bootstrap admin only regenerates against a fully empty users table).
1253
+ """
1254
+ admins: set[str] = set()
1255
+ for user in await self._store.list_users():
1256
+ if user.disabled:
1257
+ continue
1258
+ if Role.ADMINISTRATOR.value in await self._store.get_user_role_ids(user.id):
1259
+ admins.add(user.id)
1260
+ return admins == {user_id}
1261
+
1262
+ async def set_ad_group_map(self, entries: Sequence[tuple[str, str]], *, actor: str) -> None:
1263
+ await self._store.set_ad_group_role_map(entries)
1264
+ await self._audit(
1265
+ "ad_group_map.updated", actor=actor, detail=_json({"count": len(entries)})
1266
+ )
1267
+
1268
+ async def set_ad_group_scope_map(
1269
+ self, entries: Sequence[tuple[str, str]], *, actor: str
1270
+ ) -> None:
1271
+ """Replace the AD-group → channel-scope map (C3). Takes effect on each AD user's next login."""
1272
+ await self._store.set_ad_group_scope_map(entries)
1273
+ await self._audit(
1274
+ "ad_group_scope_map.updated", actor=actor, detail=_json({"count": len(entries)})
1275
+ )
1276
+
1277
+ # --- audit ---------------------------------------------------------------
1278
+
1279
+ async def audit_permission_denied(
1280
+ self, identity: Identity, permission: Permission, path: str
1281
+ ) -> None:
1282
+ await self._audit(
1283
+ "auth.permission_denied",
1284
+ actor=identity.username,
1285
+ detail=_json({"permission": permission.value, "path": path}),
1286
+ )
1287
+
1288
+ async def _audit(
1289
+ self, action: str, *, actor: str | None = None, detail: str | None = None
1290
+ ) -> None:
1291
+ await self._store.record_audit(action, actor=actor, detail=detail)
1292
+
1293
+ async def _notify_security(
1294
+ self,
1295
+ event_type: str,
1296
+ *,
1297
+ username: str,
1298
+ email: str | None,
1299
+ client: str | None = None,
1300
+ detail: dict[str, Any] | None = None,
1301
+ ) -> None:
1302
+ """Best-effort out-of-band security-event push (ASVS 6.3.5/6.3.7). A missing notifier or a
1303
+ notifier failure is swallowed (logged) — a notification must never break a login or an admin
1304
+ action. The event is also already in the audit log (the /me/security-events feed)."""
1305
+ if self._security_notifier is None:
1306
+ return
1307
+ try:
1308
+ await self._security_notifier.notify(
1309
+ SecurityEvent(
1310
+ event_type=event_type,
1311
+ username=username,
1312
+ email=email,
1313
+ client_ip=client,
1314
+ detail=detail or {},
1315
+ )
1316
+ )
1317
+ except Exception: # noqa: BLE001 - best-effort; never propagate into auth
1318
+ _log.warning(
1319
+ "security-event notification failed (%s for %s)",
1320
+ event_type,
1321
+ username,
1322
+ exc_info=True,
1323
+ )