paskia 0.7.2__py3-none-any.whl → 0.8.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 (39) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +12 -49
  3. paskia/bootstrap.py +30 -25
  4. paskia/db/__init__.py +163 -401
  5. paskia/db/background.py +128 -0
  6. paskia/db/jsonl.py +132 -0
  7. paskia/db/operations.py +1241 -0
  8. paskia/db/structs.py +148 -0
  9. paskia/fastapi/admin.py +456 -215
  10. paskia/fastapi/api.py +16 -15
  11. paskia/fastapi/authz.py +7 -2
  12. paskia/fastapi/mainapp.py +2 -1
  13. paskia/fastapi/remote.py +20 -20
  14. paskia/fastapi/reset.py +9 -10
  15. paskia/fastapi/user.py +10 -18
  16. paskia/fastapi/ws.py +22 -19
  17. paskia/frontend-build/auth/admin/index.html +3 -3
  18. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +8 -0
  19. paskia/frontend-build/auth/assets/admin-BeNu48FR.css +1 -0
  20. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +1 -0
  21. paskia/frontend-build/auth/assets/{auth-BU_O38k2.css → auth-BKX7shEe.css} +1 -1
  22. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +1 -0
  23. paskia/frontend-build/auth/index.html +3 -3
  24. paskia/globals.py +7 -10
  25. paskia/migrate/__init__.py +274 -0
  26. paskia/migrate/sql.py +381 -0
  27. paskia/util/permutil.py +16 -5
  28. paskia/util/sessionutil.py +3 -2
  29. paskia/util/userinfo.py +12 -26
  30. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/METADATA +21 -25
  31. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/RECORD +33 -29
  32. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/entry_points.txt +1 -0
  33. paskia/db/sql.py +0 -1424
  34. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +0 -8
  35. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +0 -1
  36. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +0 -1
  37. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +0 -1
  38. paskia/util/tokens.py +0 -44
  39. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/WHEEL +0 -0
paskia/fastapi/admin.py CHANGED
@@ -5,24 +5,56 @@ from uuid import UUID, uuid4
5
5
  from fastapi import Body, FastAPI, HTTPException, Request, Response
6
6
  from fastapi.responses import JSONResponse
7
7
 
8
- from paskia.authsession import reset_expires
8
+ from paskia import db
9
+ from paskia.authsession import EXPIRES, reset_expires
9
10
  from paskia.fastapi import authz
10
11
  from paskia.fastapi.session import AUTH_COOKIE
11
- from paskia.globals import db
12
12
  from paskia.util import (
13
13
  frontend,
14
14
  hostutil,
15
15
  passphrase,
16
16
  permutil,
17
17
  querysafe,
18
- tokens,
19
18
  useragent,
20
19
  )
21
- from paskia.util.tokens import encode_session_key, session_key
22
20
 
23
21
  app = FastAPI()
24
22
 
25
23
 
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
+
33
+
34
+ def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
35
+ """Check if user has org admin permission.
36
+
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 [])
44
+ )
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
+
52
+
53
+ 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)
56
+
57
+
26
58
  @app.exception_handler(ValueError)
27
59
  async def value_error_handler(_request, exc: ValueError): # pragma: no cover - simple
28
60
  return JSONResponse(status_code=400, content={"detail": str(exc)})
@@ -55,13 +87,14 @@ async def adminapp(request: Request, auth=AUTH_COOKIE):
55
87
  async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
56
88
  ctx = await authz.verify(
57
89
  auth,
58
- ["auth:admin", "auth:org:*"],
90
+ ["auth:admin", "auth:org:admin"],
59
91
  match=permutil.has_any,
60
92
  host=request.headers.get("host"),
61
93
  )
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]
94
+ orgs = db.list_organizations()
95
+ if not is_global_admin(ctx):
96
+ # Org admins can only see their own organization
97
+ orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
65
98
 
66
99
  def role_to_dict(r):
67
100
  return {
@@ -72,7 +105,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
72
105
  }
73
106
 
74
107
  async def org_to_dict(o):
75
- users = await db.instance.get_organization_users(str(o.uuid))
108
+ users = db.get_organization_users(str(o.uuid))
76
109
  return {
77
110
  "uuid": str(o.uuid),
78
111
  "display_name": o.display_name,
@@ -97,67 +130,43 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
97
130
  async def admin_create_org(
98
131
  request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
99
132
  ):
100
- await authz.verify(
133
+ ctx = await authz.verify(
101
134
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
102
135
  )
103
136
  from ..db import Org as OrgDC # local import to avoid cycles
104
- from ..db import Role as RoleDC # local import to avoid cycles
105
137
 
106
138
  org_uuid = uuid4()
107
139
  display_name = payload.get("display_name") or "New Organization"
108
140
  permissions = payload.get("permissions") or []
109
141
  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)
142
+ db.create_organization(org, ctx=ctx)
121
143
 
122
144
  return {"uuid": str(org_uuid)}
123
145
 
124
146
 
125
- @app.put("/orgs/{org_uuid}")
126
- async def admin_update_org(
147
+ @app.patch("/orgs/{org_uuid}")
148
+ async def admin_update_org_name(
127
149
  org_uuid: UUID,
128
150
  request: Request,
129
151
  payload: dict = Body(...),
130
152
  auth=AUTH_COOKIE,
131
153
  ):
154
+ """Update organization display name only."""
132
155
  ctx = await authz.verify(
133
156
  auth,
134
- ["auth:admin", f"auth:org:{org_uuid}"],
157
+ ["auth:admin", "auth:org:admin"],
135
158
  match=permutil.has_any,
136
159
  host=request.headers.get("host"),
137
160
  )
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
- )
161
+ if not can_manage_org(ctx, org_uuid):
162
+ raise authz.AuthException(
163
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
164
+ )
165
+ display_name = payload.get("display_name")
166
+ if not display_name:
167
+ raise ValueError("display_name is required")
158
168
 
159
- org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
160
- await db.instance.update_organization(org)
169
+ db.update_organization_name(org_uuid, display_name, ctx=ctx)
161
170
  return {"status": "ok"}
162
171
 
163
172
 
@@ -165,29 +174,33 @@ async def admin_update_org(
165
174
  async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
166
175
  ctx = await authz.verify(
167
176
  auth,
168
- ["auth:admin", f"auth:org:{org_uuid}"],
177
+ ["auth:admin", "auth:org:admin"],
169
178
  match=permutil.has_any,
170
179
  host=request.headers.get("host"),
171
180
  max_age="5m",
172
181
  )
182
+ if not can_manage_org(ctx, org_uuid):
183
+ raise authz.AuthException(
184
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
185
+ )
173
186
  if ctx.org.uuid == org_uuid:
174
187
  raise ValueError("Cannot delete the organization you belong to")
175
188
 
176
189
  # Delete organization-specific permissions
177
190
  org_perm_pattern = f"org:{str(org_uuid).lower()}"
178
- all_permissions = await db.instance.list_permissions()
191
+ all_permissions = db.list_permissions()
179
192
  for perm in all_permissions:
180
- perm_id_lower = perm.id.lower()
193
+ perm_scope_lower = perm.scope.lower()
181
194
  # Check if permission contains "org:{uuid}" separated by colons or at boundaries
182
195
  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
196
+ f":{org_perm_pattern}:" in perm_scope_lower
197
+ or perm_scope_lower.startswith(f"{org_perm_pattern}:")
198
+ or perm_scope_lower.endswith(f":{org_perm_pattern}")
199
+ or perm_scope_lower == org_perm_pattern
187
200
  ):
188
- await db.instance.delete_permission(perm.id)
201
+ db.delete_permission(str(perm.uuid), ctx=ctx)
189
202
 
190
- await db.instance.delete_organization(org_uuid)
203
+ db.delete_organization(org_uuid, ctx=ctx)
191
204
  return {"status": "ok"}
192
205
 
193
206
 
@@ -198,10 +211,10 @@ async def admin_add_org_permission(
198
211
  request: Request,
199
212
  auth=AUTH_COOKIE,
200
213
  ):
201
- await authz.verify(
214
+ ctx = await authz.verify(
202
215
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
203
216
  )
204
- await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
217
+ db.add_permission_to_organization(str(org_uuid), permission_id, ctx=ctx)
205
218
  return {"status": "ok"}
206
219
 
207
220
 
@@ -212,10 +225,20 @@ async def admin_remove_org_permission(
212
225
  request: Request,
213
226
  auth=AUTH_COOKIE,
214
227
  ):
215
- await authz.verify(
228
+ ctx = await authz.verify(
216
229
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
217
230
  )
218
- await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
231
+
232
+ # 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:
234
+ # Check if any other org grants auth:admin that we're a member of
235
+ # (we only know our current org, so this effectively means we can't remove it from our own org)
236
+ raise ValueError(
237
+ "Cannot remove auth:admin from your own organization. "
238
+ "This would lock you out of admin access."
239
+ )
240
+
241
+ db.remove_permission_from_organization(str(org_uuid), permission_id, ctx=ctx)
219
242
  return {"status": "ok"}
220
243
 
221
244
 
@@ -229,80 +252,154 @@ async def admin_create_role(
229
252
  payload: dict = Body(...),
230
253
  auth=AUTH_COOKIE,
231
254
  ):
232
- await authz.verify(
255
+ ctx = await authz.verify(
233
256
  auth,
234
- ["auth:admin", f"auth:org:{org_uuid}"],
257
+ ["auth:admin", "auth:org:admin"],
235
258
  match=permutil.has_any,
236
259
  host=request.headers.get("host"),
237
260
  )
261
+ if not can_manage_org(ctx, org_uuid):
262
+ raise authz.AuthException(
263
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
264
+ )
238
265
  from ..db import Role as RoleDC
239
266
 
240
267
  role_uuid = uuid4()
241
268
  display_name = payload.get("display_name") or "New Role"
242
269
  perms = payload.get("permissions") or []
243
- org = await db.instance.get_organization(str(org_uuid))
270
+ org = db.get_organization(str(org_uuid))
244
271
  grantable = set(org.permissions or [])
272
+
273
+ # Normalize permission IDs to UUIDs
274
+ permission_uuids = []
245
275
  for pid in perms:
246
- await db.instance.get_permission(pid)
247
- if pid not in grantable:
276
+ perm = db.get_permission(pid)
277
+ if not perm:
278
+ raise ValueError(f"Permission {pid} not found")
279
+ perm_uuid_str = str(perm.uuid)
280
+ if perm_uuid_str not in grantable:
248
281
  raise ValueError(f"Permission not grantable by org: {pid}")
282
+ permission_uuids.append(perm_uuid_str)
283
+
249
284
  role = RoleDC(
250
285
  uuid=role_uuid,
251
286
  org_uuid=org_uuid,
252
287
  display_name=display_name,
253
- permissions=perms,
288
+ permissions=permission_uuids,
254
289
  )
255
- await db.instance.create_role(role)
290
+ db.create_role(role, ctx=ctx)
256
291
  return {"uuid": str(role_uuid)}
257
292
 
258
293
 
259
- @app.put("/orgs/{org_uuid}/roles/{role_uuid}")
260
- async def admin_update_role(
294
+ @app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
295
+ async def admin_update_role_name(
261
296
  org_uuid: UUID,
262
297
  role_uuid: UUID,
263
298
  request: Request,
264
299
  payload: dict = Body(...),
265
300
  auth=AUTH_COOKIE,
266
301
  ):
267
- # Verify caller is global admin or admin of provided org
302
+ """Update role display name only."""
268
303
  ctx = await authz.verify(
269
304
  auth,
270
- ["auth:admin", f"auth:org:{org_uuid}"],
305
+ ["auth:admin", "auth:org:admin"],
271
306
  match=permutil.has_any,
272
307
  host=request.headers.get("host"),
273
308
  )
274
- role = await db.instance.get_role(role_uuid)
309
+ if not can_manage_org(ctx, org_uuid):
310
+ raise authz.AuthException(
311
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
312
+ )
313
+ role = db.get_role(role_uuid)
275
314
  if role.org_uuid != org_uuid:
276
315
  raise HTTPException(status_code=404, detail="Role not found in organization")
277
- from ..db import Role as RoleDC
278
316
 
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}")
317
+ display_name = payload.get("display_name")
318
+ if not display_name:
319
+ raise ValueError("display_name is required")
290
320
 
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
321
+ db.update_role_name(role_uuid, display_name, ctx=ctx)
322
+ return {"status": "ok"}
323
+
324
+
325
+ @app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
326
+ async def admin_add_role_permission(
327
+ org_uuid: UUID,
328
+ role_uuid: UUID,
329
+ permission_uuid: UUID,
330
+ request: Request,
331
+ auth=AUTH_COOKIE,
332
+ ):
333
+ """Add a permission to a role (intent-based API)."""
334
+ ctx = await authz.verify(
335
+ auth,
336
+ ["auth:admin", "auth:org:admin"],
337
+ match=permutil.has_any,
338
+ host=request.headers.get("host"),
339
+ )
340
+ if not can_manage_org(ctx, org_uuid):
341
+ raise authz.AuthException(
342
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
295
343
  )
296
- if not has_admin_access:
297
- raise ValueError("Cannot update your own role to remove admin permissions")
298
344
 
299
- updated = RoleDC(
300
- uuid=role_uuid,
301
- org_uuid=org_uuid,
302
- display_name=display_name,
303
- permissions=permissions,
345
+ role = db.get_role(role_uuid)
346
+ if role.org_uuid != org_uuid:
347
+ raise HTTPException(status_code=404, detail="Role not found in organization")
348
+
349
+ # Verify permission exists and org can grant it
350
+ perm = db.get_permission(permission_uuid)
351
+ if not perm:
352
+ 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:
355
+ raise ValueError("Permission not grantable by organization")
356
+
357
+ db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
358
+ return {"status": "ok"}
359
+
360
+
361
+ @app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
362
+ async def admin_remove_role_permission(
363
+ org_uuid: UUID,
364
+ role_uuid: UUID,
365
+ permission_uuid: UUID,
366
+ request: Request,
367
+ auth=AUTH_COOKIE,
368
+ ):
369
+ """Remove a permission from a role (intent-based API)."""
370
+ ctx = await authz.verify(
371
+ auth,
372
+ ["auth:admin", "auth:org:admin"],
373
+ match=permutil.has_any,
374
+ host=request.headers.get("host"),
304
375
  )
305
- await db.instance.update_role(updated)
376
+ if not can_manage_org(ctx, org_uuid):
377
+ raise authz.AuthException(
378
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
379
+ )
380
+
381
+ role = db.get_role(role_uuid)
382
+ if role.org_uuid != org_uuid:
383
+ raise HTTPException(status_code=404, detail="Role not found in organization")
384
+
385
+ # 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)
389
+ if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
390
+ if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
391
+ # Check if removing this permission would leave no admin access
392
+ remaining_perms = set(role.permissions) - {perm_uuid_str}
393
+ has_admin = False
394
+ for rp_uuid in remaining_perms:
395
+ rp = db.get_permission(rp_uuid)
396
+ if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
397
+ has_admin = True
398
+ break
399
+ if not has_admin:
400
+ raise ValueError("Cannot remove your own admin permissions")
401
+
402
+ db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
306
403
  return {"status": "ok"}
307
404
 
308
405
 
@@ -315,12 +412,16 @@ async def admin_delete_role(
315
412
  ):
316
413
  ctx = await authz.verify(
317
414
  auth,
318
- ["auth:admin", f"auth:org:{org_uuid}"],
415
+ ["auth:admin", "auth:org:admin"],
319
416
  match=permutil.has_any,
320
417
  host=request.headers.get("host"),
321
418
  max_age="5m",
322
419
  )
323
- role = await db.instance.get_role(role_uuid)
420
+ if not can_manage_org(ctx, org_uuid):
421
+ raise authz.AuthException(
422
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
423
+ )
424
+ role = db.get_role(role_uuid)
324
425
  if role.org_uuid != org_uuid:
325
426
  raise HTTPException(status_code=404, detail="Role not found in organization")
326
427
 
@@ -328,7 +429,7 @@ async def admin_delete_role(
328
429
  if ctx.role.uuid == role_uuid:
329
430
  raise ValueError("Cannot delete your own role")
330
431
 
331
- await db.instance.delete_role(role_uuid)
432
+ db.delete_role(role_uuid, ctx=ctx)
332
433
  return {"status": "ok"}
333
434
 
334
435
 
@@ -342,19 +443,23 @@ async def admin_create_user(
342
443
  payload: dict = Body(...),
343
444
  auth=AUTH_COOKIE,
344
445
  ):
345
- await authz.verify(
446
+ ctx = await authz.verify(
346
447
  auth,
347
- ["auth:admin", f"auth:org:{org_uuid}"],
448
+ ["auth:admin", "auth:org:admin"],
348
449
  match=permutil.has_any,
349
450
  host=request.headers.get("host"),
350
451
  )
452
+ if not can_manage_org(ctx, org_uuid):
453
+ raise authz.AuthException(
454
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
455
+ )
351
456
  display_name = payload.get("display_name")
352
457
  role_name = payload.get("role")
353
458
  if not display_name or not role_name:
354
459
  raise ValueError("display_name and role are required")
355
460
  from ..db import User as UserDC
356
461
 
357
- roles = await db.instance.get_roles_by_organization(str(org_uuid))
462
+ roles = db.get_roles_by_organization(str(org_uuid))
358
463
  role_obj = next((r for r in roles if r.display_name == role_name), None)
359
464
  if not role_obj:
360
465
  raise ValueError("Role not found in organization")
@@ -366,11 +471,11 @@ async def admin_create_user(
366
471
  visits=0,
367
472
  created_at=None,
368
473
  )
369
- await db.instance.create_user(user)
474
+ db.create_user(user, ctx=ctx)
370
475
  return {"uuid": str(user_uuid)}
371
476
 
372
477
 
373
- @app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
478
+ @app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
374
479
  async def admin_update_user_role(
375
480
  org_uuid: UUID,
376
481
  user_uuid: UUID,
@@ -380,20 +485,24 @@ async def admin_update_user_role(
380
485
  ):
381
486
  ctx = await authz.verify(
382
487
  auth,
383
- ["auth:admin", f"auth:org:{org_uuid}"],
488
+ ["auth:admin", "auth:org:admin"],
384
489
  match=permutil.has_any,
385
490
  host=request.headers.get("host"),
386
491
  )
492
+ if not can_manage_org(ctx, org_uuid):
493
+ raise authz.AuthException(
494
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
495
+ )
387
496
  new_role = payload.get("role")
388
497
  if not new_role:
389
498
  raise ValueError("role is required")
390
499
  try:
391
- user_org, _current_role = await db.instance.get_user_organization(user_uuid)
500
+ user_org, _current_role = db.get_user_organization(user_uuid)
392
501
  except ValueError:
393
502
  raise ValueError("User not found")
394
503
  if user_org.uuid != org_uuid:
395
504
  raise ValueError("User does not belong to this organization")
396
- roles = await db.instance.get_roles_by_organization(str(org_uuid))
505
+ roles = db.get_roles_by_organization(str(org_uuid))
397
506
  if not any(r.display_name == new_role for r in roles):
398
507
  raise ValueError("Role not found in organization")
399
508
 
@@ -401,16 +510,19 @@ async def admin_update_user_role(
401
510
  if ctx.user.uuid == user_uuid:
402
511
  new_role_obj = next((r for r in roles if r.display_name == new_role), None)
403
512
  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
- )
513
+ # Check if any permission in the new role is an admin permission
514
+ has_admin_access = False
515
+ for perm_uuid in new_role_obj.permissions:
516
+ perm = db.get_permission(perm_uuid)
517
+ if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
518
+ has_admin_access = True
519
+ break
408
520
  if not has_admin_access:
409
521
  raise ValueError(
410
522
  "Cannot change your own role to one without admin permissions"
411
523
  )
412
524
 
413
- await db.instance.update_user_role_in_organization(user_uuid, new_role)
525
+ db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx)
414
526
  return {"status": "ok"}
415
527
 
416
528
 
@@ -422,37 +534,35 @@ async def admin_create_user_registration_link(
422
534
  auth=AUTH_COOKIE,
423
535
  ):
424
536
  try:
425
- user_org, _role_name = await db.instance.get_user_organization(user_uuid)
537
+ user_org, _role_name = db.get_user_organization(user_uuid)
426
538
  except ValueError:
427
539
  raise HTTPException(status_code=404, detail="User not found")
428
540
  if user_org.uuid != org_uuid:
429
541
  raise HTTPException(status_code=404, detail="User not found in organization")
430
542
  ctx = await authz.verify(
431
543
  auth,
432
- ["auth:admin", f"auth:org:{org_uuid}"],
544
+ ["auth:admin", "auth:org:admin"],
433
545
  match=permutil.has_any,
434
546
  host=request.headers.get("host"),
435
547
  max_age="5m",
436
548
  )
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
- ):
549
+ if not can_manage_org(ctx, org_uuid):
441
550
  raise authz.AuthException(
442
551
  status_code=403, detail="Insufficient permissions", mode="forbidden"
443
552
  )
444
553
 
445
554
  # Check if user has existing credentials
446
- credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
555
+ credentials = db.get_credentials_by_user_uuid(user_uuid)
447
556
  token_type = "user registration" if not credentials else "account recovery"
448
557
 
449
558
  token = passphrase.generate()
450
559
  expiry = reset_expires()
451
- await db.instance.create_reset_token(
560
+ db.create_reset_token(
452
561
  user_uuid=user_uuid,
453
- key=tokens.reset_key(token),
562
+ passphrase=token,
454
563
  expiry=expiry,
455
564
  token_type=token_type,
565
+ ctx=ctx,
456
566
  )
457
567
  url = hostutil.reset_link_url(token)
458
568
  return {
@@ -473,33 +583,26 @@ async def admin_get_user_detail(
473
583
  auth=AUTH_COOKIE,
474
584
  ):
475
585
  try:
476
- user_org, role_name = await db.instance.get_user_organization(user_uuid)
586
+ user_org, role_name = db.get_user_organization(user_uuid)
477
587
  except ValueError:
478
588
  raise HTTPException(status_code=404, detail="User not found")
479
589
  if user_org.uuid != org_uuid:
480
590
  raise HTTPException(status_code=404, detail="User not found in organization")
481
591
  ctx = await authz.verify(
482
592
  auth,
483
- ["auth:admin", f"auth:org:{org_uuid}"],
593
+ ["auth:admin", "auth:org:admin"],
484
594
  match=permutil.has_any,
485
595
  host=request.headers.get("host"),
486
596
  )
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
- ):
597
+ if not can_manage_org(ctx, org_uuid):
491
598
  raise authz.AuthException(
492
599
  status_code=403, detail="Insufficient permissions", mode="forbidden"
493
600
  )
494
- user = await db.instance.get_user_by_uuid(user_uuid)
495
- cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
601
+ user = db.get_user_by_uuid(user_uuid)
602
+ user_creds = db.get_credentials_by_user_uuid(user_uuid)
496
603
  creds: list[dict] = []
497
604
  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
605
+ for c in user_creds:
503
606
  aaguid_str = str(c.aaguid)
504
607
  aaguids.add(aaguid_str)
505
608
  creds.append(
@@ -552,23 +655,22 @@ async def admin_get_user_detail(
552
655
 
553
656
  # Get sessions for the user
554
657
  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)
658
+ session_records = db.list_sessions_for_user(user_uuid)
659
+ current_session_key = auth
557
660
  sessions_payload: list[dict] = []
558
661
  for entry in session_records:
662
+ renewed = entry.expiry - EXPIRES
559
663
  sessions_payload.append(
560
664
  {
561
- "id": encode_session_key(entry.key),
665
+ "id": entry.key,
562
666
  "credential_uuid": str(entry.credential_uuid),
563
667
  "host": entry.host,
564
668
  "ip": entry.ip,
565
669
  "user_agent": useragent.compact_user_agent(entry.user_agent),
566
670
  "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)
671
+ renewed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
672
+ if renewed.tzinfo
673
+ else renewed.replace(tzinfo=timezone.utc)
572
674
  .isoformat()
573
675
  .replace("+00:00", "Z")
574
676
  ),
@@ -614,7 +716,7 @@ async def admin_get_user_detail(
614
716
  }
615
717
 
616
718
 
617
- @app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
719
+ @app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
618
720
  async def admin_update_user_display_name(
619
721
  org_uuid: UUID,
620
722
  user_uuid: UUID,
@@ -623,21 +725,18 @@ async def admin_update_user_display_name(
623
725
  auth=AUTH_COOKIE,
624
726
  ):
625
727
  try:
626
- user_org, _role_name = await db.instance.get_user_organization(user_uuid)
728
+ user_org, _role_name = db.get_user_organization(user_uuid)
627
729
  except ValueError:
628
730
  raise HTTPException(status_code=404, detail="User not found")
629
731
  if user_org.uuid != org_uuid:
630
732
  raise HTTPException(status_code=404, detail="User not found in organization")
631
733
  ctx = await authz.verify(
632
734
  auth,
633
- ["auth:admin", f"auth:org:{org_uuid}"],
735
+ ["auth:admin", "auth:org:admin"],
634
736
  match=permutil.has_any,
635
737
  host=request.headers.get("host"),
636
738
  )
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
- ):
739
+ if not can_manage_org(ctx, org_uuid):
641
740
  raise authz.AuthException(
642
741
  status_code=403, detail="Insufficient permissions", mode="forbidden"
643
742
  )
@@ -646,7 +745,7 @@ async def admin_update_user_display_name(
646
745
  raise HTTPException(status_code=400, detail="display_name required")
647
746
  if len(new_name) > 64:
648
747
  raise HTTPException(status_code=400, detail="display_name too long")
649
- await db.instance.update_user_display_name(user_uuid, new_name)
748
+ db.update_user_display_name(user_uuid, new_name, ctx=ctx)
650
749
  return {"status": "ok"}
651
750
 
652
751
 
@@ -659,26 +758,23 @@ async def admin_delete_user_credential(
659
758
  auth=AUTH_COOKIE,
660
759
  ):
661
760
  try:
662
- user_org, _role_name = await db.instance.get_user_organization(user_uuid)
761
+ user_org, _role_name = db.get_user_organization(user_uuid)
663
762
  except ValueError:
664
763
  raise HTTPException(status_code=404, detail="User not found")
665
764
  if user_org.uuid != org_uuid:
666
765
  raise HTTPException(status_code=404, detail="User not found in organization")
667
766
  ctx = await authz.verify(
668
767
  auth,
669
- ["auth:admin", f"auth:org:{org_uuid}"],
768
+ ["auth:admin", "auth:org:admin"],
670
769
  match=permutil.has_any,
671
770
  host=request.headers.get("host"),
672
771
  max_age="5m",
673
772
  )
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
- ):
773
+ if not can_manage_org(ctx, org_uuid):
678
774
  raise authz.AuthException(
679
775
  status_code=403, detail="Insufficient permissions", mode="forbidden"
680
776
  )
681
- await db.instance.delete_credential(credential_uuid, user_uuid)
777
+ db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
682
778
  return {"status": "ok"}
683
779
 
684
780
 
@@ -691,64 +787,147 @@ async def admin_delete_user_session(
691
787
  auth=AUTH_COOKIE,
692
788
  ):
693
789
  try:
694
- user_org, _role_name = await db.instance.get_user_organization(user_uuid)
790
+ user_org, _role_name = db.get_user_organization(user_uuid)
695
791
  except ValueError:
696
792
  raise HTTPException(status_code=404, detail="User not found")
697
793
  if user_org.uuid != org_uuid:
698
794
  raise HTTPException(status_code=404, detail="User not found in organization")
699
795
  ctx = await authz.verify(
700
796
  auth,
701
- ["auth:admin", f"auth:org:{org_uuid}"],
797
+ ["auth:admin", "auth:org:admin"],
702
798
  match=permutil.has_any,
703
799
  host=request.headers.get("host"),
704
800
  )
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
- ):
801
+ if not can_manage_org(ctx, org_uuid):
709
802
  raise authz.AuthException(
710
803
  status_code=403, detail="Insufficient permissions", mode="forbidden"
711
804
  )
712
805
 
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)
806
+ target_session = db.get_session(session_id)
721
807
  if not target_session or target_session.user_uuid != user_uuid:
722
808
  raise HTTPException(status_code=404, detail="Session not found")
723
809
 
724
- await db.instance.delete_session(target_key)
810
+ db.delete_session(session_id, ctx=ctx)
725
811
 
726
812
  # Check if admin terminated their own session
727
- current_terminated = target_key == session_key(auth)
813
+ current_terminated = session_id == auth
728
814
  return {"status": "ok", "current_session_terminated": current_terminated}
729
815
 
730
816
 
731
817
  # -------------------- Permissions (global) --------------------
732
818
 
733
819
 
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
+ def _validate_permission_domain(domain: str | None) -> None:
829
+ """Validate that domain is rp_id or a subdomain of it."""
830
+ if domain is None:
831
+ return
832
+ from paskia.globals import passkey
833
+
834
+ rp_id = passkey.instance.rp_id
835
+ if domain == rp_id or domain.endswith(f".{rp_id}"):
836
+ return
837
+ raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
838
+
839
+
840
+ def _check_admin_lockout(
841
+ perm_uuid: str, new_domain: str | None, current_host: str | None
842
+ ) -> None:
843
+ """Check if setting domain on auth:admin would lock out the admin.
844
+
845
+ Raises ValueError if this change would result in no auth:admin permissions
846
+ being accessible from the current host.
847
+ """
848
+ from paskia.util.hostutil import normalize_host
849
+
850
+ normalized_host = normalize_host(current_host)
851
+ host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
852
+
853
+ # Get all auth:admin permissions
854
+ all_perms = db.list_permissions()
855
+ admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
856
+
857
+ # Check if at least one auth:admin would remain accessible
858
+ for p in admin_perms:
859
+ # If this is the permission being modified, use the new domain
860
+ domain = new_domain if str(p.uuid) == perm_uuid else p.domain
861
+
862
+ # No domain restriction = accessible from anywhere
863
+ if domain is None:
864
+ return
865
+
866
+ # Domain matches current host
867
+ if host_without_port and domain == host_without_port:
868
+ return
869
+
870
+ # No accessible auth:admin permission would remain
871
+ raise ValueError(
872
+ "Cannot set this domain restriction: it would lock you out of admin access. "
873
+ "Ensure at least one auth:admin permission remains accessible from your current host."
874
+ )
875
+
876
+
877
+ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
878
+ """Check if deleting an auth:admin permission would lock out the admin.
879
+
880
+ Raises ValueError if this deletion would result in no auth:admin permissions
881
+ being accessible from the current host.
882
+ """
883
+ from paskia.util.hostutil import normalize_host
884
+
885
+ normalized_host = normalize_host(current_host)
886
+ host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
887
+
888
+ # Get all auth:admin permissions
889
+ all_perms = db.list_permissions()
890
+ admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
891
+
892
+ # Check if at least one auth:admin would remain accessible after deletion
893
+ for p in admin_perms:
894
+ # Skip the permission being deleted
895
+ if str(p.uuid) == perm_uuid:
896
+ continue
897
+
898
+ # No domain restriction = accessible from anywhere
899
+ if p.domain is None:
900
+ return
901
+
902
+ # Domain matches current host
903
+ if host_without_port and p.domain == host_without_port:
904
+ return
905
+
906
+ # No accessible auth:admin permission would remain
907
+ raise ValueError(
908
+ "Cannot delete this permission: it would lock you out of admin access. "
909
+ "Ensure at least one auth:admin permission remains accessible from your current host."
910
+ )
911
+
912
+
734
913
  @app.get("/permissions")
735
914
  async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
736
915
  ctx = await authz.verify(
737
916
  auth,
738
- ["auth:admin", "auth:org:*"],
917
+ ["auth:admin", "auth:org:admin"],
739
918
  match=permutil.has_any,
740
919
  host=request.headers.get("host"),
741
920
  )
742
- perms = await db.instance.list_permissions()
921
+ perms = db.list_permissions()
743
922
 
744
923
  # 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]
924
+ if is_global_admin(ctx):
925
+ return [_perm_to_dict(p) for p in perms]
747
926
 
748
- # Org admins only see permissions their org can grant
927
+ # Org admins only see permissions their org can grant (by UUID)
749
928
  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]
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]
752
931
 
753
932
 
754
933
  @app.post("/permissions")
@@ -757,41 +936,81 @@ async def admin_create_permission(
757
936
  payload: dict = Body(...),
758
937
  auth=AUTH_COOKIE,
759
938
  ):
760
- await authz.verify(
939
+ ctx = await authz.verify(
761
940
  auth,
762
941
  ["auth:admin"],
763
942
  host=request.headers.get("host"),
764
943
  match=permutil.has_all,
765
944
  max_age="5m",
766
945
  )
946
+ import uuid7
947
+
767
948
  from ..db import Permission as PermDC
768
949
 
769
- perm_id = payload.get("id")
950
+ scope = payload.get("scope") or payload.get(
951
+ "id"
952
+ ) # Support both for backwards compat
770
953
  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))
954
+ domain = payload.get("domain") or None # Treat empty string as None
955
+ if not scope or not display_name:
956
+ raise ValueError("scope and display_name are required")
957
+ querysafe.assert_safe(scope, field="scope")
958
+ _validate_permission_domain(domain)
959
+ db.create_permission(
960
+ PermDC(
961
+ uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain
962
+ ),
963
+ ctx=ctx,
964
+ )
775
965
  return {"status": "ok"}
776
966
 
777
967
 
778
- @app.put("/permission")
968
+ @app.patch("/permission")
779
969
  async def admin_update_permission(
780
- permission_id: str,
781
- display_name: str,
782
970
  request: Request,
783
971
  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,
784
977
  ):
785
- await authz.verify(
978
+ ctx = await authz.verify(
786
979
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
787
980
  )
788
- from ..db import Permission as PermDC
789
981
 
790
- if not display_name:
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
+ # Get existing permission
988
+ perm = db.get_permission(perm_identifier)
989
+
990
+ # Update fields that were provided
991
+ new_scope = scope if scope is not None else perm.scope
992
+ new_display_name = display_name if display_name is not None else perm.display_name
993
+ domain_value = domain if domain else None
994
+
995
+ if not new_display_name:
791
996
  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)
997
+ querysafe.assert_safe(new_scope, field="scope")
998
+ _validate_permission_domain(domain_value)
999
+
1000
+ # Safety check: prevent admin lockout when setting domain on auth:admin
1001
+ if perm.scope == "auth:admin" or new_scope == "auth:admin":
1002
+ _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
1003
+
1004
+ from ..db import Permission as PermDC
1005
+
1006
+ db.update_permission(
1007
+ PermDC(
1008
+ uuid=perm.uuid,
1009
+ scope=new_scope,
1010
+ display_name=new_display_name,
1011
+ domain=domain_value,
1012
+ ),
1013
+ ctx=ctx,
795
1014
  )
796
1015
  return {"status": "ok"}
797
1016
 
@@ -802,49 +1021,71 @@ async def admin_rename_permission(
802
1021
  payload: dict = Body(...),
803
1022
  auth=AUTH_COOKIE,
804
1023
  ):
805
- await authz.verify(
1024
+ ctx = await authz.verify(
806
1025
  auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
807
1026
  )
808
- old_id = payload.get("old_id")
809
- new_id = payload.get("new_id")
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
810
1029
  display_name = payload.get("display_name")
811
- if not old_id or not new_id:
812
- raise ValueError("old_id and new_id required")
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")
813
1035
 
814
1036
  # Sanity check: prevent renaming critical permissions
815
- if old_id == "auth:admin":
1037
+ if old_scope == "auth:admin":
816
1038
  raise ValueError("Cannot rename the master admin permission")
817
1039
 
818
- querysafe.assert_safe(old_id, field="old_id")
819
- querysafe.assert_safe(new_id, field="new_id")
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)
820
1045
  if display_name is None:
821
- perm = await db.instance.get_permission(old_id)
822
1046
  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)
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)
827
1060
  return {"status": "ok"}
828
1061
 
829
1062
 
830
1063
  @app.delete("/permission")
831
1064
  async def admin_delete_permission(
832
- permission_id: str,
833
1065
  request: Request,
834
1066
  auth=AUTH_COOKIE,
1067
+ permission_uuid: str | None = None,
1068
+ permission_id: str | None = None, # Backwards compat - treated as scope
835
1069
  ):
836
- await authz.verify(
1070
+ ctx = await authz.verify(
837
1071
  auth,
838
1072
  ["auth:admin"],
839
1073
  host=request.headers.get("host"),
840
1074
  match=permutil.has_all,
841
1075
  max_age="5m",
842
1076
  )
843
- querysafe.assert_safe(permission_id, field="permission_id")
844
1077
 
845
- # Sanity check: prevent deleting critical permissions
846
- if permission_id == "auth:admin":
847
- raise ValueError("Cannot delete the master admin permission")
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
+ # Get the permission to check its scope
1084
+ perm = db.get_permission(perm_identifier)
1085
+
1086
+ # Sanity check: prevent deleting critical permissions if it would lock out admin
1087
+ if perm.scope == "auth:admin":
1088
+ _check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
848
1089
 
849
- await db.instance.delete_permission(permission_id)
1090
+ db.delete_permission(str(perm.uuid), ctx=ctx)
850
1091
  return {"status": "ok"}