paskia 0.7.1__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 (64) hide show
  1. paskia/__init__.py +3 -0
  2. paskia/_version.py +34 -0
  3. paskia/aaguid/__init__.py +32 -0
  4. paskia/aaguid/combined_aaguid.json +1 -0
  5. paskia/authsession.py +112 -0
  6. paskia/bootstrap.py +190 -0
  7. paskia/config.py +25 -0
  8. paskia/db/__init__.py +415 -0
  9. paskia/db/sql.py +1424 -0
  10. paskia/fastapi/__init__.py +3 -0
  11. paskia/fastapi/__main__.py +335 -0
  12. paskia/fastapi/admin.py +850 -0
  13. paskia/fastapi/api.py +308 -0
  14. paskia/fastapi/auth_host.py +97 -0
  15. paskia/fastapi/authz.py +110 -0
  16. paskia/fastapi/mainapp.py +130 -0
  17. paskia/fastapi/remote.py +504 -0
  18. paskia/fastapi/reset.py +101 -0
  19. paskia/fastapi/session.py +52 -0
  20. paskia/fastapi/user.py +162 -0
  21. paskia/fastapi/ws.py +163 -0
  22. paskia/fastapi/wsutil.py +91 -0
  23. paskia/frontend-build/auth/admin/index.html +18 -0
  24. paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css +1 -0
  25. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +8 -0
  26. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +1 -0
  27. paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css +1 -0
  28. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +1 -0
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js +2 -0
  30. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +1 -0
  32. paskia/frontend-build/auth/assets/auth-BU_O38k2.css +1 -0
  33. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +1 -0
  34. paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js +1 -0
  35. paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +1 -0
  36. paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +1 -0
  37. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +1 -0
  38. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +1 -0
  39. paskia/frontend-build/auth/assets/restricted-C0IQufuH.js +1 -0
  40. paskia/frontend-build/auth/index.html +19 -0
  41. paskia/frontend-build/auth/restricted/index.html +16 -0
  42. paskia/frontend-build/int/forward/index.html +18 -0
  43. paskia/frontend-build/int/reset/index.html +15 -0
  44. paskia/globals.py +71 -0
  45. paskia/remoteauth.py +359 -0
  46. paskia/sansio.py +263 -0
  47. paskia/util/frontend.py +75 -0
  48. paskia/util/hostutil.py +76 -0
  49. paskia/util/htmlutil.py +47 -0
  50. paskia/util/passphrase.py +20 -0
  51. paskia/util/permutil.py +32 -0
  52. paskia/util/pow.py +45 -0
  53. paskia/util/querysafe.py +11 -0
  54. paskia/util/sessionutil.py +37 -0
  55. paskia/util/startupbox.py +75 -0
  56. paskia/util/timeutil.py +47 -0
  57. paskia/util/tokens.py +44 -0
  58. paskia/util/useragent.py +10 -0
  59. paskia/util/userinfo.py +159 -0
  60. paskia/util/wordlist.py +54 -0
  61. paskia-0.7.1.dist-info/METADATA +22 -0
  62. paskia-0.7.1.dist-info/RECORD +64 -0
  63. paskia-0.7.1.dist-info/WHEEL +4 -0
  64. paskia-0.7.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,850 @@
1
+ import logging
2
+ from datetime import timezone
3
+ from uuid import UUID, uuid4
4
+
5
+ from fastapi import Body, FastAPI, HTTPException, Request, Response
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from paskia.authsession import reset_expires
9
+ from paskia.fastapi import authz
10
+ from paskia.fastapi.session import AUTH_COOKIE
11
+ from paskia.globals import db
12
+ from paskia.util import (
13
+ frontend,
14
+ hostutil,
15
+ passphrase,
16
+ permutil,
17
+ querysafe,
18
+ tokens,
19
+ useragent,
20
+ )
21
+ from paskia.util.tokens import encode_session_key, session_key
22
+
23
+ app = FastAPI()
24
+
25
+
26
+ @app.exception_handler(ValueError)
27
+ async def value_error_handler(_request, exc: ValueError): # pragma: no cover - simple
28
+ return JSONResponse(status_code=400, content={"detail": str(exc)})
29
+
30
+
31
+ @app.exception_handler(authz.AuthException)
32
+ async def auth_exception_handler(_request, exc: authz.AuthException):
33
+ """Handle AuthException with auth info for UI."""
34
+ return JSONResponse(
35
+ status_code=exc.status_code,
36
+ content=await authz.auth_error_content(exc),
37
+ )
38
+
39
+
40
+ @app.exception_handler(Exception)
41
+ async def general_exception_handler(_request, exc: Exception): # pragma: no cover
42
+ logging.exception("Unhandled exception in admin app")
43
+ return JSONResponse(status_code=500, content={"detail": "Internal server error"})
44
+
45
+
46
+ @app.get("/")
47
+ async def adminapp(request: Request, auth=AUTH_COOKIE):
48
+ return Response(*await frontend.read("/auth/admin/index.html"))
49
+
50
+
51
+ # -------------------- Organizations --------------------
52
+
53
+
54
+ @app.get("/orgs")
55
+ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
56
+ ctx = await authz.verify(
57
+ auth,
58
+ ["auth:admin", "auth:org:*"],
59
+ match=permutil.has_any,
60
+ host=request.headers.get("host"),
61
+ )
62
+ orgs = await db.instance.list_organizations()
63
+ if "auth:admin" not in ctx.role.permissions:
64
+ orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
65
+
66
+ def role_to_dict(r):
67
+ return {
68
+ "uuid": str(r.uuid),
69
+ "org_uuid": str(r.org_uuid),
70
+ "display_name": r.display_name,
71
+ "permissions": r.permissions,
72
+ }
73
+
74
+ async def org_to_dict(o):
75
+ users = await db.instance.get_organization_users(str(o.uuid))
76
+ return {
77
+ "uuid": str(o.uuid),
78
+ "display_name": o.display_name,
79
+ "permissions": o.permissions,
80
+ "roles": [role_to_dict(r) for r in o.roles],
81
+ "users": [
82
+ {
83
+ "uuid": str(u.uuid),
84
+ "display_name": u.display_name,
85
+ "role": role_name,
86
+ "visits": u.visits,
87
+ "last_seen": u.last_seen.isoformat() if u.last_seen else None,
88
+ }
89
+ for (u, role_name) in users
90
+ ],
91
+ }
92
+
93
+ return [await org_to_dict(o) for o in orgs]
94
+
95
+
96
+ @app.post("/orgs")
97
+ async def admin_create_org(
98
+ request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
99
+ ):
100
+ await authz.verify(
101
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
102
+ )
103
+ from ..db import Org as OrgDC # local import to avoid cycles
104
+ from ..db import Role as RoleDC # local import to avoid cycles
105
+
106
+ org_uuid = uuid4()
107
+ display_name = payload.get("display_name") or "New Organization"
108
+ permissions = payload.get("permissions") or []
109
+ org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
110
+ await db.instance.create_organization(org)
111
+
112
+ # Automatically create Administration role with org admin permission
113
+ role_uuid = uuid4()
114
+ admin_role = RoleDC(
115
+ uuid=role_uuid,
116
+ org_uuid=org_uuid,
117
+ display_name="Administration",
118
+ permissions=[f"auth:org:{org_uuid}"],
119
+ )
120
+ await db.instance.create_role(admin_role)
121
+
122
+ return {"uuid": str(org_uuid)}
123
+
124
+
125
+ @app.put("/orgs/{org_uuid}")
126
+ async def admin_update_org(
127
+ org_uuid: UUID,
128
+ request: Request,
129
+ payload: dict = Body(...),
130
+ auth=AUTH_COOKIE,
131
+ ):
132
+ ctx = await authz.verify(
133
+ auth,
134
+ ["auth:admin", f"auth:org:{org_uuid}"],
135
+ match=permutil.has_any,
136
+ host=request.headers.get("host"),
137
+ )
138
+ from ..db import Org as OrgDC # local import to avoid cycles
139
+
140
+ current = await db.instance.get_organization(str(org_uuid))
141
+ display_name = payload.get("display_name") or current.display_name
142
+ permissions = payload.get("permissions")
143
+ if permissions is None:
144
+ permissions = current.permissions or []
145
+
146
+ # Sanity check: prevent removing permissions that would break current user's admin access
147
+ org_admin_perm = f"auth:org:{org_uuid}"
148
+
149
+ # If current user is org admin (not global admin), ensure org admin perm remains
150
+ if (
151
+ "auth:admin" not in ctx.role.permissions
152
+ and f"auth:org:{org_uuid}" in ctx.role.permissions
153
+ ):
154
+ if org_admin_perm not in permissions:
155
+ raise ValueError(
156
+ "Cannot remove organization admin permission from your own organization"
157
+ )
158
+
159
+ org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
160
+ await db.instance.update_organization(org)
161
+ return {"status": "ok"}
162
+
163
+
164
+ @app.delete("/orgs/{org_uuid}")
165
+ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
166
+ ctx = await authz.verify(
167
+ auth,
168
+ ["auth:admin", f"auth:org:{org_uuid}"],
169
+ match=permutil.has_any,
170
+ host=request.headers.get("host"),
171
+ max_age="5m",
172
+ )
173
+ if ctx.org.uuid == org_uuid:
174
+ raise ValueError("Cannot delete the organization you belong to")
175
+
176
+ # Delete organization-specific permissions
177
+ org_perm_pattern = f"org:{str(org_uuid).lower()}"
178
+ all_permissions = await db.instance.list_permissions()
179
+ for perm in all_permissions:
180
+ perm_id_lower = perm.id.lower()
181
+ # Check if permission contains "org:{uuid}" separated by colons or at boundaries
182
+ if (
183
+ f":{org_perm_pattern}:" in perm_id_lower
184
+ or perm_id_lower.startswith(f"{org_perm_pattern}:")
185
+ or perm_id_lower.endswith(f":{org_perm_pattern}")
186
+ or perm_id_lower == org_perm_pattern
187
+ ):
188
+ await db.instance.delete_permission(perm.id)
189
+
190
+ await db.instance.delete_organization(org_uuid)
191
+ return {"status": "ok"}
192
+
193
+
194
+ @app.post("/orgs/{org_uuid}/permission")
195
+ async def admin_add_org_permission(
196
+ org_uuid: UUID,
197
+ permission_id: str,
198
+ request: Request,
199
+ auth=AUTH_COOKIE,
200
+ ):
201
+ await authz.verify(
202
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
203
+ )
204
+ await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
205
+ return {"status": "ok"}
206
+
207
+
208
+ @app.delete("/orgs/{org_uuid}/permission")
209
+ async def admin_remove_org_permission(
210
+ org_uuid: UUID,
211
+ permission_id: str,
212
+ request: Request,
213
+ auth=AUTH_COOKIE,
214
+ ):
215
+ await authz.verify(
216
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
217
+ )
218
+ await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
219
+ return {"status": "ok"}
220
+
221
+
222
+ # -------------------- Roles --------------------
223
+
224
+
225
+ @app.post("/orgs/{org_uuid}/roles")
226
+ async def admin_create_role(
227
+ org_uuid: UUID,
228
+ request: Request,
229
+ payload: dict = Body(...),
230
+ auth=AUTH_COOKIE,
231
+ ):
232
+ await authz.verify(
233
+ auth,
234
+ ["auth:admin", f"auth:org:{org_uuid}"],
235
+ match=permutil.has_any,
236
+ host=request.headers.get("host"),
237
+ )
238
+ from ..db import Role as RoleDC
239
+
240
+ role_uuid = uuid4()
241
+ display_name = payload.get("display_name") or "New Role"
242
+ perms = payload.get("permissions") or []
243
+ org = await db.instance.get_organization(str(org_uuid))
244
+ grantable = set(org.permissions or [])
245
+ for pid in perms:
246
+ await db.instance.get_permission(pid)
247
+ if pid not in grantable:
248
+ raise ValueError(f"Permission not grantable by org: {pid}")
249
+ role = RoleDC(
250
+ uuid=role_uuid,
251
+ org_uuid=org_uuid,
252
+ display_name=display_name,
253
+ permissions=perms,
254
+ )
255
+ await db.instance.create_role(role)
256
+ return {"uuid": str(role_uuid)}
257
+
258
+
259
+ @app.put("/orgs/{org_uuid}/roles/{role_uuid}")
260
+ async def admin_update_role(
261
+ org_uuid: UUID,
262
+ role_uuid: UUID,
263
+ request: Request,
264
+ payload: dict = Body(...),
265
+ auth=AUTH_COOKIE,
266
+ ):
267
+ # Verify caller is global admin or admin of provided org
268
+ ctx = await authz.verify(
269
+ auth,
270
+ ["auth:admin", f"auth:org:{org_uuid}"],
271
+ match=permutil.has_any,
272
+ host=request.headers.get("host"),
273
+ )
274
+ role = await db.instance.get_role(role_uuid)
275
+ if role.org_uuid != org_uuid:
276
+ raise HTTPException(status_code=404, detail="Role not found in organization")
277
+ from ..db import Role as RoleDC
278
+
279
+ display_name = payload.get("display_name") or role.display_name
280
+ permissions = payload.get("permissions")
281
+ if permissions is None:
282
+ permissions = role.permissions
283
+ org = await db.instance.get_organization(str(org_uuid))
284
+ grantable = set(org.permissions or [])
285
+ existing_permissions = set(role.permissions)
286
+ for pid in permissions:
287
+ await db.instance.get_permission(pid)
288
+ if pid not in existing_permissions and pid not in grantable:
289
+ raise ValueError(f"Permission not grantable by org: {pid}")
290
+
291
+ # Sanity check: prevent admin from removing their own access via role update
292
+ if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
293
+ has_admin_access = (
294
+ "auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions
295
+ )
296
+ if not has_admin_access:
297
+ raise ValueError("Cannot update your own role to remove admin permissions")
298
+
299
+ updated = RoleDC(
300
+ uuid=role_uuid,
301
+ org_uuid=org_uuid,
302
+ display_name=display_name,
303
+ permissions=permissions,
304
+ )
305
+ await db.instance.update_role(updated)
306
+ return {"status": "ok"}
307
+
308
+
309
+ @app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
310
+ async def admin_delete_role(
311
+ org_uuid: UUID,
312
+ role_uuid: UUID,
313
+ request: Request,
314
+ auth=AUTH_COOKIE,
315
+ ):
316
+ ctx = await authz.verify(
317
+ auth,
318
+ ["auth:admin", f"auth:org:{org_uuid}"],
319
+ match=permutil.has_any,
320
+ host=request.headers.get("host"),
321
+ max_age="5m",
322
+ )
323
+ role = await db.instance.get_role(role_uuid)
324
+ if role.org_uuid != org_uuid:
325
+ raise HTTPException(status_code=404, detail="Role not found in organization")
326
+
327
+ # Sanity check: prevent admin from deleting their own role
328
+ if ctx.role.uuid == role_uuid:
329
+ raise ValueError("Cannot delete your own role")
330
+
331
+ await db.instance.delete_role(role_uuid)
332
+ return {"status": "ok"}
333
+
334
+
335
+ # -------------------- Users --------------------
336
+
337
+
338
+ @app.post("/orgs/{org_uuid}/users")
339
+ async def admin_create_user(
340
+ org_uuid: UUID,
341
+ request: Request,
342
+ payload: dict = Body(...),
343
+ auth=AUTH_COOKIE,
344
+ ):
345
+ await authz.verify(
346
+ auth,
347
+ ["auth:admin", f"auth:org:{org_uuid}"],
348
+ match=permutil.has_any,
349
+ host=request.headers.get("host"),
350
+ )
351
+ display_name = payload.get("display_name")
352
+ role_name = payload.get("role")
353
+ if not display_name or not role_name:
354
+ raise ValueError("display_name and role are required")
355
+ from ..db import User as UserDC
356
+
357
+ roles = await db.instance.get_roles_by_organization(str(org_uuid))
358
+ role_obj = next((r for r in roles if r.display_name == role_name), None)
359
+ if not role_obj:
360
+ raise ValueError("Role not found in organization")
361
+ user_uuid = uuid4()
362
+ user = UserDC(
363
+ uuid=user_uuid,
364
+ display_name=display_name,
365
+ role_uuid=role_obj.uuid,
366
+ visits=0,
367
+ created_at=None,
368
+ )
369
+ await db.instance.create_user(user)
370
+ return {"uuid": str(user_uuid)}
371
+
372
+
373
+ @app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
374
+ async def admin_update_user_role(
375
+ org_uuid: UUID,
376
+ user_uuid: UUID,
377
+ request: Request,
378
+ payload: dict = Body(...),
379
+ auth=AUTH_COOKIE,
380
+ ):
381
+ ctx = await authz.verify(
382
+ auth,
383
+ ["auth:admin", f"auth:org:{org_uuid}"],
384
+ match=permutil.has_any,
385
+ host=request.headers.get("host"),
386
+ )
387
+ new_role = payload.get("role")
388
+ if not new_role:
389
+ raise ValueError("role is required")
390
+ try:
391
+ user_org, _current_role = await db.instance.get_user_organization(user_uuid)
392
+ except ValueError:
393
+ raise ValueError("User not found")
394
+ if user_org.uuid != org_uuid:
395
+ raise ValueError("User does not belong to this organization")
396
+ roles = await db.instance.get_roles_by_organization(str(org_uuid))
397
+ if not any(r.display_name == new_role for r in roles):
398
+ raise ValueError("Role not found in organization")
399
+
400
+ # Sanity check: prevent admin from removing their own access
401
+ if ctx.user.uuid == user_uuid:
402
+ new_role_obj = next((r for r in roles if r.display_name == new_role), None)
403
+ if new_role_obj: # pragma: no branch - always true, role validated above
404
+ has_admin_access = (
405
+ "auth:admin" in new_role_obj.permissions
406
+ or f"auth:org:{org_uuid}" in new_role_obj.permissions
407
+ )
408
+ if not has_admin_access:
409
+ raise ValueError(
410
+ "Cannot change your own role to one without admin permissions"
411
+ )
412
+
413
+ await db.instance.update_user_role_in_organization(user_uuid, new_role)
414
+ return {"status": "ok"}
415
+
416
+
417
+ @app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
418
+ async def admin_create_user_registration_link(
419
+ org_uuid: UUID,
420
+ user_uuid: UUID,
421
+ request: Request,
422
+ auth=AUTH_COOKIE,
423
+ ):
424
+ try:
425
+ user_org, _role_name = await db.instance.get_user_organization(user_uuid)
426
+ except ValueError:
427
+ raise HTTPException(status_code=404, detail="User not found")
428
+ if user_org.uuid != org_uuid:
429
+ raise HTTPException(status_code=404, detail="User not found in organization")
430
+ ctx = await authz.verify(
431
+ auth,
432
+ ["auth:admin", f"auth:org:{org_uuid}"],
433
+ match=permutil.has_any,
434
+ host=request.headers.get("host"),
435
+ max_age="5m",
436
+ )
437
+ if ( # pragma: no cover - defense in depth, authz.verify already checked
438
+ "auth:admin" not in ctx.role.permissions
439
+ and f"auth:org:{org_uuid}" not in ctx.role.permissions
440
+ ):
441
+ raise authz.AuthException(
442
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
443
+ )
444
+
445
+ # Check if user has existing credentials
446
+ credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
447
+ token_type = "user registration" if not credentials else "account recovery"
448
+
449
+ token = passphrase.generate()
450
+ expiry = reset_expires()
451
+ await db.instance.create_reset_token(
452
+ user_uuid=user_uuid,
453
+ key=tokens.reset_key(token),
454
+ expiry=expiry,
455
+ token_type=token_type,
456
+ )
457
+ url = hostutil.reset_link_url(token)
458
+ return {
459
+ "url": url,
460
+ "expires": (
461
+ expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
462
+ if expiry.tzinfo
463
+ else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
464
+ ),
465
+ }
466
+
467
+
468
+ @app.get("/orgs/{org_uuid}/users/{user_uuid}")
469
+ async def admin_get_user_detail(
470
+ org_uuid: UUID,
471
+ user_uuid: UUID,
472
+ request: Request,
473
+ auth=AUTH_COOKIE,
474
+ ):
475
+ try:
476
+ user_org, role_name = await db.instance.get_user_organization(user_uuid)
477
+ except ValueError:
478
+ raise HTTPException(status_code=404, detail="User not found")
479
+ if user_org.uuid != org_uuid:
480
+ raise HTTPException(status_code=404, detail="User not found in organization")
481
+ ctx = await authz.verify(
482
+ auth,
483
+ ["auth:admin", f"auth:org:{org_uuid}"],
484
+ match=permutil.has_any,
485
+ host=request.headers.get("host"),
486
+ )
487
+ if ( # pragma: no cover - defense in depth, authz.verify already checked
488
+ "auth:admin" not in ctx.role.permissions
489
+ and f"auth:org:{org_uuid}" not in ctx.role.permissions
490
+ ):
491
+ raise authz.AuthException(
492
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
493
+ )
494
+ user = await db.instance.get_user_by_uuid(user_uuid)
495
+ cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
496
+ creds: list[dict] = []
497
+ aaguids: set[str] = set()
498
+ for cid in cred_ids:
499
+ try:
500
+ c = await db.instance.get_credential_by_id(cid)
501
+ except ValueError: # pragma: no cover - race condition handling
502
+ continue
503
+ aaguid_str = str(c.aaguid)
504
+ aaguids.add(aaguid_str)
505
+ creds.append(
506
+ {
507
+ "credential_uuid": str(c.uuid),
508
+ "aaguid": aaguid_str,
509
+ "created_at": (
510
+ c.created_at.astimezone(timezone.utc)
511
+ .isoformat()
512
+ .replace("+00:00", "Z")
513
+ if c.created_at.tzinfo
514
+ else c.created_at.replace(tzinfo=timezone.utc)
515
+ .isoformat()
516
+ .replace("+00:00", "Z")
517
+ ),
518
+ "last_used": (
519
+ c.last_used.astimezone(timezone.utc)
520
+ .isoformat()
521
+ .replace("+00:00", "Z")
522
+ if c.last_used and c.last_used.tzinfo
523
+ else (
524
+ c.last_used.replace(tzinfo=timezone.utc)
525
+ .isoformat()
526
+ .replace("+00:00", "Z")
527
+ if c.last_used
528
+ else None
529
+ )
530
+ ),
531
+ "last_verified": (
532
+ c.last_verified.astimezone(timezone.utc)
533
+ .isoformat()
534
+ .replace("+00:00", "Z")
535
+ if c.last_verified and c.last_verified.tzinfo
536
+ else (
537
+ c.last_verified.replace(tzinfo=timezone.utc)
538
+ .isoformat()
539
+ .replace("+00:00", "Z")
540
+ if c.last_verified
541
+ else None
542
+ )
543
+ )
544
+ if c.last_verified
545
+ else None,
546
+ "sign_count": c.sign_count,
547
+ }
548
+ )
549
+ from .. import aaguid as aaguid_mod
550
+
551
+ aaguid_info = aaguid_mod.filter(aaguids)
552
+
553
+ # Get sessions for the user
554
+ normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
555
+ session_records = await db.instance.list_sessions_for_user(user_uuid)
556
+ current_session_key = session_key(auth)
557
+ sessions_payload: list[dict] = []
558
+ for entry in session_records:
559
+ sessions_payload.append(
560
+ {
561
+ "id": encode_session_key(entry.key),
562
+ "credential_uuid": str(entry.credential_uuid),
563
+ "host": entry.host,
564
+ "ip": entry.ip,
565
+ "user_agent": useragent.compact_user_agent(entry.user_agent),
566
+ "last_renewed": (
567
+ entry.renewed.astimezone(timezone.utc)
568
+ .isoformat()
569
+ .replace("+00:00", "Z")
570
+ if entry.renewed.tzinfo
571
+ else entry.renewed.replace(tzinfo=timezone.utc)
572
+ .isoformat()
573
+ .replace("+00:00", "Z")
574
+ ),
575
+ "is_current": entry.key == current_session_key,
576
+ "is_current_host": bool(
577
+ normalized_request_host
578
+ and entry.host
579
+ and entry.host == normalized_request_host
580
+ ),
581
+ }
582
+ )
583
+
584
+ return {
585
+ "display_name": user.display_name,
586
+ "org": {"display_name": user_org.display_name},
587
+ "role": role_name,
588
+ "visits": user.visits,
589
+ "created_at": (
590
+ user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
591
+ if user.created_at and user.created_at.tzinfo
592
+ else (
593
+ user.created_at.replace(tzinfo=timezone.utc)
594
+ .isoformat()
595
+ .replace("+00:00", "Z")
596
+ if user.created_at
597
+ else None
598
+ )
599
+ ),
600
+ "last_seen": (
601
+ user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
602
+ if user.last_seen and user.last_seen.tzinfo
603
+ else (
604
+ user.last_seen.replace(tzinfo=timezone.utc)
605
+ .isoformat()
606
+ .replace("+00:00", "Z")
607
+ if user.last_seen
608
+ else None
609
+ )
610
+ ),
611
+ "credentials": creds,
612
+ "aaguid_info": aaguid_info,
613
+ "sessions": sessions_payload,
614
+ }
615
+
616
+
617
+ @app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
618
+ async def admin_update_user_display_name(
619
+ org_uuid: UUID,
620
+ user_uuid: UUID,
621
+ request: Request,
622
+ payload: dict = Body(...),
623
+ auth=AUTH_COOKIE,
624
+ ):
625
+ try:
626
+ user_org, _role_name = await db.instance.get_user_organization(user_uuid)
627
+ except ValueError:
628
+ raise HTTPException(status_code=404, detail="User not found")
629
+ if user_org.uuid != org_uuid:
630
+ raise HTTPException(status_code=404, detail="User not found in organization")
631
+ ctx = await authz.verify(
632
+ auth,
633
+ ["auth:admin", f"auth:org:{org_uuid}"],
634
+ match=permutil.has_any,
635
+ host=request.headers.get("host"),
636
+ )
637
+ if ( # pragma: no cover - defense in depth, authz.verify already checked
638
+ "auth:admin" not in ctx.role.permissions
639
+ and f"auth:org:{org_uuid}" not in ctx.role.permissions
640
+ ):
641
+ raise authz.AuthException(
642
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
643
+ )
644
+ new_name = (payload.get("display_name") or "").strip()
645
+ if not new_name:
646
+ raise HTTPException(status_code=400, detail="display_name required")
647
+ if len(new_name) > 64:
648
+ raise HTTPException(status_code=400, detail="display_name too long")
649
+ await db.instance.update_user_display_name(user_uuid, new_name)
650
+ return {"status": "ok"}
651
+
652
+
653
+ @app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
654
+ async def admin_delete_user_credential(
655
+ org_uuid: UUID,
656
+ user_uuid: UUID,
657
+ credential_uuid: UUID,
658
+ request: Request,
659
+ auth=AUTH_COOKIE,
660
+ ):
661
+ try:
662
+ user_org, _role_name = await db.instance.get_user_organization(user_uuid)
663
+ except ValueError:
664
+ raise HTTPException(status_code=404, detail="User not found")
665
+ if user_org.uuid != org_uuid:
666
+ raise HTTPException(status_code=404, detail="User not found in organization")
667
+ ctx = await authz.verify(
668
+ auth,
669
+ ["auth:admin", f"auth:org:{org_uuid}"],
670
+ match=permutil.has_any,
671
+ host=request.headers.get("host"),
672
+ max_age="5m",
673
+ )
674
+ if ( # pragma: no cover - defense in depth, authz.verify already checked
675
+ "auth:admin" not in ctx.role.permissions
676
+ and f"auth:org:{org_uuid}" not in ctx.role.permissions
677
+ ):
678
+ raise authz.AuthException(
679
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
680
+ )
681
+ await db.instance.delete_credential(credential_uuid, user_uuid)
682
+ return {"status": "ok"}
683
+
684
+
685
+ @app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
686
+ async def admin_delete_user_session(
687
+ org_uuid: UUID,
688
+ user_uuid: UUID,
689
+ session_id: str,
690
+ request: Request,
691
+ auth=AUTH_COOKIE,
692
+ ):
693
+ try:
694
+ user_org, _role_name = await db.instance.get_user_organization(user_uuid)
695
+ except ValueError:
696
+ raise HTTPException(status_code=404, detail="User not found")
697
+ if user_org.uuid != org_uuid:
698
+ raise HTTPException(status_code=404, detail="User not found in organization")
699
+ ctx = await authz.verify(
700
+ auth,
701
+ ["auth:admin", f"auth:org:{org_uuid}"],
702
+ match=permutil.has_any,
703
+ host=request.headers.get("host"),
704
+ )
705
+ if ( # pragma: no cover - defense in depth, authz.verify already checked
706
+ "auth:admin" not in ctx.role.permissions
707
+ and f"auth:org:{org_uuid}" not in ctx.role.permissions
708
+ ):
709
+ raise authz.AuthException(
710
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
711
+ )
712
+
713
+ try:
714
+ target_key = tokens.decode_session_key(session_id)
715
+ except ValueError as exc:
716
+ raise HTTPException(
717
+ status_code=400, detail="Invalid session identifier"
718
+ ) from exc
719
+
720
+ target_session = await db.instance.get_session(target_key)
721
+ if not target_session or target_session.user_uuid != user_uuid:
722
+ raise HTTPException(status_code=404, detail="Session not found")
723
+
724
+ await db.instance.delete_session(target_key)
725
+
726
+ # Check if admin terminated their own session
727
+ current_terminated = target_key == session_key(auth)
728
+ return {"status": "ok", "current_session_terminated": current_terminated}
729
+
730
+
731
+ # -------------------- Permissions (global) --------------------
732
+
733
+
734
+ @app.get("/permissions")
735
+ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
736
+ ctx = await authz.verify(
737
+ auth,
738
+ ["auth:admin", "auth:org:*"],
739
+ match=permutil.has_any,
740
+ host=request.headers.get("host"),
741
+ )
742
+ perms = await db.instance.list_permissions()
743
+
744
+ # Global admins see all permissions
745
+ if "auth:admin" in ctx.role.permissions:
746
+ return [{"id": p.id, "display_name": p.display_name} for p in perms]
747
+
748
+ # Org admins only see permissions their org can grant
749
+ grantable = set(ctx.org.permissions or [])
750
+ filtered_perms = [p for p in perms if p.id in grantable]
751
+ return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms]
752
+
753
+
754
+ @app.post("/permissions")
755
+ async def admin_create_permission(
756
+ request: Request,
757
+ payload: dict = Body(...),
758
+ auth=AUTH_COOKIE,
759
+ ):
760
+ await authz.verify(
761
+ auth,
762
+ ["auth:admin"],
763
+ host=request.headers.get("host"),
764
+ match=permutil.has_all,
765
+ max_age="5m",
766
+ )
767
+ from ..db import Permission as PermDC
768
+
769
+ perm_id = payload.get("id")
770
+ display_name = payload.get("display_name")
771
+ if not perm_id or not display_name:
772
+ raise ValueError("id and display_name are required")
773
+ querysafe.assert_safe(perm_id, field="id")
774
+ await db.instance.create_permission(PermDC(id=perm_id, display_name=display_name))
775
+ return {"status": "ok"}
776
+
777
+
778
+ @app.put("/permission")
779
+ async def admin_update_permission(
780
+ permission_id: str,
781
+ display_name: str,
782
+ request: Request,
783
+ auth=AUTH_COOKIE,
784
+ ):
785
+ await authz.verify(
786
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
787
+ )
788
+ from ..db import Permission as PermDC
789
+
790
+ if not display_name:
791
+ raise ValueError("display_name is required")
792
+ querysafe.assert_safe(permission_id, field="permission_id")
793
+ await db.instance.update_permission(
794
+ PermDC(id=permission_id, display_name=display_name)
795
+ )
796
+ return {"status": "ok"}
797
+
798
+
799
+ @app.post("/permission/rename")
800
+ async def admin_rename_permission(
801
+ request: Request,
802
+ payload: dict = Body(...),
803
+ auth=AUTH_COOKIE,
804
+ ):
805
+ await authz.verify(
806
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
807
+ )
808
+ old_id = payload.get("old_id")
809
+ new_id = payload.get("new_id")
810
+ display_name = payload.get("display_name")
811
+ if not old_id or not new_id:
812
+ raise ValueError("old_id and new_id required")
813
+
814
+ # Sanity check: prevent renaming critical permissions
815
+ if old_id == "auth:admin":
816
+ raise ValueError("Cannot rename the master admin permission")
817
+
818
+ querysafe.assert_safe(old_id, field="old_id")
819
+ querysafe.assert_safe(new_id, field="new_id")
820
+ if display_name is None:
821
+ perm = await db.instance.get_permission(old_id)
822
+ display_name = perm.display_name
823
+ rename_fn = getattr(db.instance, "rename_permission", None)
824
+ if not rename_fn: # pragma: no cover - all current backends support rename
825
+ raise ValueError("Permission renaming not supported by this backend")
826
+ await rename_fn(old_id, new_id, display_name)
827
+ return {"status": "ok"}
828
+
829
+
830
+ @app.delete("/permission")
831
+ async def admin_delete_permission(
832
+ permission_id: str,
833
+ request: Request,
834
+ auth=AUTH_COOKIE,
835
+ ):
836
+ await authz.verify(
837
+ auth,
838
+ ["auth:admin"],
839
+ host=request.headers.get("host"),
840
+ match=permutil.has_all,
841
+ max_age="5m",
842
+ )
843
+ querysafe.assert_safe(permission_id, field="permission_id")
844
+
845
+ # Sanity check: prevent deleting critical permissions
846
+ if permission_id == "auth:admin":
847
+ raise ValueError("Cannot delete the master admin permission")
848
+
849
+ await db.instance.delete_permission(permission_id)
850
+ return {"status": "ok"}