intflow-authctl 0.1.8__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.
- intflow_auth/__init__.py +3 -0
- intflow_auth/admin_auth.py +512 -0
- intflow_auth/api/__init__.py +1 -0
- intflow_auth/api/admin_app_roles.py +197 -0
- intflow_auth/api/admin_audit.py +77 -0
- intflow_auth/api/admin_clients.py +346 -0
- intflow_auth/api/admin_operator_tokens.py +357 -0
- intflow_auth/api/admin_roles.py +232 -0
- intflow_auth/api/admin_users.py +243 -0
- intflow_auth/api/cli_login.py +341 -0
- intflow_auth/api/dependencies.py +77 -0
- intflow_auth/api/health.py +25 -0
- intflow_auth/api/login.py +495 -0
- intflow_auth/api/oauth.py +595 -0
- intflow_auth/api/oidc.py +137 -0
- intflow_auth/api/pagination.py +19 -0
- intflow_auth/api/rate_limits.py +57 -0
- intflow_auth/api/router.py +30 -0
- intflow_auth/api/session_management.py +268 -0
- intflow_auth/app.py +183 -0
- intflow_auth/app_roles.py +531 -0
- intflow_auth/audit.py +370 -0
- intflow_auth/authctl_cli/__init__.py +1 -0
- intflow_auth/authctl_cli/api_client.py +114 -0
- intflow_auth/authctl_cli/commands.py +578 -0
- intflow_auth/authctl_cli/config.py +151 -0
- intflow_auth/authctl_cli/constants.py +7 -0
- intflow_auth/authctl_cli/errors.py +5 -0
- intflow_auth/authctl_cli/output.py +152 -0
- intflow_auth/authctl_cli/parser.py +633 -0
- intflow_auth/authctl_cli/validation.py +70 -0
- intflow_auth/authctl_credentials.py +57 -0
- intflow_auth/authorization_codes.py +531 -0
- intflow_auth/bootstrap.py +297 -0
- intflow_auth/cli.py +213 -0
- intflow_auth/cli_login_service.py +483 -0
- intflow_auth/cli_login_transactions.py +621 -0
- intflow_auth/config.py +467 -0
- intflow_auth/context.py +43 -0
- intflow_auth/csrf.py +59 -0
- intflow_auth/database.py +101 -0
- intflow_auth/errors.py +61 -0
- intflow_auth/google_oauth.py +600 -0
- intflow_auth/logging.py +246 -0
- intflow_auth/login_transactions.py +481 -0
- intflow_auth/middleware.py +24 -0
- intflow_auth/oauth_clients.py +640 -0
- intflow_auth/oauth_service.py +681 -0
- intflow_auth/operator_admin_tokens.py +558 -0
- intflow_auth/pagination.py +102 -0
- intflow_auth/py.typed +1 -0
- intflow_auth/rate_limits.py +114 -0
- intflow_auth/readiness.py +240 -0
- intflow_auth/refresh_tokens.py +559 -0
- intflow_auth/roles.py +290 -0
- intflow_auth/secret_resolution.py +339 -0
- intflow_auth/sessions.py +525 -0
- intflow_auth/signing_keys.py +298 -0
- intflow_auth/store_resolver.py +286 -0
- intflow_auth/tokens.py +78 -0
- intflow_auth/usage_metadata.py +15 -0
- intflow_auth/users.py +592 -0
- intflow_authctl-0.1.8.dist-info/METADATA +56 -0
- intflow_authctl-0.1.8.dist-info/RECORD +66 -0
- intflow_authctl-0.1.8.dist-info/WHEEL +4 -0
- intflow_authctl-0.1.8.dist-info/entry_points.txt +4 -0
intflow_auth/__init__.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Admin session authorization guard."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException, Request, status
|
|
9
|
+
|
|
10
|
+
from intflow_auth.api.rate_limits import client_ip_subject, enforce_rate_limit
|
|
11
|
+
from intflow_auth.audit import (
|
|
12
|
+
AuditEventInput,
|
|
13
|
+
AuditEventType,
|
|
14
|
+
AuditResult,
|
|
15
|
+
write_audit_event,
|
|
16
|
+
)
|
|
17
|
+
from intflow_auth.config import Settings
|
|
18
|
+
from intflow_auth.operator_admin_tokens import (
|
|
19
|
+
OperatorAdminTokenRecord,
|
|
20
|
+
OperatorAdminTokenStatus,
|
|
21
|
+
OperatorAdminTokenStore,
|
|
22
|
+
hash_operator_admin_token,
|
|
23
|
+
mark_operator_admin_token_expired,
|
|
24
|
+
mark_operator_admin_token_used,
|
|
25
|
+
operator_admin_token_is_expired,
|
|
26
|
+
)
|
|
27
|
+
from intflow_auth.roles import (
|
|
28
|
+
RoleName,
|
|
29
|
+
has_admin_access,
|
|
30
|
+
)
|
|
31
|
+
from intflow_auth.sessions import (
|
|
32
|
+
SESSION_COOKIE_NAME,
|
|
33
|
+
SessionError,
|
|
34
|
+
SessionRecord,
|
|
35
|
+
validate_session_token,
|
|
36
|
+
)
|
|
37
|
+
from intflow_auth.store_resolver import (
|
|
38
|
+
client_ip as _client_ip,
|
|
39
|
+
)
|
|
40
|
+
from intflow_auth.store_resolver import (
|
|
41
|
+
get_audit_writer as _get_audit_writer,
|
|
42
|
+
)
|
|
43
|
+
from intflow_auth.store_resolver import (
|
|
44
|
+
get_operator_admin_token_store as _get_operator_admin_token_store,
|
|
45
|
+
)
|
|
46
|
+
from intflow_auth.store_resolver import (
|
|
47
|
+
get_request_settings as _get_settings,
|
|
48
|
+
)
|
|
49
|
+
from intflow_auth.store_resolver import (
|
|
50
|
+
get_role_assignment_store as _get_role_assignment_store,
|
|
51
|
+
)
|
|
52
|
+
from intflow_auth.store_resolver import (
|
|
53
|
+
get_session_store as _get_session_store,
|
|
54
|
+
)
|
|
55
|
+
from intflow_auth.store_resolver import (
|
|
56
|
+
get_user_identity_store as _get_user_identity_store,
|
|
57
|
+
)
|
|
58
|
+
from intflow_auth.usage_metadata import usage_metadata_update_due
|
|
59
|
+
from intflow_auth.users import (
|
|
60
|
+
UserRecord,
|
|
61
|
+
UserStatus,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
PRIMARY_OPERATOR_ADMIN_TOKEN_PEPPER_SOURCE = "operator_admin_token_pepper"
|
|
65
|
+
LEGACY_REFRESH_TOKEN_PEPPER_SOURCE = "legacy_refresh_token_pepper"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class AdminSessionContext:
|
|
70
|
+
"""Authorized admin request context."""
|
|
71
|
+
|
|
72
|
+
session: SessionRecord
|
|
73
|
+
user: UserRecord
|
|
74
|
+
roles: tuple[RoleName, ...]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AdminActorCredentialType(StrEnum):
|
|
78
|
+
"""Supported admin authentication credential types."""
|
|
79
|
+
|
|
80
|
+
SESSION = "session"
|
|
81
|
+
OPERATOR_ADMIN_TOKEN = "operator_admin_token"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class AdminActorContext:
|
|
86
|
+
"""Authorized admin actor context."""
|
|
87
|
+
|
|
88
|
+
credential_type: AdminActorCredentialType
|
|
89
|
+
user: UserRecord
|
|
90
|
+
roles: tuple[RoleName, ...]
|
|
91
|
+
effective_scopes: tuple[str, ...]
|
|
92
|
+
session: SessionRecord | None = None
|
|
93
|
+
operator_admin_token: OperatorAdminTokenRecord | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def require_admin_session(request: Request) -> AdminSessionContext:
|
|
97
|
+
"""Require an authenticated active operator/admin browser session."""
|
|
98
|
+
settings = _get_settings(request)
|
|
99
|
+
session_store = _get_session_store(request, settings)
|
|
100
|
+
token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
101
|
+
if not token:
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
104
|
+
detail="Authentication required.",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
session = await validate_session_token(session_store, token=token)
|
|
109
|
+
except SessionError as exc:
|
|
110
|
+
raise HTTPException(
|
|
111
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
112
|
+
detail="Authentication required.",
|
|
113
|
+
) from exc
|
|
114
|
+
|
|
115
|
+
user = await _get_user_identity_store(request, settings).get_user(session.user_id)
|
|
116
|
+
if user is None or user.status is not UserStatus.ACTIVE:
|
|
117
|
+
await _audit_admin_access_denied(
|
|
118
|
+
request,
|
|
119
|
+
settings,
|
|
120
|
+
actor_user_id=session.user_id,
|
|
121
|
+
reason="inactive_user",
|
|
122
|
+
)
|
|
123
|
+
raise HTTPException(
|
|
124
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
125
|
+
detail="Admin access denied.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
roles = await _get_role_assignment_store(request, settings).get_role_names(user.id)
|
|
129
|
+
if not has_admin_access(roles):
|
|
130
|
+
await _audit_admin_access_denied(
|
|
131
|
+
request,
|
|
132
|
+
settings,
|
|
133
|
+
actor_user_id=user.id,
|
|
134
|
+
reason="missing_privileged_role",
|
|
135
|
+
)
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
138
|
+
detail="Admin access denied.",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return AdminSessionContext(session=session, user=user, roles=roles)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def require_admin_actor(
|
|
145
|
+
request: Request,
|
|
146
|
+
*,
|
|
147
|
+
required_scopes: Sequence[str] = (),
|
|
148
|
+
) -> AdminActorContext:
|
|
149
|
+
"""Require an authorized admin actor from bearer token or browser session."""
|
|
150
|
+
settings = _get_settings(request)
|
|
151
|
+
try:
|
|
152
|
+
bearer_token = _extract_bearer_token(request)
|
|
153
|
+
except HTTPException:
|
|
154
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
155
|
+
request,
|
|
156
|
+
settings,
|
|
157
|
+
bearer_token=None,
|
|
158
|
+
increment=True,
|
|
159
|
+
)
|
|
160
|
+
raise
|
|
161
|
+
if bearer_token is not None:
|
|
162
|
+
return await _require_operator_admin_token(
|
|
163
|
+
request,
|
|
164
|
+
settings,
|
|
165
|
+
bearer_token=bearer_token,
|
|
166
|
+
required_scopes=required_scopes,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
session_context = await require_admin_session(request)
|
|
170
|
+
return AdminActorContext(
|
|
171
|
+
credential_type=AdminActorCredentialType.SESSION,
|
|
172
|
+
user=session_context.user,
|
|
173
|
+
roles=session_context.roles,
|
|
174
|
+
effective_scopes=("admin:*",),
|
|
175
|
+
session=session_context.session,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _require_operator_admin_token(
|
|
180
|
+
request: Request,
|
|
181
|
+
settings: Settings,
|
|
182
|
+
*,
|
|
183
|
+
bearer_token: str,
|
|
184
|
+
required_scopes: Sequence[str],
|
|
185
|
+
) -> AdminActorContext:
|
|
186
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
187
|
+
request,
|
|
188
|
+
settings,
|
|
189
|
+
bearer_token=bearer_token,
|
|
190
|
+
increment=False,
|
|
191
|
+
)
|
|
192
|
+
store = _get_operator_admin_token_store(request, settings)
|
|
193
|
+
token_record, pepper_source = await _get_operator_admin_token_record(
|
|
194
|
+
store,
|
|
195
|
+
bearer_token=bearer_token,
|
|
196
|
+
settings=settings,
|
|
197
|
+
)
|
|
198
|
+
if token_record is None:
|
|
199
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
200
|
+
request,
|
|
201
|
+
settings,
|
|
202
|
+
bearer_token=bearer_token,
|
|
203
|
+
increment=True,
|
|
204
|
+
)
|
|
205
|
+
await _audit_operator_admin_token_rejected(
|
|
206
|
+
request,
|
|
207
|
+
settings,
|
|
208
|
+
actor_user_id=None,
|
|
209
|
+
token_record=None,
|
|
210
|
+
reason="unknown_token",
|
|
211
|
+
)
|
|
212
|
+
raise HTTPException(
|
|
213
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
214
|
+
detail="Authentication required.",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if token_record.status is not OperatorAdminTokenStatus.ACTIVE:
|
|
218
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
219
|
+
request,
|
|
220
|
+
settings,
|
|
221
|
+
bearer_token=bearer_token,
|
|
222
|
+
increment=True,
|
|
223
|
+
)
|
|
224
|
+
await _audit_operator_admin_token_rejected(
|
|
225
|
+
request,
|
|
226
|
+
settings,
|
|
227
|
+
actor_user_id=token_record.user_id,
|
|
228
|
+
token_record=token_record,
|
|
229
|
+
reason=f"token_{token_record.status.value}",
|
|
230
|
+
)
|
|
231
|
+
raise HTTPException(
|
|
232
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
233
|
+
detail="Authentication required.",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
now = _utc_now()
|
|
237
|
+
if operator_admin_token_is_expired(token_record, now):
|
|
238
|
+
expired = mark_operator_admin_token_expired(token_record)
|
|
239
|
+
await store.save(expired)
|
|
240
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
241
|
+
request,
|
|
242
|
+
settings,
|
|
243
|
+
bearer_token=bearer_token,
|
|
244
|
+
increment=True,
|
|
245
|
+
)
|
|
246
|
+
await _audit_operator_admin_token_rejected(
|
|
247
|
+
request,
|
|
248
|
+
settings,
|
|
249
|
+
actor_user_id=token_record.user_id,
|
|
250
|
+
token_record=expired,
|
|
251
|
+
reason="token_expired",
|
|
252
|
+
)
|
|
253
|
+
raise HTTPException(
|
|
254
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
255
|
+
detail="Authentication required.",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
user = await _get_user_identity_store(request, settings).get_user(token_record.user_id)
|
|
259
|
+
if user is None or user.status is not UserStatus.ACTIVE:
|
|
260
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
261
|
+
request,
|
|
262
|
+
settings,
|
|
263
|
+
bearer_token=bearer_token,
|
|
264
|
+
increment=True,
|
|
265
|
+
)
|
|
266
|
+
await _audit_operator_admin_token_rejected(
|
|
267
|
+
request,
|
|
268
|
+
settings,
|
|
269
|
+
actor_user_id=token_record.user_id,
|
|
270
|
+
token_record=token_record,
|
|
271
|
+
reason="inactive_user",
|
|
272
|
+
)
|
|
273
|
+
raise HTTPException(
|
|
274
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
275
|
+
detail="Admin access denied.",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
roles = await _get_role_assignment_store(request, settings).get_role_names(user.id)
|
|
279
|
+
if not has_admin_access(roles):
|
|
280
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
281
|
+
request,
|
|
282
|
+
settings,
|
|
283
|
+
bearer_token=bearer_token,
|
|
284
|
+
increment=True,
|
|
285
|
+
)
|
|
286
|
+
await _audit_operator_admin_token_rejected(
|
|
287
|
+
request,
|
|
288
|
+
settings,
|
|
289
|
+
actor_user_id=user.id,
|
|
290
|
+
token_record=token_record,
|
|
291
|
+
reason="missing_privileged_role",
|
|
292
|
+
)
|
|
293
|
+
raise HTTPException(
|
|
294
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
295
|
+
detail="Admin access denied.",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if not _has_required_scopes(token_record.scopes, required_scopes):
|
|
299
|
+
_enforce_operator_admin_token_failure_rate_limits(
|
|
300
|
+
request,
|
|
301
|
+
settings,
|
|
302
|
+
bearer_token=bearer_token,
|
|
303
|
+
increment=True,
|
|
304
|
+
)
|
|
305
|
+
await _audit_operator_admin_token_rejected(
|
|
306
|
+
request,
|
|
307
|
+
settings,
|
|
308
|
+
actor_user_id=user.id,
|
|
309
|
+
token_record=token_record,
|
|
310
|
+
reason="missing_token_scope",
|
|
311
|
+
)
|
|
312
|
+
raise HTTPException(
|
|
313
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
314
|
+
detail="Admin access denied.",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
used = token_record
|
|
318
|
+
should_update_usage = usage_metadata_update_due(token_record.last_used_at, now)
|
|
319
|
+
if should_update_usage or pepper_source == LEGACY_REFRESH_TOKEN_PEPPER_SOURCE:
|
|
320
|
+
used = mark_operator_admin_token_used(
|
|
321
|
+
token_record,
|
|
322
|
+
client_ip=_client_ip(request),
|
|
323
|
+
clock=lambda: now,
|
|
324
|
+
)
|
|
325
|
+
await store.save(used)
|
|
326
|
+
await _audit_operator_admin_token_used(
|
|
327
|
+
request,
|
|
328
|
+
settings,
|
|
329
|
+
actor_user_id=user.id,
|
|
330
|
+
token=used,
|
|
331
|
+
pepper_source=pepper_source,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return AdminActorContext(
|
|
335
|
+
credential_type=AdminActorCredentialType.OPERATOR_ADMIN_TOKEN,
|
|
336
|
+
user=user,
|
|
337
|
+
roles=roles,
|
|
338
|
+
effective_scopes=used.scopes,
|
|
339
|
+
operator_admin_token=used,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def _get_operator_admin_token_record(
|
|
344
|
+
store: OperatorAdminTokenStore,
|
|
345
|
+
*,
|
|
346
|
+
bearer_token: str,
|
|
347
|
+
settings: Settings,
|
|
348
|
+
) -> tuple[OperatorAdminTokenRecord | None, str]:
|
|
349
|
+
primary_hash = hash_operator_admin_token(
|
|
350
|
+
bearer_token,
|
|
351
|
+
pepper=settings.operator_admin_token_pepper,
|
|
352
|
+
)
|
|
353
|
+
token_record = await store.get_by_token_hash(primary_hash)
|
|
354
|
+
if token_record is not None:
|
|
355
|
+
return token_record, PRIMARY_OPERATOR_ADMIN_TOKEN_PEPPER_SOURCE
|
|
356
|
+
|
|
357
|
+
if not settings.operator_admin_token_legacy_refresh_pepper_fallback_enabled:
|
|
358
|
+
return None, PRIMARY_OPERATOR_ADMIN_TOKEN_PEPPER_SOURCE
|
|
359
|
+
|
|
360
|
+
legacy_hash = hash_operator_admin_token(
|
|
361
|
+
bearer_token,
|
|
362
|
+
pepper=settings.refresh_token_pepper,
|
|
363
|
+
)
|
|
364
|
+
if legacy_hash == primary_hash:
|
|
365
|
+
return None, PRIMARY_OPERATOR_ADMIN_TOKEN_PEPPER_SOURCE
|
|
366
|
+
|
|
367
|
+
token_record = await store.get_by_token_hash(legacy_hash)
|
|
368
|
+
if token_record is None:
|
|
369
|
+
return None, PRIMARY_OPERATOR_ADMIN_TOKEN_PEPPER_SOURCE
|
|
370
|
+
return token_record, LEGACY_REFRESH_TOKEN_PEPPER_SOURCE
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def _audit_admin_access_denied(
|
|
374
|
+
request: Request,
|
|
375
|
+
settings: Settings,
|
|
376
|
+
*,
|
|
377
|
+
actor_user_id: str | None,
|
|
378
|
+
reason: str,
|
|
379
|
+
) -> None:
|
|
380
|
+
await write_audit_event(
|
|
381
|
+
_get_audit_writer(request, settings),
|
|
382
|
+
AuditEventInput(
|
|
383
|
+
event_type=AuditEventType.ADMIN_ACCESS_DENIED,
|
|
384
|
+
result=AuditResult.DENIED,
|
|
385
|
+
actor_user_id=actor_user_id,
|
|
386
|
+
client_ip=_client_ip(request),
|
|
387
|
+
user_agent=request.headers.get("user-agent"),
|
|
388
|
+
payload={"reason": reason, "path": request.url.path},
|
|
389
|
+
),
|
|
390
|
+
fail_closed=False,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def _audit_operator_admin_token_used(
|
|
395
|
+
request: Request,
|
|
396
|
+
settings: Settings,
|
|
397
|
+
*,
|
|
398
|
+
actor_user_id: str,
|
|
399
|
+
token: OperatorAdminTokenRecord,
|
|
400
|
+
pepper_source: str,
|
|
401
|
+
) -> None:
|
|
402
|
+
payload = {
|
|
403
|
+
"token_id": token.id,
|
|
404
|
+
"token_fingerprint": token.token_fingerprint,
|
|
405
|
+
"path": request.url.path,
|
|
406
|
+
}
|
|
407
|
+
if pepper_source == LEGACY_REFRESH_TOKEN_PEPPER_SOURCE:
|
|
408
|
+
payload["legacy_hash_fallback"] = True
|
|
409
|
+
await write_audit_event(
|
|
410
|
+
_get_audit_writer(request, settings),
|
|
411
|
+
AuditEventInput(
|
|
412
|
+
event_type=AuditEventType.OPERATOR_ADMIN_TOKEN_USED,
|
|
413
|
+
result=AuditResult.SUCCESS,
|
|
414
|
+
actor_user_id=actor_user_id,
|
|
415
|
+
client_ip=_client_ip(request),
|
|
416
|
+
user_agent=request.headers.get("user-agent"),
|
|
417
|
+
payload=payload,
|
|
418
|
+
),
|
|
419
|
+
fail_closed=False,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _audit_operator_admin_token_rejected(
|
|
424
|
+
request: Request,
|
|
425
|
+
settings: Settings,
|
|
426
|
+
*,
|
|
427
|
+
actor_user_id: str | None,
|
|
428
|
+
token_record: OperatorAdminTokenRecord | None,
|
|
429
|
+
reason: str,
|
|
430
|
+
) -> None:
|
|
431
|
+
payload: dict[str, object] = {"reason": reason, "path": request.url.path}
|
|
432
|
+
if token_record is not None:
|
|
433
|
+
payload["token_id"] = token_record.id
|
|
434
|
+
payload["token_fingerprint"] = token_record.token_fingerprint
|
|
435
|
+
payload["token_status"] = token_record.status.value
|
|
436
|
+
await write_audit_event(
|
|
437
|
+
_get_audit_writer(request, settings),
|
|
438
|
+
AuditEventInput(
|
|
439
|
+
event_type=AuditEventType.OPERATOR_ADMIN_TOKEN_REJECTED,
|
|
440
|
+
result=AuditResult.DENIED,
|
|
441
|
+
actor_user_id=actor_user_id,
|
|
442
|
+
client_ip=_client_ip(request),
|
|
443
|
+
user_agent=request.headers.get("user-agent"),
|
|
444
|
+
payload=payload,
|
|
445
|
+
),
|
|
446
|
+
fail_closed=False,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _extract_bearer_token(request: Request) -> str | None:
|
|
451
|
+
authorization = request.headers.get("authorization")
|
|
452
|
+
if authorization is None:
|
|
453
|
+
return None
|
|
454
|
+
scheme, separator, credentials = authorization.partition(" ")
|
|
455
|
+
if scheme.lower() != "bearer" or not separator or not credentials.strip():
|
|
456
|
+
raise HTTPException(
|
|
457
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
458
|
+
detail="Authentication required.",
|
|
459
|
+
)
|
|
460
|
+
return credentials.strip()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _enforce_operator_admin_token_failure_rate_limits(
|
|
464
|
+
request: Request,
|
|
465
|
+
settings: Settings,
|
|
466
|
+
*,
|
|
467
|
+
bearer_token: str | None,
|
|
468
|
+
increment: bool,
|
|
469
|
+
) -> None:
|
|
470
|
+
enforce_rate_limit(
|
|
471
|
+
request,
|
|
472
|
+
settings,
|
|
473
|
+
name="admin_bearer_failure_ip",
|
|
474
|
+
limit=settings.rate_limit_admin_bearer_failure_ip_per_window,
|
|
475
|
+
subjects=(client_ip_subject(request),),
|
|
476
|
+
increment=increment,
|
|
477
|
+
)
|
|
478
|
+
if bearer_token is None:
|
|
479
|
+
return
|
|
480
|
+
enforce_rate_limit(
|
|
481
|
+
request,
|
|
482
|
+
settings,
|
|
483
|
+
name="admin_bearer_failure_token",
|
|
484
|
+
limit=settings.rate_limit_admin_bearer_failure_token_per_window,
|
|
485
|
+
subjects=(_operator_admin_token_hash_prefix_subject(settings, bearer_token),),
|
|
486
|
+
increment=increment,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _operator_admin_token_hash_prefix_subject(
|
|
491
|
+
settings: Settings,
|
|
492
|
+
bearer_token: str,
|
|
493
|
+
) -> str:
|
|
494
|
+
token_hash = hash_operator_admin_token(
|
|
495
|
+
bearer_token,
|
|
496
|
+
pepper=settings.operator_admin_token_pepper,
|
|
497
|
+
)
|
|
498
|
+
return f"operator_admin_token_hash_prefix:{token_hash[:16]}"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _has_required_scopes(
|
|
502
|
+
token_scopes: Sequence[str],
|
|
503
|
+
required_scopes: Sequence[str],
|
|
504
|
+
) -> bool:
|
|
505
|
+
if not required_scopes:
|
|
506
|
+
return True
|
|
507
|
+
scope_set = set(token_scopes)
|
|
508
|
+
return "admin:*" in scope_set or all(scope in scope_set for scope in required_scopes)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _utc_now() -> datetime:
|
|
512
|
+
return datetime.now(UTC)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API package."""
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Admin APIs for application-local role assignments."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Request, status
|
|
4
|
+
from fastapi.responses import Response
|
|
5
|
+
|
|
6
|
+
from intflow_auth.admin_auth import AdminActorContext, require_admin_actor
|
|
7
|
+
from intflow_auth.api.dependencies import (
|
|
8
|
+
client_ip,
|
|
9
|
+
get_app_role_assignment_store,
|
|
10
|
+
get_audit_writer,
|
|
11
|
+
get_oauth_client_store,
|
|
12
|
+
get_request_settings,
|
|
13
|
+
get_user_identity_store,
|
|
14
|
+
require_admin_actor_csrf,
|
|
15
|
+
)
|
|
16
|
+
from intflow_auth.api.pagination import admin_page_request
|
|
17
|
+
from intflow_auth.app_roles import (
|
|
18
|
+
AppRoleAssignmentError,
|
|
19
|
+
AppRoleAssignmentRecord,
|
|
20
|
+
AppRoleName,
|
|
21
|
+
assign_app_admin,
|
|
22
|
+
list_client_app_admins_page,
|
|
23
|
+
remove_app_admin,
|
|
24
|
+
)
|
|
25
|
+
from intflow_auth.audit import (
|
|
26
|
+
AuditEventInput,
|
|
27
|
+
AuditEventType,
|
|
28
|
+
AuditResult,
|
|
29
|
+
write_audit_event,
|
|
30
|
+
)
|
|
31
|
+
from intflow_auth.config import Settings
|
|
32
|
+
from intflow_auth.pagination import PaginationError
|
|
33
|
+
|
|
34
|
+
ADMIN_APP_ROLES_READ_SCOPE = "admin:app_roles:read"
|
|
35
|
+
ADMIN_APP_ROLES_WRITE_SCOPE = "admin:app_roles:write"
|
|
36
|
+
|
|
37
|
+
router = APIRouter(tags=["Admin Clients"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/v1/admin/clients/{client_id}/admins")
|
|
41
|
+
async def list_oauth_client_admins(
|
|
42
|
+
request: Request,
|
|
43
|
+
client_id: str,
|
|
44
|
+
limit: int | None = None,
|
|
45
|
+
cursor: str | None = None,
|
|
46
|
+
) -> dict[str, object]:
|
|
47
|
+
"""List app-admin assignments for one OAuth client."""
|
|
48
|
+
settings = get_request_settings(request)
|
|
49
|
+
await require_admin_actor(request, required_scopes=(ADMIN_APP_ROLES_READ_SCOPE,))
|
|
50
|
+
page_request = admin_page_request(limit=limit, cursor=cursor)
|
|
51
|
+
try:
|
|
52
|
+
page = await list_client_app_admins_page(
|
|
53
|
+
get_app_role_assignment_store(request, settings),
|
|
54
|
+
get_oauth_client_store(request, settings),
|
|
55
|
+
client_id=client_id,
|
|
56
|
+
page=page_request,
|
|
57
|
+
)
|
|
58
|
+
except AppRoleAssignmentError as exc:
|
|
59
|
+
raise _app_role_http_error(exc) from exc
|
|
60
|
+
except PaginationError as exc:
|
|
61
|
+
raise _bad_list_request(str(exc)) from exc
|
|
62
|
+
return {
|
|
63
|
+
"admins": [_assignment_response(assignment) for assignment in page.items],
|
|
64
|
+
"next_cursor": page.next_cursor,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.put("/v1/admin/clients/{client_id}/admins/{user_id}")
|
|
69
|
+
async def grant_oauth_client_admin(
|
|
70
|
+
request: Request,
|
|
71
|
+
client_id: str,
|
|
72
|
+
user_id: str,
|
|
73
|
+
) -> dict[str, object]:
|
|
74
|
+
"""Grant app-admin assignment for one OAuth client and internal user."""
|
|
75
|
+
settings = get_request_settings(request)
|
|
76
|
+
context = await require_admin_actor(
|
|
77
|
+
request,
|
|
78
|
+
required_scopes=(ADMIN_APP_ROLES_WRITE_SCOPE,),
|
|
79
|
+
)
|
|
80
|
+
require_admin_actor_csrf(context, request, settings)
|
|
81
|
+
try:
|
|
82
|
+
assignment, created = await assign_app_admin(
|
|
83
|
+
get_app_role_assignment_store(request, settings),
|
|
84
|
+
get_oauth_client_store(request, settings),
|
|
85
|
+
get_user_identity_store(request, settings),
|
|
86
|
+
client_id=client_id,
|
|
87
|
+
user_id=user_id,
|
|
88
|
+
assigned_by_user_id=context.user.id,
|
|
89
|
+
)
|
|
90
|
+
except AppRoleAssignmentError as exc:
|
|
91
|
+
raise _app_role_http_error(exc) from exc
|
|
92
|
+
await _audit_app_admin_change(
|
|
93
|
+
request,
|
|
94
|
+
settings,
|
|
95
|
+
context=context,
|
|
96
|
+
event_type=AuditEventType.APP_ADMIN_ASSIGNED,
|
|
97
|
+
client_id=client_id,
|
|
98
|
+
user_id=user_id,
|
|
99
|
+
changed=created,
|
|
100
|
+
assignment=assignment,
|
|
101
|
+
)
|
|
102
|
+
return {
|
|
103
|
+
"assignment": _assignment_response(assignment),
|
|
104
|
+
"created": created,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.delete(
|
|
109
|
+
"/v1/admin/clients/{client_id}/admins/{user_id}",
|
|
110
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
111
|
+
)
|
|
112
|
+
async def revoke_oauth_client_admin(
|
|
113
|
+
request: Request,
|
|
114
|
+
client_id: str,
|
|
115
|
+
user_id: str,
|
|
116
|
+
) -> Response:
|
|
117
|
+
"""Revoke app-admin assignment for one OAuth client and internal user."""
|
|
118
|
+
settings = get_request_settings(request)
|
|
119
|
+
context = await require_admin_actor(
|
|
120
|
+
request,
|
|
121
|
+
required_scopes=(ADMIN_APP_ROLES_WRITE_SCOPE,),
|
|
122
|
+
)
|
|
123
|
+
require_admin_actor_csrf(context, request, settings)
|
|
124
|
+
try:
|
|
125
|
+
changed = await remove_app_admin(
|
|
126
|
+
get_app_role_assignment_store(request, settings),
|
|
127
|
+
get_oauth_client_store(request, settings),
|
|
128
|
+
get_user_identity_store(request, settings),
|
|
129
|
+
client_id=client_id,
|
|
130
|
+
user_id=user_id,
|
|
131
|
+
)
|
|
132
|
+
except AppRoleAssignmentError as exc:
|
|
133
|
+
raise _app_role_http_error(exc) from exc
|
|
134
|
+
await _audit_app_admin_change(
|
|
135
|
+
request,
|
|
136
|
+
settings,
|
|
137
|
+
context=context,
|
|
138
|
+
event_type=AuditEventType.APP_ADMIN_REMOVED,
|
|
139
|
+
client_id=client_id,
|
|
140
|
+
user_id=user_id,
|
|
141
|
+
changed=changed,
|
|
142
|
+
)
|
|
143
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _assignment_response(assignment: AppRoleAssignmentRecord) -> dict[str, object]:
|
|
147
|
+
return {
|
|
148
|
+
"id": assignment.id,
|
|
149
|
+
"client_id": assignment.client_id,
|
|
150
|
+
"user_id": assignment.user_id,
|
|
151
|
+
"role_name": assignment.role_name.value,
|
|
152
|
+
"assigned_by_user_id": assignment.assigned_by_user_id,
|
|
153
|
+
"assigned_at": assignment.assigned_at,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _app_role_http_error(exc: AppRoleAssignmentError) -> HTTPException:
|
|
158
|
+
detail = str(exc)
|
|
159
|
+
if "not found" in detail:
|
|
160
|
+
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
|
161
|
+
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _bad_list_request(detail: str) -> HTTPException:
|
|
165
|
+
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _audit_app_admin_change(
|
|
169
|
+
request: Request,
|
|
170
|
+
settings: Settings,
|
|
171
|
+
*,
|
|
172
|
+
context: AdminActorContext,
|
|
173
|
+
event_type: AuditEventType,
|
|
174
|
+
client_id: str,
|
|
175
|
+
user_id: str,
|
|
176
|
+
changed: bool,
|
|
177
|
+
assignment: AppRoleAssignmentRecord | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
payload: dict[str, object] = {
|
|
180
|
+
"role_name": AppRoleName.ADMIN.value,
|
|
181
|
+
"changed": changed,
|
|
182
|
+
}
|
|
183
|
+
if assignment is not None:
|
|
184
|
+
payload["assignment_id"] = assignment.id
|
|
185
|
+
await write_audit_event(
|
|
186
|
+
get_audit_writer(request, settings),
|
|
187
|
+
AuditEventInput(
|
|
188
|
+
event_type=event_type,
|
|
189
|
+
result=AuditResult.SUCCESS,
|
|
190
|
+
actor_user_id=context.user.id,
|
|
191
|
+
target_user_id=user_id,
|
|
192
|
+
client_id=client_id,
|
|
193
|
+
client_ip=client_ip(request),
|
|
194
|
+
user_agent=request.headers.get("user-agent"),
|
|
195
|
+
payload=payload,
|
|
196
|
+
),
|
|
197
|
+
)
|