paskia 0.8.1__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +14 -27
  3. paskia/bootstrap.py +31 -103
  4. paskia/db/__init__.py +25 -51
  5. paskia/db/background.py +17 -37
  6. paskia/db/jsonl.py +168 -6
  7. paskia/db/migrations.py +34 -0
  8. paskia/db/operations.py +400 -723
  9. paskia/db/structs.py +214 -90
  10. paskia/fastapi/__main__.py +24 -28
  11. paskia/fastapi/admin.py +101 -160
  12. paskia/fastapi/api.py +47 -83
  13. paskia/fastapi/mainapp.py +13 -6
  14. paskia/fastapi/remote.py +16 -39
  15. paskia/fastapi/reset.py +27 -17
  16. paskia/fastapi/session.py +2 -2
  17. paskia/fastapi/user.py +21 -27
  18. paskia/fastapi/ws.py +27 -62
  19. paskia/fastapi/wschat.py +62 -0
  20. paskia/frontend-build/auth/admin/index.html +5 -5
  21. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  22. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  23. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  24. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  25. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  26. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  27. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  28. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  29. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  30. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  31. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  32. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  33. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  34. paskia/frontend-build/auth/index.html +5 -5
  35. paskia/frontend-build/auth/restricted/index.html +4 -4
  36. paskia/frontend-build/int/forward/index.html +4 -4
  37. paskia/frontend-build/int/reset/index.html +3 -3
  38. paskia/globals.py +2 -2
  39. paskia/migrate/__init__.py +62 -55
  40. paskia/migrate/sql.py +72 -22
  41. paskia/remoteauth.py +1 -2
  42. paskia/sansio.py +6 -12
  43. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/METADATA +1 -1
  44. paskia-0.9.0.dist-info/RECORD +57 -0
  45. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  46. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  47. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  48. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  49. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  50. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  51. paskia-0.8.1.dist-info/RECORD +0 -55
  52. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
  53. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
paskia/fastapi/admin.py CHANGED
@@ -1,14 +1,20 @@
1
1
  import logging
2
2
  from datetime import timezone
3
- from uuid import UUID, uuid4
3
+ from uuid import UUID
4
4
 
5
- from fastapi import Body, FastAPI, HTTPException, Request, Response
5
+ from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
6
6
  from fastapi.responses import JSONResponse
7
7
 
8
+ from paskia import aaguid as aaguid_mod
8
9
  from paskia import db
9
10
  from paskia.authsession import EXPIRES, reset_expires
11
+ from paskia.db import Org as OrgDC
12
+ from paskia.db import Permission as PermDC
13
+ from paskia.db import Role as RoleDC
14
+ from paskia.db import User as UserDC
10
15
  from paskia.fastapi import authz
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,
@@ -17,8 +23,9 @@ from paskia.util import (
17
23
  useragent,
18
24
  vitedev,
19
25
  )
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
31
  def is_global_admin(ctx) -> bool:
@@ -91,7 +98,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
91
98
  match=permutil.has_any,
92
99
  host=request.headers.get("host"),
93
100
  )
94
- orgs = db.list_organizations()
101
+ orgs = list(db.data().orgs.values())
95
102
  if not is_global_admin(ctx):
96
103
  # Org admins can only see their own organization
97
104
  orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
@@ -99,18 +106,22 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
99
106
  def role_to_dict(r):
100
107
  return {
101
108
  "uuid": str(r.uuid),
102
- "org_uuid": str(r.org_uuid),
109
+ "org": str(r.org),
103
110
  "display_name": r.display_name,
104
- "permissions": r.permissions,
111
+ "permissions": list(r.permissions.keys()),
105
112
  }
106
113
 
107
114
  async def org_to_dict(o):
108
- users = db.get_organization_users(str(o.uuid))
115
+ users = db.get_organization_users(o.uuid)
109
116
  return {
110
117
  "uuid": str(o.uuid),
111
118
  "display_name": o.display_name,
112
- "permissions": o.permissions,
113
- "roles": [role_to_dict(r) for r in o.roles],
119
+ "permissions": {
120
+ pid for pid, p in db.data().permissions.items() if o.uuid in p.orgs
121
+ },
122
+ "roles": [
123
+ role_to_dict(r) for r in db.data().roles.values() if r.org == o.uuid
124
+ ],
114
125
  "users": [
115
126
  {
116
127
  "uuid": str(u.uuid),
@@ -133,15 +144,16 @@ async def admin_create_org(
133
144
  ctx = await authz.verify(
134
145
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
135
146
  )
136
- from ..db import Org as OrgDC # local import to avoid cycles
137
147
 
138
- org_uuid = uuid4()
139
148
  display_name = payload.get("display_name") or "New Organization"
140
149
  permissions = payload.get("permissions") or []
141
- org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
142
- db.create_organization(org, ctx=ctx)
150
+ org = OrgDC.create(display_name=display_name)
151
+ db.create_org(org, ctx=ctx)
152
+ # Grant requested permissions to the new org
153
+ for perm in permissions:
154
+ db.add_permission_to_org(str(org.uuid), perm)
143
155
 
144
- return {"uuid": str(org_uuid)}
156
+ return {"uuid": str(org.uuid)}
145
157
 
146
158
 
147
159
  @app.patch("/orgs/{org_uuid}")
@@ -166,7 +178,7 @@ async def admin_update_org_name(
166
178
  if not display_name:
167
179
  raise ValueError("display_name is required")
168
180
 
169
- db.update_organization_name(org_uuid, display_name, ctx=ctx)
181
+ db.update_org_name(org_uuid, display_name, ctx=ctx)
170
182
  return {"status": "ok"}
171
183
 
172
184
 
@@ -188,7 +200,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
188
200
 
189
201
  # Delete organization-specific permissions
190
202
  org_perm_pattern = f"org:{str(org_uuid).lower()}"
191
- all_permissions = db.list_permissions()
203
+ all_permissions = list(db.data().permissions.values())
192
204
  for perm in all_permissions:
193
205
  perm_scope_lower = perm.scope.lower()
194
206
  # Check if permission contains "org:{uuid}" separated by colons or at boundaries
@@ -198,39 +210,43 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
198
210
  or perm_scope_lower.endswith(f":{org_perm_pattern}")
199
211
  or perm_scope_lower == org_perm_pattern
200
212
  ):
201
- db.delete_permission(str(perm.uuid), ctx=ctx)
213
+ db.delete_permission(perm.uuid, ctx=ctx)
202
214
 
203
- db.delete_organization(org_uuid, ctx=ctx)
215
+ db.delete_org(org_uuid, ctx=ctx)
204
216
  return {"status": "ok"}
205
217
 
206
218
 
207
219
  @app.post("/orgs/{org_uuid}/permission")
208
220
  async def admin_add_org_permission(
209
221
  org_uuid: UUID,
210
- permission_id: str,
211
222
  request: Request,
223
+ permission_uuid: UUID = Query(...),
212
224
  auth=AUTH_COOKIE,
213
225
  ):
214
226
  ctx = await authz.verify(
215
227
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
216
228
  )
217
- db.add_permission_to_organization(str(org_uuid), permission_id, ctx=ctx)
229
+
230
+ db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
218
231
  return {"status": "ok"}
219
232
 
220
233
 
221
234
  @app.delete("/orgs/{org_uuid}/permission")
222
235
  async def admin_remove_org_permission(
223
236
  org_uuid: UUID,
224
- permission_id: str,
225
237
  request: Request,
238
+ permission_uuid: UUID = Query(...),
226
239
  auth=AUTH_COOKIE,
227
240
  ):
228
241
  ctx = await authz.verify(
229
242
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
230
243
  )
231
244
 
245
+ db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
246
+
232
247
  # 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:
248
+ perm = db.data().permissions.get(permission_uuid)
249
+ if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
234
250
  # Check if any other org grants auth:admin that we're a member of
235
251
  # (we only know our current org, so this effectively means we can't remove it from our own org)
236
252
  raise ValueError(
@@ -238,7 +254,7 @@ async def admin_remove_org_permission(
238
254
  "This would lock you out of admin access."
239
255
  )
240
256
 
241
- db.remove_permission_from_organization(str(org_uuid), permission_id, ctx=ctx)
257
+ db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
242
258
  return {"status": "ok"}
243
259
 
244
260
 
@@ -262,33 +278,30 @@ async def admin_create_role(
262
278
  raise authz.AuthException(
263
279
  status_code=403, detail="Insufficient permissions", mode="forbidden"
264
280
  )
265
- from ..db import Role as RoleDC
266
281
 
267
- role_uuid = uuid4()
268
282
  display_name = payload.get("display_name") or "New Role"
269
283
  perms = payload.get("permissions") or []
270
- org = db.get_organization(str(org_uuid))
271
- grantable = set(org.permissions or [])
284
+ if org_uuid not in db.data().orgs:
285
+ raise HTTPException(status_code=404, detail="Organization not found")
286
+ grantable = {pid for pid, p in db.data().permissions.items() if org_uuid in p.orgs}
272
287
 
273
288
  # Normalize permission IDs to UUIDs
274
- permission_uuids = []
289
+ permission_uuids: set[UUID] = set()
275
290
  for pid in perms:
276
- perm = db.get_permission(pid)
291
+ perm = db.data().permissions.get(UUID(pid))
277
292
  if not perm:
278
293
  raise ValueError(f"Permission {pid} not found")
279
- perm_uuid_str = str(perm.uuid)
280
- if perm_uuid_str not in grantable:
294
+ if perm.uuid not in grantable:
281
295
  raise ValueError(f"Permission not grantable by org: {pid}")
282
- permission_uuids.append(perm_uuid_str)
296
+ permission_uuids.add(perm.uuid)
283
297
 
284
- role = RoleDC(
285
- uuid=role_uuid,
286
- org_uuid=org_uuid,
298
+ role = RoleDC.create(
299
+ org=org_uuid,
287
300
  display_name=display_name,
288
301
  permissions=permission_uuids,
289
302
  )
290
303
  db.create_role(role, ctx=ctx)
291
- return {"uuid": str(role_uuid)}
304
+ return {"uuid": str(role.uuid)}
292
305
 
293
306
 
294
307
  @app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
@@ -310,8 +323,8 @@ async def admin_update_role_name(
310
323
  raise authz.AuthException(
311
324
  status_code=403, detail="Insufficient permissions", mode="forbidden"
312
325
  )
313
- role = db.get_role(role_uuid)
314
- if role.org_uuid != org_uuid:
326
+ role = db.data().roles.get(role_uuid)
327
+ if not role or role.org != org_uuid:
315
328
  raise HTTPException(status_code=404, detail="Role not found in organization")
316
329
 
317
330
  display_name = payload.get("display_name")
@@ -342,16 +355,15 @@ async def admin_add_role_permission(
342
355
  status_code=403, detail="Insufficient permissions", mode="forbidden"
343
356
  )
344
357
 
345
- role = db.get_role(role_uuid)
346
- if role.org_uuid != org_uuid:
358
+ role = db.data().roles.get(role_uuid)
359
+ if not role or role.org != org_uuid:
347
360
  raise HTTPException(status_code=404, detail="Role not found in organization")
348
361
 
349
362
  # Verify permission exists and org can grant it
350
- perm = db.get_permission(permission_uuid)
363
+ perm = db.data().permissions.get(permission_uuid)
351
364
  if not perm:
352
365
  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:
366
+ if org_uuid not in perm.orgs:
355
367
  raise ValueError("Permission not grantable by organization")
356
368
 
357
369
  db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
@@ -378,21 +390,19 @@ async def admin_remove_role_permission(
378
390
  status_code=403, detail="Insufficient permissions", mode="forbidden"
379
391
  )
380
392
 
381
- role = db.get_role(role_uuid)
382
- if role.org_uuid != org_uuid:
393
+ role = db.data().roles.get(role_uuid)
394
+ if not role or role.org != org_uuid:
383
395
  raise HTTPException(status_code=404, detail="Role not found in organization")
384
396
 
385
397
  # 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)
398
+ perm = db.data().permissions.get(permission_uuid)
389
399
  if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
390
400
  if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
391
401
  # Check if removing this permission would leave no admin access
392
- remaining_perms = set(role.permissions) - {perm_uuid_str}
402
+ remaining_perms = role.permission_set - {permission_uuid}
393
403
  has_admin = False
394
404
  for rp_uuid in remaining_perms:
395
- rp = db.get_permission(rp_uuid)
405
+ rp = db.data().permissions.get(rp_uuid)
396
406
  if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
397
407
  has_admin = True
398
408
  break
@@ -421,8 +431,8 @@ async def admin_delete_role(
421
431
  raise authz.AuthException(
422
432
  status_code=403, detail="Insufficient permissions", mode="forbidden"
423
433
  )
424
- role = db.get_role(role_uuid)
425
- if role.org_uuid != org_uuid:
434
+ role = db.data().roles.get(role_uuid)
435
+ if not role or role.org != org_uuid:
426
436
  raise HTTPException(status_code=404, detail="Role not found in organization")
427
437
 
428
438
  # Sanity check: prevent admin from deleting their own role
@@ -457,22 +467,17 @@ async def admin_create_user(
457
467
  role_name = payload.get("role")
458
468
  if not display_name or not role_name:
459
469
  raise ValueError("display_name and role are required")
460
- from ..db import User as UserDC
461
470
 
462
- roles = db.get_roles_by_organization(str(org_uuid))
471
+ roles = [r for r in db.data().roles.values() if r.org == org_uuid]
463
472
  role_obj = next((r for r in roles if r.display_name == role_name), None)
464
473
  if not role_obj:
465
474
  raise ValueError("Role not found in organization")
466
- user_uuid = uuid4()
467
- user = UserDC(
468
- uuid=user_uuid,
475
+ user = UserDC.create(
469
476
  display_name=display_name,
470
- role_uuid=role_obj.uuid,
471
- visits=0,
472
- created_at=None,
477
+ role=role_obj.uuid,
473
478
  )
474
479
  db.create_user(user, ctx=ctx)
475
- return {"uuid": str(user_uuid)}
480
+ return {"uuid": str(user.uuid)}
476
481
 
477
482
 
478
483
  @app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
@@ -502,7 +507,7 @@ async def admin_update_user_role(
502
507
  raise ValueError("User not found")
503
508
  if user_org.uuid != org_uuid:
504
509
  raise ValueError("User does not belong to this organization")
505
- roles = db.get_roles_by_organization(str(org_uuid))
510
+ roles = [r for r in db.data().roles.values() if r.org == org_uuid]
506
511
  if not any(r.display_name == new_role for r in roles):
507
512
  raise ValueError("Role not found in organization")
508
513
 
@@ -513,7 +518,7 @@ async def admin_update_user_role(
513
518
  # Check if any permission in the new role is an admin permission
514
519
  has_admin_access = False
515
520
  for perm_uuid in new_role_obj.permissions:
516
- perm = db.get_permission(perm_uuid)
521
+ perm = db.data().permissions.get(perm_uuid)
517
522
  if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
518
523
  has_admin_access = True
519
524
  break
@@ -552,8 +557,8 @@ async def admin_create_user_registration_link(
552
557
  )
553
558
 
554
559
  # 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"
560
+ has_credentials = db.get_user_credential_ids(user_uuid)
561
+ token_type = "user registration" if not has_credentials else "account recovery"
557
562
 
558
563
  token = passphrase.generate()
559
564
  expiry = reset_expires()
@@ -598,8 +603,8 @@ async def admin_get_user_detail(
598
603
  raise authz.AuthException(
599
604
  status_code=403, detail="Insufficient permissions", mode="forbidden"
600
605
  )
601
- user = db.get_user_by_uuid(user_uuid)
602
- user_creds = db.get_credentials_by_user_uuid(user_uuid)
606
+ user = db.data().users.get(user_uuid)
607
+ user_creds = [c for c in db.data().credentials.values() if c.user == user_uuid]
603
608
  creds: list[dict] = []
604
609
  aaguids: set[str] = set()
605
610
  for c in user_creds:
@@ -607,7 +612,7 @@ async def admin_get_user_detail(
607
612
  aaguids.add(aaguid_str)
608
613
  creds.append(
609
614
  {
610
- "credential_uuid": str(c.uuid),
615
+ "credential": str(c.uuid),
611
616
  "aaguid": aaguid_str,
612
617
  "created_at": (
613
618
  c.created_at.astimezone(timezone.utc)
@@ -649,13 +654,12 @@ async def admin_get_user_detail(
649
654
  "sign_count": c.sign_count,
650
655
  }
651
656
  )
652
- from .. import aaguid as aaguid_mod
653
657
 
654
658
  aaguid_info = aaguid_mod.filter(aaguids)
655
659
 
656
660
  # Get sessions for the user
657
661
  normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
658
- session_records = db.list_sessions_for_user(user_uuid)
662
+ session_records = [s for s in db.data().sessions.values() if s.user == user_uuid]
659
663
  current_session_key = auth
660
664
  sessions_payload: list[dict] = []
661
665
  for entry in session_records:
@@ -663,7 +667,7 @@ async def admin_get_user_detail(
663
667
  sessions_payload.append(
664
668
  {
665
669
  "id": entry.key,
666
- "credential_uuid": str(entry.credential_uuid),
670
+ "credential": str(entry.credential),
667
671
  "host": entry.host,
668
672
  "ip": entry.ip,
669
673
  "user_agent": useragent.compact_user_agent(entry.user_agent),
@@ -803,8 +807,8 @@ async def admin_delete_user_session(
803
807
  status_code=403, detail="Insufficient permissions", mode="forbidden"
804
808
  )
805
809
 
806
- target_session = db.get_session(session_id)
807
- if not target_session or target_session.user_uuid != user_uuid:
810
+ target_session = db.data().sessions.get(session_id)
811
+ if not target_session or target_session.user != user_uuid:
808
812
  raise HTTPException(status_code=404, detail="Session not found")
809
813
 
810
814
  db.delete_session(session_id, ctx=ctx)
@@ -829,7 +833,6 @@ def _validate_permission_domain(domain: str | None) -> None:
829
833
  """Validate that domain is rp_id or a subdomain of it."""
830
834
  if domain is None:
831
835
  return
832
- from paskia.globals import passkey
833
836
 
834
837
  rp_id = passkey.instance.rp_id
835
838
  if domain == rp_id or domain.endswith(f".{rp_id}"):
@@ -845,13 +848,12 @@ def _check_admin_lockout(
845
848
  Raises ValueError if this change would result in no auth:admin permissions
846
849
  being accessible from the current host.
847
850
  """
848
- from paskia.util.hostutil import normalize_host
849
851
 
850
852
  normalized_host = normalize_host(current_host)
851
853
  host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
852
854
 
853
855
  # Get all auth:admin permissions
854
- all_perms = db.list_permissions()
856
+ all_perms = list(db.data().permissions.values())
855
857
  admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
856
858
 
857
859
  # Check if at least one auth:admin would remain accessible
@@ -880,13 +882,12 @@ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) ->
880
882
  Raises ValueError if this deletion would result in no auth:admin permissions
881
883
  being accessible from the current host.
882
884
  """
883
- from paskia.util.hostutil import normalize_host
884
885
 
885
886
  normalized_host = normalize_host(current_host)
886
887
  host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
887
888
 
888
889
  # Get all auth:admin permissions
889
- all_perms = db.list_permissions()
890
+ all_perms = list(db.data().permissions.values())
890
891
  admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
891
892
 
892
893
  # Check if at least one auth:admin would remain accessible after deletion
@@ -918,15 +919,17 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
918
919
  match=permutil.has_any,
919
920
  host=request.headers.get("host"),
920
921
  )
921
- perms = db.list_permissions()
922
+ perms = list(db.data().permissions.values())
922
923
 
923
924
  # Global admins see all permissions
924
925
  if is_global_admin(ctx):
925
926
  return [_perm_to_dict(p) for p in perms]
926
927
 
927
928
  # 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]
929
+ grantable = {
930
+ pid for pid, p in db.data().permissions.items() if ctx.org.uuid in p.orgs
931
+ }
932
+ filtered_perms = [p for p in perms if p.uuid in grantable]
930
933
  return [_perm_to_dict(p) for p in filtered_perms]
931
934
 
932
935
 
@@ -943,9 +946,6 @@ async def admin_create_permission(
943
946
  match=permutil.has_all,
944
947
  max_age="5m",
945
948
  )
946
- import uuid7
947
-
948
- from ..db import Permission as PermDC
949
949
 
950
950
  scope = payload.get("scope") or payload.get(
951
951
  "id"
@@ -957,9 +957,7 @@ async def admin_create_permission(
957
957
  querysafe.assert_safe(scope, field="scope")
958
958
  _validate_permission_domain(domain)
959
959
  db.create_permission(
960
- PermDC(
961
- uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain
962
- ),
960
+ PermDC.create(scope=scope, display_name=display_name, domain=domain),
963
961
  ctx=ctx,
964
962
  )
965
963
  return {"status": "ok"}
@@ -969,29 +967,27 @@ async def admin_create_permission(
969
967
  async def admin_update_permission(
970
968
  request: Request,
971
969
  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,
970
+ permission_uuid: UUID = Query(...),
971
+ display_name: str | None = Query(None),
972
+ scope: str | None = Query(None),
973
+ domain: str | None = Query(None),
977
974
  ):
978
975
  ctx = await authz.verify(
979
976
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
980
977
  )
981
978
 
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
979
  # Get existing permission
988
- perm = db.get_permission(perm_identifier)
980
+ perm = db.data().permissions.get(permission_uuid)
989
981
 
990
982
  # Update fields that were provided
991
983
  new_scope = scope if scope is not None else perm.scope
992
984
  new_display_name = display_name if display_name is not None else perm.display_name
993
985
  domain_value = domain if domain else None
994
986
 
987
+ # Sanity check: prevent changing the auth:admin permission scope
988
+ if perm.scope == "auth:admin" and new_scope != "auth:admin":
989
+ raise ValueError("Cannot rename the master admin permission")
990
+
995
991
  if not new_display_name:
996
992
  raise ValueError("display_name is required")
997
993
  querysafe.assert_safe(new_scope, field="scope")
@@ -1001,71 +997,21 @@ async def admin_update_permission(
1001
997
  if perm.scope == "auth:admin" or new_scope == "auth:admin":
1002
998
  _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
1003
999
 
1004
- from ..db import Permission as PermDC
1005
-
1006
1000
  db.update_permission(
1007
- PermDC(
1008
- uuid=perm.uuid,
1009
- scope=new_scope,
1010
- display_name=new_display_name,
1011
- domain=domain_value,
1012
- ),
1001
+ uuid=perm.uuid,
1002
+ scope=new_scope,
1003
+ display_name=new_display_name,
1004
+ domain=domain_value,
1013
1005
  ctx=ctx,
1014
1006
  )
1015
1007
  return {"status": "ok"}
1016
1008
 
1017
1009
 
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
1010
  @app.delete("/permission")
1064
1011
  async def admin_delete_permission(
1065
1012
  request: Request,
1013
+ permission_uuid: UUID = Query(...),
1066
1014
  auth=AUTH_COOKIE,
1067
- permission_uuid: str | None = None,
1068
- permission_id: str | None = None, # Backwards compat - treated as scope
1069
1015
  ):
1070
1016
  ctx = await authz.verify(
1071
1017
  auth,
@@ -1075,17 +1021,12 @@ async def admin_delete_permission(
1075
1021
  max_age="5m",
1076
1022
  )
1077
1023
 
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
1024
  # Get the permission to check its scope
1084
- perm = db.get_permission(perm_identifier)
1025
+ perm = db.data().permissions.get(permission_uuid)
1085
1026
 
1086
1027
  # Sanity check: prevent deleting critical permissions if it would lock out admin
1087
1028
  if perm.scope == "auth:admin":
1088
1029
  _check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
1089
1030
 
1090
- db.delete_permission(str(perm.uuid), ctx=ctx)
1031
+ db.delete_permission(permission_uuid, ctx=ctx)
1091
1032
  return {"status": "ok"}