paskia 0.8.1__py3-none-any.whl → 0.9.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 (58) hide show
  1. paskia/_version.py +2 -2
  2. paskia/aaguid/__init__.py +5 -4
  3. paskia/authsession.py +15 -43
  4. paskia/bootstrap.py +31 -103
  5. paskia/db/__init__.py +27 -55
  6. paskia/db/background.py +20 -40
  7. paskia/db/jsonl.py +196 -46
  8. paskia/db/logging.py +233 -0
  9. paskia/db/migrations.py +33 -0
  10. paskia/db/operations.py +409 -825
  11. paskia/db/structs.py +408 -94
  12. paskia/fastapi/__main__.py +25 -28
  13. paskia/fastapi/admin.py +147 -329
  14. paskia/fastapi/api.py +68 -110
  15. paskia/fastapi/logging.py +218 -0
  16. paskia/fastapi/mainapp.py +25 -8
  17. paskia/fastapi/remote.py +16 -39
  18. paskia/fastapi/reset.py +27 -19
  19. paskia/fastapi/response.py +22 -0
  20. paskia/fastapi/session.py +2 -2
  21. paskia/fastapi/user.py +24 -30
  22. paskia/fastapi/ws.py +25 -60
  23. paskia/fastapi/wschat.py +62 -0
  24. paskia/fastapi/wsutil.py +15 -2
  25. paskia/frontend-build/auth/admin/index.html +5 -5
  26. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  27. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  28. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  29. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  30. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  31. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  32. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  33. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  34. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  35. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  36. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  37. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  38. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  39. paskia/frontend-build/auth/index.html +5 -5
  40. paskia/frontend-build/auth/restricted/index.html +4 -4
  41. paskia/frontend-build/int/forward/index.html +4 -4
  42. paskia/frontend-build/int/reset/index.html +3 -3
  43. paskia/globals.py +2 -2
  44. paskia/migrate/__init__.py +67 -60
  45. paskia/migrate/sql.py +94 -37
  46. paskia/remoteauth.py +7 -8
  47. paskia/sansio.py +6 -12
  48. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
  49. paskia-0.9.1.dist-info/RECORD +60 -0
  50. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  51. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  52. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  53. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  54. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  55. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  56. paskia-0.8.1.dist-info/RECORD +0 -55
  57. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
  58. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/entry_points.txt +0 -0
paskia/fastapi/admin.py CHANGED
@@ -1,58 +1,45 @@
1
1
  import logging
2
- from datetime import timezone
3
- from uuid import UUID, uuid4
2
+ from uuid import UUID
4
3
 
5
- from fastapi import Body, FastAPI, HTTPException, Request, Response
4
+ from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
6
5
  from fastapi.responses import JSONResponse
7
6
 
7
+ from paskia import aaguid as aaguid_mod
8
8
  from paskia import db
9
9
  from paskia.authsession import EXPIRES, reset_expires
10
+ from paskia.db import Org as OrgDC
11
+ from paskia.db import Permission as PermDC
12
+ from paskia.db import Role as RoleDC
13
+ from paskia.db import User as UserDC
10
14
  from paskia.fastapi import authz
15
+ from paskia.fastapi.response import MsgspecResponse
11
16
  from paskia.fastapi.session import AUTH_COOKIE
17
+ from paskia.globals import passkey
12
18
  from paskia.util import (
13
19
  hostutil,
14
20
  passphrase,
15
21
  permutil,
16
22
  querysafe,
17
- useragent,
18
23
  vitedev,
19
24
  )
25
+ from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime
26
+ from paskia.util.hostutil import normalize_host
20
27
 
21
- app = FastAPI()
28
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
22
29
 
23
30
 
24
- def is_global_admin(ctx) -> bool:
25
- """Check if user has global admin permission."""
26
- effective_scopes = (
27
- {p.scope for p in (ctx.permissions or [])}
28
- if ctx.permissions
29
- else set(ctx.role.permissions or [])
30
- )
31
- return "auth:admin" in effective_scopes
32
-
31
+ def master_admin(ctx) -> bool:
32
+ return any(p.scope == "auth:admin" for p in ctx.permissions)
33
33
 
34
- def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
35
- """Check if user has org admin permission.
36
34
 
37
- If org_uuid is provided, checks if user is admin of that specific org.
38
- If org_uuid is None, checks if user is admin of their own org.
39
- """
40
- effective_scopes = (
41
- {p.scope for p in (ctx.permissions or [])}
42
- if ctx.permissions
43
- else set(ctx.role.permissions or [])
35
+ def org_admin(ctx, org_uuid: UUID) -> bool:
36
+ return ctx.org.uuid == org_uuid and any(
37
+ p.scope == "auth:org:admin" for p in ctx.permissions
44
38
  )
45
- if "auth:org:admin" not in effective_scopes:
46
- return False
47
- if org_uuid is None:
48
- return True
49
- # User must belong to the target org (via their role)
50
- return ctx.org.uuid == org_uuid
51
39
 
52
40
 
53
41
  def can_manage_org(ctx, org_uuid: UUID) -> bool:
54
- """Check if user can manage the specified organization."""
55
- return is_global_admin(ctx) or is_org_admin(ctx, org_uuid)
42
+ return master_admin(ctx) or org_admin(ctx, org_uuid)
56
43
 
57
44
 
58
45
  @app.exception_handler(ValueError)
@@ -91,39 +78,39 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
91
78
  match=permutil.has_any,
92
79
  host=request.headers.get("host"),
93
80
  )
94
- orgs = db.list_organizations()
95
- if not is_global_admin(ctx):
81
+ orgs = list(db.data().orgs.values())
82
+ if not master_admin(ctx):
96
83
  # Org admins can only see their own organization
97
84
  orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
98
85
 
99
- def role_to_dict(r):
86
+ def org_to_dict(o):
87
+ users = db.get_organization_users(o.uuid)
100
88
  return {
101
- "uuid": str(r.uuid),
102
- "org_uuid": str(r.org_uuid),
103
- "display_name": r.display_name,
104
- "permissions": r.permissions,
105
- }
106
-
107
- async def org_to_dict(o):
108
- users = db.get_organization_users(str(o.uuid))
109
- return {
110
- "uuid": str(o.uuid),
89
+ "uuid": o.uuid,
111
90
  "display_name": o.display_name,
112
- "permissions": o.permissions,
113
- "roles": [role_to_dict(r) for r in o.roles],
91
+ "permissions": {p.uuid for p in o.permissions},
92
+ "roles": [
93
+ {
94
+ "uuid": r.uuid,
95
+ "org": r.org_uuid,
96
+ "display_name": r.display_name,
97
+ "permissions": list(r.permissions.keys()),
98
+ }
99
+ for r in o.roles
100
+ ],
114
101
  "users": [
115
102
  {
116
- "uuid": str(u.uuid),
103
+ "uuid": u.uuid,
117
104
  "display_name": u.display_name,
118
105
  "role": role_name,
119
106
  "visits": u.visits,
120
- "last_seen": u.last_seen.isoformat() if u.last_seen else None,
107
+ "last_seen": u.last_seen,
121
108
  }
122
109
  for (u, role_name) in users
123
110
  ],
124
111
  }
125
112
 
126
- return [await org_to_dict(o) for o in orgs]
113
+ return MsgspecResponse([org_to_dict(o) for o in orgs])
127
114
 
128
115
 
129
116
  @app.post("/orgs")
@@ -133,15 +120,16 @@ async def admin_create_org(
133
120
  ctx = await authz.verify(
134
121
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
135
122
  )
136
- from ..db import Org as OrgDC # local import to avoid cycles
137
123
 
138
- org_uuid = uuid4()
139
124
  display_name = payload.get("display_name") or "New Organization"
140
125
  permissions = payload.get("permissions") or []
141
- org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
142
- db.create_organization(org, ctx=ctx)
126
+ org = OrgDC.create(display_name=display_name)
127
+ db.create_org(org, ctx=ctx)
128
+ # Grant requested permissions to the new org
129
+ for perm in permissions:
130
+ db.add_permission_to_org(str(org.uuid), perm)
143
131
 
144
- return {"uuid": str(org_uuid)}
132
+ return {"uuid": str(org.uuid)}
145
133
 
146
134
 
147
135
  @app.patch("/orgs/{org_uuid}")
@@ -166,7 +154,7 @@ async def admin_update_org_name(
166
154
  if not display_name:
167
155
  raise ValueError("display_name is required")
168
156
 
169
- db.update_organization_name(org_uuid, display_name, ctx=ctx)
157
+ db.update_org_name(org_uuid, display_name, ctx=ctx)
170
158
  return {"status": "ok"}
171
159
 
172
160
 
@@ -188,7 +176,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
188
176
 
189
177
  # Delete organization-specific permissions
190
178
  org_perm_pattern = f"org:{str(org_uuid).lower()}"
191
- all_permissions = db.list_permissions()
179
+ all_permissions = list(db.data().permissions.values())
192
180
  for perm in all_permissions:
193
181
  perm_scope_lower = perm.scope.lower()
194
182
  # Check if permission contains "org:{uuid}" separated by colons or at boundaries
@@ -198,39 +186,43 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
198
186
  or perm_scope_lower.endswith(f":{org_perm_pattern}")
199
187
  or perm_scope_lower == org_perm_pattern
200
188
  ):
201
- db.delete_permission(str(perm.uuid), ctx=ctx)
189
+ db.delete_permission(perm.uuid, ctx=ctx)
202
190
 
203
- db.delete_organization(org_uuid, ctx=ctx)
191
+ db.delete_org(org_uuid, ctx=ctx)
204
192
  return {"status": "ok"}
205
193
 
206
194
 
207
195
  @app.post("/orgs/{org_uuid}/permission")
208
196
  async def admin_add_org_permission(
209
197
  org_uuid: UUID,
210
- permission_id: str,
211
198
  request: Request,
199
+ permission_uuid: UUID = Query(...),
212
200
  auth=AUTH_COOKIE,
213
201
  ):
214
202
  ctx = await authz.verify(
215
203
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
216
204
  )
217
- db.add_permission_to_organization(str(org_uuid), permission_id, ctx=ctx)
205
+
206
+ db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
218
207
  return {"status": "ok"}
219
208
 
220
209
 
221
210
  @app.delete("/orgs/{org_uuid}/permission")
222
211
  async def admin_remove_org_permission(
223
212
  org_uuid: UUID,
224
- permission_id: str,
225
213
  request: Request,
214
+ permission_uuid: UUID = Query(...),
226
215
  auth=AUTH_COOKIE,
227
216
  ):
228
217
  ctx = await authz.verify(
229
218
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
230
219
  )
231
220
 
221
+ db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
222
+
232
223
  # Guard rail: prevent removing auth:admin from your own org if it would lock you out
233
- if permission_id == "auth:admin" and ctx.org.uuid == org_uuid:
224
+ perm = db.data().permissions.get(permission_uuid)
225
+ if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
234
226
  # Check if any other org grants auth:admin that we're a member of
235
227
  # (we only know our current org, so this effectively means we can't remove it from our own org)
236
228
  raise ValueError(
@@ -238,7 +230,7 @@ async def admin_remove_org_permission(
238
230
  "This would lock you out of admin access."
239
231
  )
240
232
 
241
- db.remove_permission_from_organization(str(org_uuid), permission_id, ctx=ctx)
233
+ db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
242
234
  return {"status": "ok"}
243
235
 
244
236
 
@@ -262,33 +254,31 @@ async def admin_create_role(
262
254
  raise authz.AuthException(
263
255
  status_code=403, detail="Insufficient permissions", mode="forbidden"
264
256
  )
265
- from ..db import Role as RoleDC
266
257
 
267
- role_uuid = uuid4()
268
258
  display_name = payload.get("display_name") or "New Role"
269
259
  perms = payload.get("permissions") or []
270
- org = db.get_organization(str(org_uuid))
271
- grantable = set(org.permissions or [])
260
+ if org_uuid not in db.data().orgs:
261
+ raise HTTPException(status_code=404, detail="Organization not found")
262
+ org = db.data().orgs[org_uuid]
263
+ grantable = {p.uuid for p in org.permissions}
272
264
 
273
265
  # Normalize permission IDs to UUIDs
274
- permission_uuids = []
266
+ permission_uuids: set[UUID] = set()
275
267
  for pid in perms:
276
- perm = db.get_permission(pid)
268
+ perm = db.data().permissions.get(UUID(pid))
277
269
  if not perm:
278
270
  raise ValueError(f"Permission {pid} not found")
279
- perm_uuid_str = str(perm.uuid)
280
- if perm_uuid_str not in grantable:
271
+ if perm.uuid not in grantable:
281
272
  raise ValueError(f"Permission not grantable by org: {pid}")
282
- permission_uuids.append(perm_uuid_str)
273
+ permission_uuids.add(perm.uuid)
283
274
 
284
- role = RoleDC(
285
- uuid=role_uuid,
286
- org_uuid=org_uuid,
275
+ role = RoleDC.create(
276
+ org=org_uuid,
287
277
  display_name=display_name,
288
278
  permissions=permission_uuids,
289
279
  )
290
280
  db.create_role(role, ctx=ctx)
291
- return {"uuid": str(role_uuid)}
281
+ return {"uuid": str(role.uuid)}
292
282
 
293
283
 
294
284
  @app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
@@ -310,8 +300,8 @@ async def admin_update_role_name(
310
300
  raise authz.AuthException(
311
301
  status_code=403, detail="Insufficient permissions", mode="forbidden"
312
302
  )
313
- role = db.get_role(role_uuid)
314
- if role.org_uuid != org_uuid:
303
+ role = db.data().roles.get(role_uuid)
304
+ if not role or role.org_uuid != org_uuid:
315
305
  raise HTTPException(status_code=404, detail="Role not found in organization")
316
306
 
317
307
  display_name = payload.get("display_name")
@@ -342,16 +332,15 @@ async def admin_add_role_permission(
342
332
  status_code=403, detail="Insufficient permissions", mode="forbidden"
343
333
  )
344
334
 
345
- role = db.get_role(role_uuid)
346
- if role.org_uuid != org_uuid:
335
+ role = db.data().roles.get(role_uuid)
336
+ if not role or role.org_uuid != org_uuid:
347
337
  raise HTTPException(status_code=404, detail="Role not found in organization")
348
338
 
349
339
  # Verify permission exists and org can grant it
350
- perm = db.get_permission(permission_uuid)
340
+ perm = db.data().permissions.get(permission_uuid)
351
341
  if not perm:
352
342
  raise HTTPException(status_code=404, detail="Permission not found")
353
- org = db.get_organization(str(org_uuid))
354
- if str(permission_uuid) not in org.permissions:
343
+ if org_uuid not in perm.orgs:
355
344
  raise ValueError("Permission not grantable by organization")
356
345
 
357
346
  db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
@@ -378,21 +367,19 @@ async def admin_remove_role_permission(
378
367
  status_code=403, detail="Insufficient permissions", mode="forbidden"
379
368
  )
380
369
 
381
- role = db.get_role(role_uuid)
382
- if role.org_uuid != org_uuid:
370
+ role = db.data().roles.get(role_uuid)
371
+ if not role or role.org_uuid != org_uuid:
383
372
  raise HTTPException(status_code=404, detail="Role not found in organization")
384
373
 
385
374
  # Sanity check: prevent admin from removing their own access
386
- # Find auth:admin and auth:org:admin permission UUIDs
387
- perm_uuid_str = str(permission_uuid)
388
- perm = db.get_permission(permission_uuid)
375
+ perm = db.data().permissions.get(permission_uuid)
389
376
  if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
390
377
  if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
391
378
  # Check if removing this permission would leave no admin access
392
- remaining_perms = set(role.permissions) - {perm_uuid_str}
379
+ remaining_perms = role.permission_set - {permission_uuid}
393
380
  has_admin = False
394
381
  for rp_uuid in remaining_perms:
395
- rp = db.get_permission(rp_uuid)
382
+ rp = db.data().permissions.get(rp_uuid)
396
383
  if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
397
384
  has_admin = True
398
385
  break
@@ -421,8 +408,8 @@ async def admin_delete_role(
421
408
  raise authz.AuthException(
422
409
  status_code=403, detail="Insufficient permissions", mode="forbidden"
423
410
  )
424
- role = db.get_role(role_uuid)
425
- if role.org_uuid != org_uuid:
411
+ role = db.data().roles.get(role_uuid)
412
+ if not role or role.org_uuid != org_uuid:
426
413
  raise HTTPException(status_code=404, detail="Role not found in organization")
427
414
 
428
415
  # Sanity check: prevent admin from deleting their own role
@@ -457,22 +444,20 @@ async def admin_create_user(
457
444
  role_name = payload.get("role")
458
445
  if not display_name or not role_name:
459
446
  raise ValueError("display_name and role are required")
460
- from ..db import User as UserDC
461
447
 
462
- roles = db.get_roles_by_organization(str(org_uuid))
463
- role_obj = next((r for r in roles if r.display_name == role_name), None)
448
+ org = db.data().orgs[org_uuid]
449
+ role_obj = next(
450
+ (r for r in org.roles if r.display_name == role_name),
451
+ None,
452
+ )
464
453
  if not role_obj:
465
454
  raise ValueError("Role not found in organization")
466
- user_uuid = uuid4()
467
- user = UserDC(
468
- uuid=user_uuid,
455
+ user = UserDC.create(
469
456
  display_name=display_name,
470
- role_uuid=role_obj.uuid,
471
- visits=0,
472
- created_at=None,
457
+ role=role_obj.uuid,
473
458
  )
474
459
  db.create_user(user, ctx=ctx)
475
- return {"uuid": str(user_uuid)}
460
+ return {"uuid": str(user.uuid)}
476
461
 
477
462
 
478
463
  @app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
@@ -502,7 +487,7 @@ async def admin_update_user_role(
502
487
  raise ValueError("User not found")
503
488
  if user_org.uuid != org_uuid:
504
489
  raise ValueError("User does not belong to this organization")
505
- roles = db.get_roles_by_organization(str(org_uuid))
490
+ roles = user_org.roles
506
491
  if not any(r.display_name == new_role for r in roles):
507
492
  raise ValueError("Role not found in organization")
508
493
 
@@ -513,7 +498,7 @@ async def admin_update_user_role(
513
498
  # Check if any permission in the new role is an admin permission
514
499
  has_admin_access = False
515
500
  for perm_uuid in new_role_obj.permissions:
516
- perm = db.get_permission(perm_uuid)
501
+ perm = db.data().permissions.get(perm_uuid)
517
502
  if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
518
503
  has_admin_access = True
519
504
  break
@@ -552,8 +537,8 @@ async def admin_create_user_registration_link(
552
537
  )
553
538
 
554
539
  # Check if user has existing credentials
555
- credentials = db.get_credentials_by_user_uuid(user_uuid)
556
- token_type = "user registration" if not credentials else "account recovery"
540
+ has_credentials = db.get_user_credential_ids(user_uuid)
541
+ token_type = "user registration" if not has_credentials else "account recovery"
557
542
 
558
543
  token = passphrase.generate()
559
544
  expiry = reset_expires()
@@ -567,11 +552,7 @@ async def admin_create_user_registration_link(
567
552
  url = hostutil.reset_link_url(token)
568
553
  return {
569
554
  "url": url,
570
- "expires": (
571
- expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
572
- if expiry.tzinfo
573
- else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
574
- ),
555
+ "expires": format_datetime(expiry),
575
556
  }
576
557
 
577
558
 
@@ -598,122 +579,40 @@ async def admin_get_user_detail(
598
579
  raise authz.AuthException(
599
580
  status_code=403, detail="Insufficient permissions", mode="forbidden"
600
581
  )
601
- user = db.get_user_by_uuid(user_uuid)
602
- user_creds = db.get_credentials_by_user_uuid(user_uuid)
603
- creds: list[dict] = []
604
- aaguids: set[str] = set()
605
- for c in user_creds:
606
- aaguid_str = str(c.aaguid)
607
- aaguids.add(aaguid_str)
608
- creds.append(
609
- {
610
- "credential_uuid": str(c.uuid),
611
- "aaguid": aaguid_str,
612
- "created_at": (
613
- c.created_at.astimezone(timezone.utc)
614
- .isoformat()
615
- .replace("+00:00", "Z")
616
- if c.created_at.tzinfo
617
- else c.created_at.replace(tzinfo=timezone.utc)
618
- .isoformat()
619
- .replace("+00:00", "Z")
620
- ),
621
- "last_used": (
622
- c.last_used.astimezone(timezone.utc)
623
- .isoformat()
624
- .replace("+00:00", "Z")
625
- if c.last_used and c.last_used.tzinfo
626
- else (
627
- c.last_used.replace(tzinfo=timezone.utc)
628
- .isoformat()
629
- .replace("+00:00", "Z")
630
- if c.last_used
631
- else None
632
- )
633
- ),
634
- "last_verified": (
635
- c.last_verified.astimezone(timezone.utc)
636
- .isoformat()
637
- .replace("+00:00", "Z")
638
- if c.last_verified and c.last_verified.tzinfo
639
- else (
640
- c.last_verified.replace(tzinfo=timezone.utc)
641
- .isoformat()
642
- .replace("+00:00", "Z")
643
- if c.last_verified
644
- else None
645
- )
582
+ user = db.data().users.get(user_uuid)
583
+ normalized_host = hostutil.normalize_host(request.headers.get("host"))
584
+
585
+ return MsgspecResponse(
586
+ {
587
+ "display_name": user.display_name,
588
+ "org": {"display_name": user_org.display_name},
589
+ "role": role_name,
590
+ "visits": user.visits,
591
+ "created_at": user.created_at,
592
+ "last_seen": user.last_seen,
593
+ "credentials": [
594
+ {
595
+ "credential": c.uuid,
596
+ "aaguid": c.aaguid,
597
+ "created_at": c.created_at,
598
+ "last_used": c.last_used,
599
+ "last_verified": c.last_verified,
600
+ "sign_count": c.sign_count,
601
+ }
602
+ for c in user.credentials
603
+ ],
604
+ "aaguid_info": aaguid_mod.filter(c.aaguid for c in user.credentials),
605
+ "sessions": [
606
+ ApiSession.from_db(
607
+ s,
608
+ current_key=auth,
609
+ normalized_host=normalized_host,
610
+ expires_delta=EXPIRES,
646
611
  )
647
- if c.last_verified
648
- else None,
649
- "sign_count": c.sign_count,
650
- }
651
- )
652
- from .. import aaguid as aaguid_mod
653
-
654
- aaguid_info = aaguid_mod.filter(aaguids)
655
-
656
- # Get sessions for the user
657
- normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
658
- session_records = db.list_sessions_for_user(user_uuid)
659
- current_session_key = auth
660
- sessions_payload: list[dict] = []
661
- for entry in session_records:
662
- renewed = entry.expiry - EXPIRES
663
- sessions_payload.append(
664
- {
665
- "id": entry.key,
666
- "credential_uuid": str(entry.credential_uuid),
667
- "host": entry.host,
668
- "ip": entry.ip,
669
- "user_agent": useragent.compact_user_agent(entry.user_agent),
670
- "last_renewed": (
671
- renewed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
672
- if renewed.tzinfo
673
- else renewed.replace(tzinfo=timezone.utc)
674
- .isoformat()
675
- .replace("+00:00", "Z")
676
- ),
677
- "is_current": entry.key == current_session_key,
678
- "is_current_host": bool(
679
- normalized_request_host
680
- and entry.host
681
- and entry.host == normalized_request_host
682
- ),
683
- }
684
- )
685
-
686
- return {
687
- "display_name": user.display_name,
688
- "org": {"display_name": user_org.display_name},
689
- "role": role_name,
690
- "visits": user.visits,
691
- "created_at": (
692
- user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
693
- if user.created_at and user.created_at.tzinfo
694
- else (
695
- user.created_at.replace(tzinfo=timezone.utc)
696
- .isoformat()
697
- .replace("+00:00", "Z")
698
- if user.created_at
699
- else None
700
- )
701
- ),
702
- "last_seen": (
703
- user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
704
- if user.last_seen and user.last_seen.tzinfo
705
- else (
706
- user.last_seen.replace(tzinfo=timezone.utc)
707
- .isoformat()
708
- .replace("+00:00", "Z")
709
- if user.last_seen
710
- else None
711
- )
712
- ),
713
- "credentials": creds,
714
- "aaguid_info": aaguid_info,
715
- "sessions": sessions_payload,
716
- }
612
+ for s in user.sessions
613
+ ],
614
+ }
615
+ )
717
616
 
718
617
 
719
618
  @app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
@@ -803,7 +702,7 @@ async def admin_delete_user_session(
803
702
  status_code=403, detail="Insufficient permissions", mode="forbidden"
804
703
  )
805
704
 
806
- target_session = db.get_session(session_id)
705
+ target_session = db.data().sessions.get(session_id)
807
706
  if not target_session or target_session.user_uuid != user_uuid:
808
707
  raise HTTPException(status_code=404, detail="Session not found")
809
708
 
@@ -817,19 +716,10 @@ async def admin_delete_user_session(
817
716
  # -------------------- Permissions (global) --------------------
818
717
 
819
718
 
820
- def _perm_to_dict(p):
821
- """Convert Permission to dict, omitting domain if None."""
822
- d = {"uuid": str(p.uuid), "scope": p.scope, "display_name": p.display_name}
823
- if p.domain is not None:
824
- d["domain"] = p.domain
825
- return d
826
-
827
-
828
719
  def _validate_permission_domain(domain: str | None) -> None:
829
720
  """Validate that domain is rp_id or a subdomain of it."""
830
721
  if domain is None:
831
722
  return
832
- from paskia.globals import passkey
833
723
 
834
724
  rp_id = passkey.instance.rp_id
835
725
  if domain == rp_id or domain.endswith(f".{rp_id}"):
@@ -845,13 +735,12 @@ def _check_admin_lockout(
845
735
  Raises ValueError if this change would result in no auth:admin permissions
846
736
  being accessible from the current host.
847
737
  """
848
- from paskia.util.hostutil import normalize_host
849
738
 
850
739
  normalized_host = normalize_host(current_host)
851
740
  host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
852
741
 
853
742
  # Get all auth:admin permissions
854
- all_perms = db.list_permissions()
743
+ all_perms = list(db.data().permissions.values())
855
744
  admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
856
745
 
857
746
  # Check if at least one auth:admin would remain accessible
@@ -880,13 +769,12 @@ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) ->
880
769
  Raises ValueError if this deletion would result in no auth:admin permissions
881
770
  being accessible from the current host.
882
771
  """
883
- from paskia.util.hostutil import normalize_host
884
772
 
885
773
  normalized_host = normalize_host(current_host)
886
774
  host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
887
775
 
888
776
  # Get all auth:admin permissions
889
- all_perms = db.list_permissions()
777
+ all_perms = list(db.data().permissions.values())
890
778
  admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
891
779
 
892
780
  # Check if at least one auth:admin would remain accessible after deletion
@@ -918,16 +806,8 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
918
806
  match=permutil.has_any,
919
807
  host=request.headers.get("host"),
920
808
  )
921
- perms = db.list_permissions()
922
-
923
- # Global admins see all permissions
924
- if is_global_admin(ctx):
925
- return [_perm_to_dict(p) for p in perms]
926
-
927
- # Org admins only see permissions their org can grant (by UUID)
928
- grantable = set(ctx.org.permissions or [])
929
- filtered_perms = [p for p in perms if str(p.uuid) in grantable]
930
- return [_perm_to_dict(p) for p in filtered_perms]
809
+ perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
810
+ return MsgspecResponse([ApiPermission.from_db(p) for p in perms])
931
811
 
932
812
 
933
813
  @app.post("/permissions")
@@ -943,9 +823,6 @@ async def admin_create_permission(
943
823
  match=permutil.has_all,
944
824
  max_age="5m",
945
825
  )
946
- import uuid7
947
-
948
- from ..db import Permission as PermDC
949
826
 
950
827
  scope = payload.get("scope") or payload.get(
951
828
  "id"
@@ -957,9 +834,7 @@ async def admin_create_permission(
957
834
  querysafe.assert_safe(scope, field="scope")
958
835
  _validate_permission_domain(domain)
959
836
  db.create_permission(
960
- PermDC(
961
- uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain
962
- ),
837
+ PermDC.create(scope=scope, display_name=display_name, domain=domain),
963
838
  ctx=ctx,
964
839
  )
965
840
  return {"status": "ok"}
@@ -969,29 +844,27 @@ async def admin_create_permission(
969
844
  async def admin_update_permission(
970
845
  request: Request,
971
846
  auth=AUTH_COOKIE,
972
- permission_uuid: str | None = None,
973
- permission_id: str | None = None, # Backwards compat - treated as scope
974
- display_name: str | None = None,
975
- scope: str | None = None,
976
- domain: str | None = None,
847
+ permission_uuid: UUID = Query(...),
848
+ display_name: str | None = Query(None),
849
+ scope: str | None = Query(None),
850
+ domain: str | None = Query(None),
977
851
  ):
978
852
  ctx = await authz.verify(
979
853
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
980
854
  )
981
855
 
982
- # permission_uuid or permission_id (scope) to identify the permission
983
- perm_identifier = permission_uuid or permission_id
984
- if not perm_identifier:
985
- raise ValueError("permission_uuid or permission_id required")
986
-
987
856
  # Get existing permission
988
- perm = db.get_permission(perm_identifier)
857
+ perm = db.data().permissions.get(permission_uuid)
989
858
 
990
859
  # Update fields that were provided
991
860
  new_scope = scope if scope is not None else perm.scope
992
861
  new_display_name = display_name if display_name is not None else perm.display_name
993
862
  domain_value = domain if domain else None
994
863
 
864
+ # Sanity check: prevent changing the auth:admin permission scope
865
+ if perm.scope == "auth:admin" and new_scope != "auth:admin":
866
+ raise ValueError("Cannot rename the master admin permission")
867
+
995
868
  if not new_display_name:
996
869
  raise ValueError("display_name is required")
997
870
  querysafe.assert_safe(new_scope, field="scope")
@@ -1001,71 +874,21 @@ async def admin_update_permission(
1001
874
  if perm.scope == "auth:admin" or new_scope == "auth:admin":
1002
875
  _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
1003
876
 
1004
- from ..db import Permission as PermDC
1005
-
1006
877
  db.update_permission(
1007
- PermDC(
1008
- uuid=perm.uuid,
1009
- scope=new_scope,
1010
- display_name=new_display_name,
1011
- domain=domain_value,
1012
- ),
878
+ uuid=perm.uuid,
879
+ scope=new_scope,
880
+ display_name=new_display_name,
881
+ domain=domain_value,
1013
882
  ctx=ctx,
1014
883
  )
1015
884
  return {"status": "ok"}
1016
885
 
1017
886
 
1018
- @app.post("/permission/rename")
1019
- async def admin_rename_permission(
1020
- request: Request,
1021
- payload: dict = Body(...),
1022
- auth=AUTH_COOKIE,
1023
- ):
1024
- ctx = await authz.verify(
1025
- auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
1026
- )
1027
- old_scope = payload.get("old_scope") or payload.get("old_id") # Support both
1028
- new_scope = payload.get("new_scope") or payload.get("new_id") # Support both
1029
- display_name = payload.get("display_name")
1030
- domain = payload.get(
1031
- "domain"
1032
- ) # Can be None (not provided), empty string (clear), or value
1033
- if not old_scope or not new_scope:
1034
- raise ValueError("old_scope and new_scope required")
1035
-
1036
- # Sanity check: prevent renaming critical permissions
1037
- if old_scope == "auth:admin":
1038
- raise ValueError("Cannot rename the master admin permission")
1039
-
1040
- querysafe.assert_safe(old_scope, field="old_scope")
1041
- querysafe.assert_safe(new_scope, field="new_scope")
1042
-
1043
- # Get existing permission to preserve values not being changed
1044
- perm = db.get_permission(old_scope)
1045
- if display_name is None:
1046
- display_name = perm.display_name
1047
- # domain=None means "not provided, keep existing", domain="" means "clear it"
1048
- if domain is None:
1049
- domain_value = perm.domain
1050
- else:
1051
- domain_value = domain if domain else None
1052
- _validate_permission_domain(domain_value)
1053
-
1054
- # Safety check: prevent admin lockout when setting domain on auth:admin
1055
- if perm.scope == "auth:admin" or new_scope == "auth:admin":
1056
- _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
1057
-
1058
- # All current backends support rename_permission
1059
- db.rename_permission(old_scope, new_scope, display_name, domain_value, ctx=ctx)
1060
- return {"status": "ok"}
1061
-
1062
-
1063
887
  @app.delete("/permission")
1064
888
  async def admin_delete_permission(
1065
889
  request: Request,
890
+ permission_uuid: UUID = Query(...),
1066
891
  auth=AUTH_COOKIE,
1067
- permission_uuid: str | None = None,
1068
- permission_id: str | None = None, # Backwards compat - treated as scope
1069
892
  ):
1070
893
  ctx = await authz.verify(
1071
894
  auth,
@@ -1075,17 +898,12 @@ async def admin_delete_permission(
1075
898
  max_age="5m",
1076
899
  )
1077
900
 
1078
- perm_identifier = permission_uuid or permission_id
1079
- if not perm_identifier:
1080
- raise ValueError("permission_uuid or permission_id required")
1081
- querysafe.assert_safe(perm_identifier, field="permission_id")
1082
-
1083
901
  # Get the permission to check its scope
1084
- perm = db.get_permission(perm_identifier)
902
+ perm = db.data().permissions.get(permission_uuid)
1085
903
 
1086
904
  # Sanity check: prevent deleting critical permissions if it would lock out admin
1087
905
  if perm.scope == "auth:admin":
1088
906
  _check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
1089
907
 
1090
- db.delete_permission(str(perm.uuid), ctx=ctx)
908
+ db.delete_permission(permission_uuid, ctx=ctx)
1091
909
  return {"status": "ok"}