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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Authentication + user-administration routes, registered onto the app by :func:`add_auth_routes`.
|
|
4
|
+
|
|
5
|
+
Kept out of ``app.py`` to keep that file focused on the engine surface. Every route here is
|
|
6
|
+
deny-by-default: it depends on ``require(...)`` for the relevant permission (login/logout/me are the
|
|
7
|
+
only unauthenticated or self-scoped ones).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import binascii
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
|
|
18
|
+
|
|
19
|
+
from messagefoundry.api.auth_models import (
|
|
20
|
+
AdGroupMap,
|
|
21
|
+
AdGroupMapEntry,
|
|
22
|
+
AdGroupScopeEntry,
|
|
23
|
+
AdGroupScopeMap,
|
|
24
|
+
AuditEntry,
|
|
25
|
+
AuditList,
|
|
26
|
+
ChannelScope,
|
|
27
|
+
CurrentUser,
|
|
28
|
+
LoginRequest,
|
|
29
|
+
LoginResponse,
|
|
30
|
+
MfaConfirmRequest,
|
|
31
|
+
MfaConfirmResponse,
|
|
32
|
+
MfaEnrollResponse,
|
|
33
|
+
MfaStatusResponse,
|
|
34
|
+
MfaVerifyRequest,
|
|
35
|
+
PasswordChangeRequest,
|
|
36
|
+
PasswordResetResponse,
|
|
37
|
+
ProvidersInfo,
|
|
38
|
+
ReauthRequest,
|
|
39
|
+
RoleInfo,
|
|
40
|
+
RolesUpdateRequest,
|
|
41
|
+
SecurityEventInfo,
|
|
42
|
+
SecurityEventsList,
|
|
43
|
+
SessionInfo,
|
|
44
|
+
SessionList,
|
|
45
|
+
SimpleMessage,
|
|
46
|
+
UserCreateRequest,
|
|
47
|
+
UserSummary,
|
|
48
|
+
UserUpdateRequest,
|
|
49
|
+
)
|
|
50
|
+
from messagefoundry.api.security import (
|
|
51
|
+
bearer_token,
|
|
52
|
+
get_auth,
|
|
53
|
+
require,
|
|
54
|
+
require_reauth_only,
|
|
55
|
+
require_step_up,
|
|
56
|
+
)
|
|
57
|
+
from messagefoundry.auth import (
|
|
58
|
+
BUILTIN_ROLE_PERMISSIONS,
|
|
59
|
+
ROLE_METADATA,
|
|
60
|
+
AuthProvider,
|
|
61
|
+
Identity,
|
|
62
|
+
Permission,
|
|
63
|
+
Role,
|
|
64
|
+
)
|
|
65
|
+
from messagefoundry.auth.service import AuthService
|
|
66
|
+
from messagefoundry.auth.tokens import hash_token
|
|
67
|
+
from messagefoundry.store.store import SessionRecord, UserRecord
|
|
68
|
+
|
|
69
|
+
_VALID_ROLE_IDS = {role.value for role in Role}
|
|
70
|
+
|
|
71
|
+
_log = logging.getLogger(__name__)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _session_info(session: SessionRecord, current_token_hash: str) -> SessionInfo:
|
|
75
|
+
"""Project a stored session into the self-service view, flagging the caller's current one (WP-10)."""
|
|
76
|
+
return SessionInfo(
|
|
77
|
+
id=session.token_hash,
|
|
78
|
+
created_at=session.created_at,
|
|
79
|
+
last_used_at=session.last_used_at,
|
|
80
|
+
expires_at=session.expires_at,
|
|
81
|
+
client=session.client,
|
|
82
|
+
current=session.token_hash == current_token_hash,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _rate_limited(request: Request, label: str) -> HTTPException:
|
|
87
|
+
"""Log a throttled (HTTP 429) attempt so password-spraying is no longer silent (ASVS 16.3.3),
|
|
88
|
+
then return the exception to raise. We log (the rotating general log) rather than write an
|
|
89
|
+
audit_log row per rejection so a sustained flood can't amplify into unbounded DB growth — the
|
|
90
|
+
per-account ``auth.login_failed``/``auth.login_locked`` events already provide the audit trail."""
|
|
91
|
+
_log.warning("rate-limited %s attempt from client=%s", label, _client(request))
|
|
92
|
+
return HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many attempts; please retry later")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _service(request: Request) -> AuthService:
|
|
96
|
+
auth = get_auth(request)
|
|
97
|
+
if auth is None or not auth.enabled:
|
|
98
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "authentication is not enabled")
|
|
99
|
+
return auth
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _client(request: Request) -> str | None:
|
|
103
|
+
return request.client.host if request.client else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _current_user(identity: Identity) -> CurrentUser:
|
|
107
|
+
return CurrentUser(
|
|
108
|
+
user_id=identity.user_id,
|
|
109
|
+
username=identity.username,
|
|
110
|
+
auth_provider=identity.auth_provider.value,
|
|
111
|
+
roles=sorted(r.value for r in identity.roles),
|
|
112
|
+
permissions=sorted(p.value for p in identity.permissions),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _login_response(
|
|
117
|
+
token: str, identity: Identity, must_change: bool, *, mfa_required: bool = False
|
|
118
|
+
) -> LoginResponse:
|
|
119
|
+
return LoginResponse(
|
|
120
|
+
token=token,
|
|
121
|
+
must_change_password=must_change,
|
|
122
|
+
mfa_required=mfa_required,
|
|
123
|
+
user=_current_user(identity),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_channel_scope(raw: str | None) -> list[str] | None:
|
|
128
|
+
"""Decode the stored ``channel_scope`` JSON to a list (None = all; malformed → empty list)."""
|
|
129
|
+
if raw is None:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
value = json.loads(raw)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
return []
|
|
135
|
+
return [str(c) for c in value] if isinstance(value, list) else []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _user_summary(user: UserRecord, role_ids: list[str]) -> UserSummary:
|
|
139
|
+
return UserSummary(
|
|
140
|
+
id=user.id,
|
|
141
|
+
username=user.username,
|
|
142
|
+
auth_provider=user.auth_provider,
|
|
143
|
+
display_name=user.display_name,
|
|
144
|
+
email=user.email,
|
|
145
|
+
disabled=user.disabled,
|
|
146
|
+
roles=sorted(role_ids),
|
|
147
|
+
channel_scope=_parse_channel_scope(user.channel_scope),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_roles(roles: list[str]) -> None:
|
|
152
|
+
unknown = sorted(set(roles) - _VALID_ROLE_IDS)
|
|
153
|
+
if unknown:
|
|
154
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"unknown role(s): {', '.join(unknown)}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def add_auth_routes(app: FastAPI) -> None:
|
|
158
|
+
# --- authentication ------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
@app.get("/auth/providers", response_model=ProvidersInfo)
|
|
161
|
+
async def providers(
|
|
162
|
+
request: Request, service: AuthService = Depends(_service)
|
|
163
|
+
) -> ProvidersInfo:
|
|
164
|
+
return ProvidersInfo(local=True, ad=service.ad_enabled, kerberos=service.kerberos_enabled)
|
|
165
|
+
|
|
166
|
+
@app.post("/auth/login", response_model=LoginResponse)
|
|
167
|
+
async def login(
|
|
168
|
+
body: LoginRequest, request: Request, service: AuthService = Depends(_service)
|
|
169
|
+
) -> LoginResponse:
|
|
170
|
+
if not service.allow_login_attempt(_client(request)):
|
|
171
|
+
raise _rate_limited(request, "login")
|
|
172
|
+
try:
|
|
173
|
+
provider = AuthProvider(body.provider)
|
|
174
|
+
except ValueError:
|
|
175
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown provider") from None
|
|
176
|
+
outcome = await service.login(
|
|
177
|
+
body.username, body.password, provider=provider, client=_client(request)
|
|
178
|
+
)
|
|
179
|
+
if not outcome.ok or outcome.token is None or outcome.identity is None:
|
|
180
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
|
181
|
+
return _login_response(
|
|
182
|
+
outcome.token,
|
|
183
|
+
outcome.identity,
|
|
184
|
+
outcome.must_change_password,
|
|
185
|
+
mfa_required=outcome.mfa_required,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@app.post("/auth/negotiate", response_model=LoginResponse)
|
|
189
|
+
async def negotiate(
|
|
190
|
+
request: Request, service: AuthService = Depends(_service)
|
|
191
|
+
) -> LoginResponse:
|
|
192
|
+
if not service.allow_login_attempt(_client(request)):
|
|
193
|
+
raise _rate_limited(request, "negotiate")
|
|
194
|
+
header = request.headers.get("Authorization", "")
|
|
195
|
+
if not header.startswith("Negotiate "):
|
|
196
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "missing SPNEGO token")
|
|
197
|
+
try:
|
|
198
|
+
token_bytes = base64.b64decode(header[len("Negotiate ") :], validate=True)
|
|
199
|
+
except (binascii.Error, ValueError):
|
|
200
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid SPNEGO token") from None
|
|
201
|
+
outcome = await service.authenticate_kerberos(token_bytes, client=_client(request))
|
|
202
|
+
if not outcome.ok or outcome.token is None or outcome.identity is None:
|
|
203
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "SSO authentication failed")
|
|
204
|
+
return _login_response(outcome.token, outcome.identity, outcome.must_change_password)
|
|
205
|
+
|
|
206
|
+
@app.post("/auth/logout", response_model=SimpleMessage)
|
|
207
|
+
async def logout(
|
|
208
|
+
request: Request,
|
|
209
|
+
service: AuthService = Depends(_service),
|
|
210
|
+
identity: Identity = Depends(require()),
|
|
211
|
+
) -> SimpleMessage:
|
|
212
|
+
await service.logout(bearer_token(request), actor=identity.username)
|
|
213
|
+
return SimpleMessage(detail="logged out")
|
|
214
|
+
|
|
215
|
+
@app.get("/auth/me", response_model=CurrentUser)
|
|
216
|
+
async def me(identity: Identity = Depends(require())) -> CurrentUser:
|
|
217
|
+
return _current_user(identity)
|
|
218
|
+
|
|
219
|
+
@app.post("/me/password", response_model=SimpleMessage)
|
|
220
|
+
async def change_password(
|
|
221
|
+
body: PasswordChangeRequest,
|
|
222
|
+
request: Request,
|
|
223
|
+
service: AuthService = Depends(_service),
|
|
224
|
+
identity: Identity = Depends(require()),
|
|
225
|
+
) -> SimpleMessage:
|
|
226
|
+
if not service.allow_login_attempt(_client(request)):
|
|
227
|
+
raise _rate_limited(request, "password-change")
|
|
228
|
+
if identity.auth_provider is AuthProvider.AD:
|
|
229
|
+
raise HTTPException(
|
|
230
|
+
status.HTTP_400_BAD_REQUEST, "AD passwords are managed in Active Directory"
|
|
231
|
+
)
|
|
232
|
+
if not await service.verify_current_password(identity, body.current_password):
|
|
233
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, "current password is incorrect")
|
|
234
|
+
violations = await service.change_password(
|
|
235
|
+
identity, body.new_password, client=_client(request)
|
|
236
|
+
)
|
|
237
|
+
if violations:
|
|
238
|
+
raise HTTPException(
|
|
239
|
+
status.HTTP_400_BAD_REQUEST, "password must " + "; ".join(violations)
|
|
240
|
+
)
|
|
241
|
+
return SimpleMessage(detail="password changed; please sign in again")
|
|
242
|
+
|
|
243
|
+
@app.post("/me/reauth", response_model=SimpleMessage)
|
|
244
|
+
async def reauth(
|
|
245
|
+
body: ReauthRequest,
|
|
246
|
+
request: Request,
|
|
247
|
+
service: AuthService = Depends(_service),
|
|
248
|
+
identity: Identity = Depends(require()),
|
|
249
|
+
) -> SimpleMessage:
|
|
250
|
+
"""Step-up re-verification (ASVS 7.5.3): re-prove the current credential to refresh this
|
|
251
|
+
session's step-up window so it may perform highly sensitive operations for the configured
|
|
252
|
+
period. Rate-limited like the password change; a failure is a 403 and performs nothing."""
|
|
253
|
+
if not service.allow_login_attempt(_client(request)):
|
|
254
|
+
raise _rate_limited(request, "reauth")
|
|
255
|
+
token = bearer_token(request)
|
|
256
|
+
if token is None or not await service.reauth(
|
|
257
|
+
identity, body.password, token=token, client=_client(request)
|
|
258
|
+
):
|
|
259
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, "re-verification failed")
|
|
260
|
+
return SimpleMessage(detail="re-verified")
|
|
261
|
+
|
|
262
|
+
# --- MFA: native TOTP second factor (WP-14, ASVS 6.3.3) ------------------
|
|
263
|
+
|
|
264
|
+
@app.post("/auth/mfa-verify", response_model=SimpleMessage)
|
|
265
|
+
async def mfa_verify(
|
|
266
|
+
body: MfaVerifyRequest,
|
|
267
|
+
request: Request,
|
|
268
|
+
service: AuthService = Depends(_service),
|
|
269
|
+
_: Identity = Depends(require()),
|
|
270
|
+
) -> SimpleMessage:
|
|
271
|
+
"""Satisfy the current session's second factor with a TOTP code or a single-use recovery code.
|
|
272
|
+
Authenticated but **not** step-up/MFA-gated (this is *how* a session becomes MFA-satisfied);
|
|
273
|
+
rate-limited like login. A wrong code is a 401 and changes nothing."""
|
|
274
|
+
if not service.allow_login_attempt(_client(request)):
|
|
275
|
+
raise _rate_limited(request, "mfa-verify")
|
|
276
|
+
token = bearer_token(request)
|
|
277
|
+
if token is None or not await service.verify_mfa(token, body.code, client=_client(request)):
|
|
278
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid code")
|
|
279
|
+
return SimpleMessage(detail="verified")
|
|
280
|
+
|
|
281
|
+
@app.get("/me/mfa", response_model=MfaStatusResponse)
|
|
282
|
+
async def my_mfa(
|
|
283
|
+
service: AuthService = Depends(_service),
|
|
284
|
+
identity: Identity = Depends(require()),
|
|
285
|
+
) -> MfaStatusResponse:
|
|
286
|
+
"""The caller's current MFA posture (enabled, enrolled-at, recovery codes left, required)."""
|
|
287
|
+
st = await service.mfa_status(identity)
|
|
288
|
+
return MfaStatusResponse(
|
|
289
|
+
enabled=st.enabled,
|
|
290
|
+
enrolled_at=st.enrolled_at,
|
|
291
|
+
recovery_codes_remaining=st.recovery_codes_remaining,
|
|
292
|
+
required=st.required,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
@app.post("/me/mfa/enroll", response_model=MfaEnrollResponse)
|
|
296
|
+
async def enroll_mfa(
|
|
297
|
+
service: AuthService = Depends(_service),
|
|
298
|
+
identity: Identity = Depends(require_reauth_only()),
|
|
299
|
+
) -> MfaEnrollResponse:
|
|
300
|
+
"""Begin TOTP enrollment: stage a secret and return it + the ``otpauth://`` URI for the QR.
|
|
301
|
+
Gated by a recent **password** step-up (not MFA — you may have none yet); not active until
|
|
302
|
+
confirmed via ``/me/mfa/confirm``."""
|
|
303
|
+
if identity.auth_provider is AuthProvider.AD:
|
|
304
|
+
raise HTTPException(
|
|
305
|
+
status.HTTP_400_BAD_REQUEST, "AD accounts use directory MFA, not an engine TOTP"
|
|
306
|
+
)
|
|
307
|
+
try:
|
|
308
|
+
enroll = await service.begin_mfa_enrollment(identity)
|
|
309
|
+
except ValueError as exc:
|
|
310
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
311
|
+
return MfaEnrollResponse(secret=enroll.secret, otpauth_uri=enroll.otpauth_uri)
|
|
312
|
+
|
|
313
|
+
@app.post("/me/mfa/confirm", response_model=MfaConfirmResponse)
|
|
314
|
+
async def confirm_mfa(
|
|
315
|
+
body: MfaConfirmRequest,
|
|
316
|
+
request: Request,
|
|
317
|
+
service: AuthService = Depends(_service),
|
|
318
|
+
identity: Identity = Depends(require_reauth_only()),
|
|
319
|
+
) -> MfaConfirmResponse:
|
|
320
|
+
"""Confirm a staged enrollment by proving a live TOTP code; activates MFA and returns the
|
|
321
|
+
single-use recovery codes (shown **once** — save them). A wrong code is a 400."""
|
|
322
|
+
if not service.allow_login_attempt(_client(request)):
|
|
323
|
+
raise _rate_limited(request, "mfa-confirm")
|
|
324
|
+
token = bearer_token(request)
|
|
325
|
+
if token is None:
|
|
326
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
|
327
|
+
try:
|
|
328
|
+
codes = await service.confirm_mfa_enrollment(
|
|
329
|
+
identity, body.code, token=token, client=_client(request)
|
|
330
|
+
)
|
|
331
|
+
except ValueError as exc:
|
|
332
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
|
333
|
+
if codes is None:
|
|
334
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid code")
|
|
335
|
+
return MfaConfirmResponse(recovery_codes=codes)
|
|
336
|
+
|
|
337
|
+
@app.delete("/me/mfa", response_model=SimpleMessage)
|
|
338
|
+
async def disable_my_mfa(
|
|
339
|
+
request: Request,
|
|
340
|
+
service: AuthService = Depends(_service),
|
|
341
|
+
identity: Identity = Depends(require_step_up()),
|
|
342
|
+
) -> SimpleMessage:
|
|
343
|
+
"""Self-service: turn off the caller's TOTP MFA. Step-up gated — you prove your current factor
|
|
344
|
+
(a TOTP or recovery code via ``/auth/mfa-verify``) and a recent password."""
|
|
345
|
+
await service.disable_mfa(identity, client=_client(request))
|
|
346
|
+
return SimpleMessage(detail="MFA disabled")
|
|
347
|
+
|
|
348
|
+
# --- self-service session inventory (WP-10, ASVS 7.5.2/7.4.5) -------------
|
|
349
|
+
|
|
350
|
+
@app.get("/me/sessions", response_model=SessionList)
|
|
351
|
+
async def my_sessions(
|
|
352
|
+
request: Request,
|
|
353
|
+
service: AuthService = Depends(_service),
|
|
354
|
+
identity: Identity = Depends(require()),
|
|
355
|
+
) -> SessionList:
|
|
356
|
+
current = hash_token(bearer_token(request) or "")
|
|
357
|
+
sessions = await service.list_sessions(identity.user_id)
|
|
358
|
+
return SessionList(sessions=[_session_info(s, current) for s in sessions])
|
|
359
|
+
|
|
360
|
+
@app.get("/me/security-events", response_model=SecurityEventsList)
|
|
361
|
+
async def my_security_events(
|
|
362
|
+
service: AuthService = Depends(_service),
|
|
363
|
+
identity: Identity = Depends(require()),
|
|
364
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
365
|
+
) -> SecurityEventsList:
|
|
366
|
+
"""The caller's own security-event history (WP-L3-05, ASVS 6.3.5/6.3.7): the audited ``auth.*``
|
|
367
|
+
actions on their account (sign-ins, lockouts, password changes), most-recent-first. The
|
|
368
|
+
out-of-band email push complements this for events the user should learn of without logging in
|
|
369
|
+
(and for admin-initiated changes, whose audit actor is the admin)."""
|
|
370
|
+
rows = await service.security_events_for(identity.username, limit=limit)
|
|
371
|
+
return SecurityEventsList(events=[SecurityEventInfo(**r) for r in rows])
|
|
372
|
+
|
|
373
|
+
@app.delete("/me/sessions/{session_id}", response_model=SimpleMessage)
|
|
374
|
+
async def revoke_my_session(
|
|
375
|
+
session_id: str,
|
|
376
|
+
service: AuthService = Depends(_service),
|
|
377
|
+
identity: Identity = Depends(require()),
|
|
378
|
+
) -> SimpleMessage:
|
|
379
|
+
# Ownership-checked in the service: a 404 (not 403) avoids confirming another user's session id.
|
|
380
|
+
if not await service.revoke_own_session(identity, session_id, actor=identity.username):
|
|
381
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such session")
|
|
382
|
+
return SimpleMessage(detail="session revoked")
|
|
383
|
+
|
|
384
|
+
@app.delete("/me/sessions", response_model=SimpleMessage)
|
|
385
|
+
async def revoke_my_other_sessions(
|
|
386
|
+
request: Request,
|
|
387
|
+
service: AuthService = Depends(_service),
|
|
388
|
+
identity: Identity = Depends(require()),
|
|
389
|
+
) -> SimpleMessage:
|
|
390
|
+
current = hash_token(bearer_token(request) or "")
|
|
391
|
+
revoked = await service.revoke_other_sessions(identity, current, actor=identity.username)
|
|
392
|
+
return SimpleMessage(detail=f"signed out {revoked} other session(s)")
|
|
393
|
+
|
|
394
|
+
# --- roles + user administration -----------------------------------------
|
|
395
|
+
|
|
396
|
+
@app.get("/roles", response_model=list[RoleInfo])
|
|
397
|
+
async def list_roles(_: Identity = Depends(require(Permission.USERS_READ))) -> list[RoleInfo]:
|
|
398
|
+
out: list[RoleInfo] = []
|
|
399
|
+
for role in Role:
|
|
400
|
+
label, description = ROLE_METADATA[role]
|
|
401
|
+
out.append(
|
|
402
|
+
RoleInfo(
|
|
403
|
+
id=role.value,
|
|
404
|
+
display_name=label,
|
|
405
|
+
description=description,
|
|
406
|
+
permissions=sorted(p.value for p in BUILTIN_ROLE_PERMISSIONS[role]),
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
@app.get("/users", response_model=list[UserSummary])
|
|
412
|
+
async def list_users(
|
|
413
|
+
service: AuthService = Depends(_service),
|
|
414
|
+
_: Identity = Depends(require(Permission.USERS_READ)),
|
|
415
|
+
) -> list[UserSummary]:
|
|
416
|
+
summaries: list[UserSummary] = []
|
|
417
|
+
for user in await service.store.list_users():
|
|
418
|
+
role_ids = await service.store.get_user_role_ids(user.id)
|
|
419
|
+
summaries.append(_user_summary(user, role_ids))
|
|
420
|
+
return summaries
|
|
421
|
+
|
|
422
|
+
@app.post("/users", response_model=UserSummary, status_code=status.HTTP_201_CREATED)
|
|
423
|
+
async def create_user(
|
|
424
|
+
body: UserCreateRequest,
|
|
425
|
+
service: AuthService = Depends(_service),
|
|
426
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
427
|
+
) -> UserSummary:
|
|
428
|
+
_validate_roles(body.roles)
|
|
429
|
+
if await service.store.get_user_by_username(body.username) is not None:
|
|
430
|
+
raise HTTPException(status.HTTP_409_CONFLICT, "username already exists")
|
|
431
|
+
violations = service.password_violations(body.password, username=body.username)
|
|
432
|
+
if violations:
|
|
433
|
+
raise HTTPException(
|
|
434
|
+
status.HTTP_400_BAD_REQUEST, "password must " + "; ".join(violations)
|
|
435
|
+
)
|
|
436
|
+
user_id = await service.create_local_user(
|
|
437
|
+
username=body.username,
|
|
438
|
+
password=body.password,
|
|
439
|
+
display_name=body.display_name,
|
|
440
|
+
email=body.email,
|
|
441
|
+
roles=body.roles,
|
|
442
|
+
actor=identity.username,
|
|
443
|
+
)
|
|
444
|
+
user = await service.store.get_user(user_id)
|
|
445
|
+
assert user is not None
|
|
446
|
+
return _user_summary(user, sorted(body.roles))
|
|
447
|
+
|
|
448
|
+
@app.patch("/users/{user_id}", response_model=SimpleMessage)
|
|
449
|
+
async def update_user(
|
|
450
|
+
user_id: str,
|
|
451
|
+
body: UserUpdateRequest,
|
|
452
|
+
service: AuthService = Depends(_service),
|
|
453
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
454
|
+
) -> SimpleMessage:
|
|
455
|
+
current = await service.store.get_user(user_id)
|
|
456
|
+
if current is None:
|
|
457
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
|
|
458
|
+
if body.disabled and user_id == identity.user_id:
|
|
459
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "cannot disable your own account")
|
|
460
|
+
# PATCH is partial: only fields actually present in the body should change. Omitted
|
|
461
|
+
# display_name/email keep their current value (the store sets them unconditionally, so a
|
|
462
|
+
# partial PATCH would otherwise NULL them); an explicit null still clears (review M-20).
|
|
463
|
+
supplied = body.model_fields_set
|
|
464
|
+
await service.update_user(
|
|
465
|
+
user_id,
|
|
466
|
+
display_name=body.display_name if "display_name" in supplied else current.display_name,
|
|
467
|
+
email=body.email if "email" in supplied else current.email,
|
|
468
|
+
disabled=body.disabled if "disabled" in supplied else None,
|
|
469
|
+
actor=identity.username,
|
|
470
|
+
)
|
|
471
|
+
return SimpleMessage(detail="updated")
|
|
472
|
+
|
|
473
|
+
@app.delete("/users/{user_id}", response_model=SimpleMessage)
|
|
474
|
+
async def delete_user(
|
|
475
|
+
user_id: str,
|
|
476
|
+
service: AuthService = Depends(_service),
|
|
477
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
478
|
+
) -> SimpleMessage:
|
|
479
|
+
if user_id == identity.user_id:
|
|
480
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "cannot delete your own account")
|
|
481
|
+
if await service.store.get_user(user_id) is None:
|
|
482
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
|
|
483
|
+
await service.delete_user(user_id, actor=identity.username)
|
|
484
|
+
return SimpleMessage(detail="deleted")
|
|
485
|
+
|
|
486
|
+
@app.delete("/users/{user_id}/sessions", response_model=SimpleMessage)
|
|
487
|
+
async def admin_revoke_user_sessions(
|
|
488
|
+
user_id: str,
|
|
489
|
+
service: AuthService = Depends(_service),
|
|
490
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
491
|
+
) -> SimpleMessage:
|
|
492
|
+
"""Force-sign-out: revoke all of a user's sessions (e.g. after a compromise or offboarding)."""
|
|
493
|
+
if await service.store.get_user(user_id) is None:
|
|
494
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
|
|
495
|
+
revoked = await service.revoke_sessions_for_user(user_id, actor=identity.username)
|
|
496
|
+
return SimpleMessage(detail=f"revoked {revoked} session(s)")
|
|
497
|
+
|
|
498
|
+
@app.put("/users/{user_id}/roles", response_model=SimpleMessage)
|
|
499
|
+
async def set_user_roles(
|
|
500
|
+
user_id: str,
|
|
501
|
+
body: RolesUpdateRequest,
|
|
502
|
+
service: AuthService = Depends(_service),
|
|
503
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
504
|
+
) -> SimpleMessage:
|
|
505
|
+
_validate_roles(body.roles)
|
|
506
|
+
user = await service.store.get_user(user_id)
|
|
507
|
+
if user is None:
|
|
508
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
|
|
509
|
+
if user.auth_provider == AuthProvider.AD.value:
|
|
510
|
+
raise HTTPException(
|
|
511
|
+
status.HTTP_400_BAD_REQUEST, "AD users get roles from the AD-group map"
|
|
512
|
+
)
|
|
513
|
+
if Role.ADMINISTRATOR.value not in body.roles and await service.is_last_enabled_admin(
|
|
514
|
+
user_id
|
|
515
|
+
):
|
|
516
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "cannot remove the last administrator")
|
|
517
|
+
await service.set_roles(user_id, body.roles, actor=identity.username)
|
|
518
|
+
return SimpleMessage(detail="roles updated")
|
|
519
|
+
|
|
520
|
+
@app.post("/users/{user_id}/reset-password", response_model=PasswordResetResponse)
|
|
521
|
+
async def reset_user_password(
|
|
522
|
+
user_id: str,
|
|
523
|
+
service: AuthService = Depends(_service),
|
|
524
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
525
|
+
) -> PasswordResetResponse:
|
|
526
|
+
"""Admin password reset (ASVS 6.4.6 / WP-L3-12): issue a one-time, must-change credential the
|
|
527
|
+
administrator never keeps. Returned **once** for out-of-band delivery; the affected user is also
|
|
528
|
+
notified by email. Use change-password for your own account."""
|
|
529
|
+
if user_id == identity.user_id:
|
|
530
|
+
raise HTTPException(
|
|
531
|
+
status.HTTP_400_BAD_REQUEST, "use change-password for your own account"
|
|
532
|
+
)
|
|
533
|
+
try:
|
|
534
|
+
temp = await service.admin_reset_password(user_id, actor=identity.username)
|
|
535
|
+
except ValueError as exc:
|
|
536
|
+
detail = str(exc)
|
|
537
|
+
code = (
|
|
538
|
+
status.HTTP_404_NOT_FOUND
|
|
539
|
+
if detail == "no such user"
|
|
540
|
+
else status.HTTP_400_BAD_REQUEST
|
|
541
|
+
)
|
|
542
|
+
raise HTTPException(code, detail) from exc
|
|
543
|
+
return PasswordResetResponse(temp_password=temp)
|
|
544
|
+
|
|
545
|
+
@app.post("/users/{user_id}/reset-mfa", response_model=SimpleMessage)
|
|
546
|
+
async def reset_user_mfa(
|
|
547
|
+
user_id: str,
|
|
548
|
+
service: AuthService = Depends(_service),
|
|
549
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
550
|
+
) -> SimpleMessage:
|
|
551
|
+
"""Admin MFA reset (lost authenticator + no recovery codes): clear the user's TOTP enrollment
|
|
552
|
+
and revoke their sessions so they re-enroll. The acting admin is itself step-up + MFA gated."""
|
|
553
|
+
try:
|
|
554
|
+
await service.admin_reset_mfa(user_id, actor=identity.username)
|
|
555
|
+
except ValueError as exc:
|
|
556
|
+
detail = str(exc)
|
|
557
|
+
code = (
|
|
558
|
+
status.HTTP_404_NOT_FOUND
|
|
559
|
+
if detail == "no such user"
|
|
560
|
+
else status.HTTP_400_BAD_REQUEST
|
|
561
|
+
)
|
|
562
|
+
raise HTTPException(code, detail) from exc
|
|
563
|
+
return SimpleMessage(detail="MFA reset")
|
|
564
|
+
|
|
565
|
+
@app.get("/users/{user_id}/channel-scope", response_model=ChannelScope)
|
|
566
|
+
async def get_channel_scope(
|
|
567
|
+
user_id: str,
|
|
568
|
+
service: AuthService = Depends(_service),
|
|
569
|
+
_: Identity = Depends(require(Permission.USERS_MANAGE)),
|
|
570
|
+
) -> ChannelScope:
|
|
571
|
+
user = await service.store.get_user(user_id)
|
|
572
|
+
if user is None:
|
|
573
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
|
|
574
|
+
return ChannelScope(channels=_parse_channel_scope(user.channel_scope))
|
|
575
|
+
|
|
576
|
+
@app.put("/users/{user_id}/channel-scope", response_model=SimpleMessage)
|
|
577
|
+
async def set_channel_scope(
|
|
578
|
+
user_id: str,
|
|
579
|
+
body: ChannelScope,
|
|
580
|
+
service: AuthService = Depends(_service),
|
|
581
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
582
|
+
) -> SimpleMessage:
|
|
583
|
+
"""Set a user's per-channel RBAC scope (``channels: null`` = all). Administrators are always
|
|
584
|
+
all-channels, so a scope set on one has no effect."""
|
|
585
|
+
if await service.store.get_user(user_id) is None:
|
|
586
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user")
|
|
587
|
+
await service.set_channel_scope(user_id, body.channels, actor=identity.username)
|
|
588
|
+
return SimpleMessage(detail="channel scope updated")
|
|
589
|
+
|
|
590
|
+
# --- AD group -> role mapping --------------------------------------------
|
|
591
|
+
|
|
592
|
+
@app.get("/ad-group-map", response_model=AdGroupMap)
|
|
593
|
+
async def get_ad_group_map(
|
|
594
|
+
service: AuthService = Depends(_service),
|
|
595
|
+
_: Identity = Depends(require(Permission.USERS_MANAGE)),
|
|
596
|
+
) -> AdGroupMap:
|
|
597
|
+
rows = await service.store.list_ad_group_role_map()
|
|
598
|
+
return AdGroupMap(
|
|
599
|
+
entries=[AdGroupMapEntry(ad_group=r["ad_group"], role=r["role_id"]) for r in rows]
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
@app.put("/ad-group-map", response_model=SimpleMessage)
|
|
603
|
+
async def set_ad_group_map(
|
|
604
|
+
body: AdGroupMap,
|
|
605
|
+
service: AuthService = Depends(_service),
|
|
606
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
607
|
+
) -> SimpleMessage:
|
|
608
|
+
_validate_roles([e.role for e in body.entries])
|
|
609
|
+
await service.set_ad_group_map(
|
|
610
|
+
[(e.ad_group, e.role) for e in body.entries], actor=identity.username
|
|
611
|
+
)
|
|
612
|
+
return SimpleMessage(detail="ad-group map updated")
|
|
613
|
+
|
|
614
|
+
@app.get("/ad-group-scope-map", response_model=AdGroupScopeMap)
|
|
615
|
+
async def get_ad_group_scope_map(
|
|
616
|
+
service: AuthService = Depends(_service),
|
|
617
|
+
_: Identity = Depends(require(Permission.USERS_MANAGE)),
|
|
618
|
+
) -> AdGroupScopeMap:
|
|
619
|
+
rows = await service.store.list_ad_group_scope_map()
|
|
620
|
+
return AdGroupScopeMap(
|
|
621
|
+
entries=[AdGroupScopeEntry(ad_group=r["ad_group"], channel=r["channel"]) for r in rows]
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
@app.put("/ad-group-scope-map", response_model=SimpleMessage)
|
|
625
|
+
async def set_ad_group_scope_map(
|
|
626
|
+
body: AdGroupScopeMap,
|
|
627
|
+
service: AuthService = Depends(_service),
|
|
628
|
+
identity: Identity = Depends(require_step_up(Permission.USERS_MANAGE)),
|
|
629
|
+
) -> SimpleMessage:
|
|
630
|
+
await service.set_ad_group_scope_map(
|
|
631
|
+
[(e.ad_group, e.channel) for e in body.entries], actor=identity.username
|
|
632
|
+
)
|
|
633
|
+
return SimpleMessage(detail="ad-group scope map updated")
|
|
634
|
+
|
|
635
|
+
# --- audit ---------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
@app.get("/audit", response_model=AuditList)
|
|
638
|
+
async def list_audit(
|
|
639
|
+
service: AuthService = Depends(_service),
|
|
640
|
+
_: Identity = Depends(require(Permission.AUDIT_READ)),
|
|
641
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
642
|
+
) -> AuditList:
|
|
643
|
+
rows = await service.store.list_audit(limit=limit)
|
|
644
|
+
return AuditList(
|
|
645
|
+
entries=[
|
|
646
|
+
AuditEntry(
|
|
647
|
+
ts=r["ts"],
|
|
648
|
+
actor=r["actor"],
|
|
649
|
+
action=r["action"],
|
|
650
|
+
channel_id=r["channel_id"],
|
|
651
|
+
detail=r["detail"],
|
|
652
|
+
)
|
|
653
|
+
for r in rows
|
|
654
|
+
]
|
|
655
|
+
)
|