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