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.
- paskia/_version.py +2 -2
- paskia/authsession.py +14 -27
- paskia/bootstrap.py +31 -103
- paskia/db/__init__.py +25 -51
- paskia/db/background.py +17 -37
- paskia/db/jsonl.py +168 -6
- paskia/db/migrations.py +34 -0
- paskia/db/operations.py +400 -723
- paskia/db/structs.py +214 -90
- paskia/fastapi/__main__.py +24 -28
- paskia/fastapi/admin.py +101 -160
- paskia/fastapi/api.py +47 -83
- paskia/fastapi/mainapp.py +13 -6
- paskia/fastapi/remote.py +16 -39
- paskia/fastapi/reset.py +27 -17
- paskia/fastapi/session.py +2 -2
- paskia/fastapi/user.py +21 -27
- paskia/fastapi/ws.py +27 -62
- paskia/fastapi/wschat.py +62 -0
- paskia/frontend-build/auth/admin/index.html +5 -5
- paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
- paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
- paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
- paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
- paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
- paskia/frontend-build/auth/index.html +5 -5
- paskia/frontend-build/auth/restricted/index.html +4 -4
- paskia/frontend-build/int/forward/index.html +4 -4
- paskia/frontend-build/int/reset/index.html +3 -3
- paskia/globals.py +2 -2
- paskia/migrate/__init__.py +62 -55
- paskia/migrate/sql.py +72 -22
- paskia/remoteauth.py +1 -2
- paskia/sansio.py +6 -12
- {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/METADATA +1 -1
- paskia-0.9.0.dist-info/RECORD +57 -0
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
- paskia-0.8.1.dist-info/RECORD +0 -55
- {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
- {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
|
|
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.
|
|
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
|
-
"
|
|
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(
|
|
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":
|
|
113
|
-
|
|
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(
|
|
142
|
-
db.
|
|
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(
|
|
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.
|
|
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.
|
|
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(
|
|
213
|
+
db.delete_permission(perm.uuid, ctx=ctx)
|
|
202
214
|
|
|
203
|
-
db.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
271
|
-
|
|
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.
|
|
291
|
+
perm = db.data().permissions.get(UUID(pid))
|
|
277
292
|
if not perm:
|
|
278
293
|
raise ValueError(f"Permission {pid} not found")
|
|
279
|
-
|
|
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.
|
|
296
|
+
permission_uuids.add(perm.uuid)
|
|
283
297
|
|
|
284
|
-
role = RoleDC(
|
|
285
|
-
|
|
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(
|
|
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.
|
|
314
|
-
if role.
|
|
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.
|
|
346
|
-
if role.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
382
|
-
if role.
|
|
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
|
-
|
|
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 =
|
|
402
|
+
remaining_perms = role.permission_set - {permission_uuid}
|
|
393
403
|
has_admin = False
|
|
394
404
|
for rp_uuid in remaining_perms:
|
|
395
|
-
rp = db.
|
|
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.
|
|
425
|
-
if role.
|
|
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.
|
|
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
|
-
|
|
467
|
-
user = UserDC(
|
|
468
|
-
uuid=user_uuid,
|
|
475
|
+
user = UserDC.create(
|
|
469
476
|
display_name=display_name,
|
|
470
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
556
|
-
token_type = "user registration" if not
|
|
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.
|
|
602
|
-
user_creds = db.
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
807
|
-
if not target_session or target_session.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
929
|
-
|
|
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:
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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.
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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.
|
|
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(
|
|
1031
|
+
db.delete_permission(permission_uuid, ctx=ctx)
|
|
1091
1032
|
return {"status": "ok"}
|