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.
- paskia/_version.py +2 -2
- paskia/authsession.py +12 -49
- paskia/bootstrap.py +30 -25
- paskia/db/__init__.py +163 -401
- paskia/db/background.py +128 -0
- paskia/db/jsonl.py +132 -0
- paskia/db/operations.py +1241 -0
- paskia/db/structs.py +148 -0
- paskia/fastapi/admin.py +456 -215
- paskia/fastapi/api.py +16 -15
- paskia/fastapi/authz.py +7 -2
- paskia/fastapi/mainapp.py +2 -1
- paskia/fastapi/remote.py +20 -20
- paskia/fastapi/reset.py +9 -10
- paskia/fastapi/user.py +10 -18
- paskia/fastapi/ws.py +22 -19
- paskia/frontend-build/auth/admin/index.html +3 -3
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +8 -0
- paskia/frontend-build/auth/assets/admin-BeNu48FR.css +1 -0
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +1 -0
- paskia/frontend-build/auth/assets/{auth-BU_O38k2.css → auth-BKX7shEe.css} +1 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +1 -0
- paskia/frontend-build/auth/index.html +3 -3
- paskia/globals.py +7 -10
- paskia/migrate/__init__.py +274 -0
- paskia/migrate/sql.py +381 -0
- paskia/util/permutil.py +16 -5
- paskia/util/sessionutil.py +3 -2
- paskia/util/userinfo.py +12 -26
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/METADATA +21 -25
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/RECORD +33 -29
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/entry_points.txt +1 -0
- paskia/db/sql.py +0 -1424
- paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +0 -8
- paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +0 -1
- paskia/frontend-build/auth/assets/admin-Df5_Damp.js +0 -1
- paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +0 -1
- paskia/util/tokens.py +0 -44
- {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
|
|
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 =
|
|
63
|
-
if
|
|
64
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
126
|
-
async def
|
|
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",
|
|
157
|
+
["auth:admin", "auth:org:admin"],
|
|
135
158
|
match=permutil.has_any,
|
|
136
159
|
host=request.headers.get("host"),
|
|
137
160
|
)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
|
|
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
|
-
|
|
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",
|
|
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 =
|
|
191
|
+
all_permissions = db.list_permissions()
|
|
179
192
|
for perm in all_permissions:
|
|
180
|
-
|
|
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
|
|
184
|
-
or
|
|
185
|
-
or
|
|
186
|
-
or
|
|
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
|
-
|
|
201
|
+
db.delete_permission(str(perm.uuid), ctx=ctx)
|
|
189
202
|
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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 =
|
|
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
|
-
|
|
247
|
-
if
|
|
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=
|
|
288
|
+
permissions=permission_uuids,
|
|
254
289
|
)
|
|
255
|
-
|
|
290
|
+
db.create_role(role, ctx=ctx)
|
|
256
291
|
return {"uuid": str(role_uuid)}
|
|
257
292
|
|
|
258
293
|
|
|
259
|
-
@app.
|
|
260
|
-
async def
|
|
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
|
-
|
|
302
|
+
"""Update role display name only."""
|
|
268
303
|
ctx = await authz.verify(
|
|
269
304
|
auth,
|
|
270
|
-
["auth:admin",
|
|
305
|
+
["auth:admin", "auth:org:admin"],
|
|
271
306
|
match=permutil.has_any,
|
|
272
307
|
host=request.headers.get("host"),
|
|
273
308
|
)
|
|
274
|
-
|
|
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")
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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 =
|
|
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
|
-
|
|
474
|
+
db.create_user(user, ctx=ctx)
|
|
370
475
|
return {"uuid": str(user_uuid)}
|
|
371
476
|
|
|
372
477
|
|
|
373
|
-
@app.
|
|
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",
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
560
|
+
db.create_reset_token(
|
|
452
561
|
user_uuid=user_uuid,
|
|
453
|
-
|
|
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 =
|
|
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",
|
|
593
|
+
["auth:admin", "auth:org:admin"],
|
|
484
594
|
match=permutil.has_any,
|
|
485
595
|
host=request.headers.get("host"),
|
|
486
596
|
)
|
|
487
|
-
if (
|
|
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 =
|
|
495
|
-
|
|
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
|
|
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 =
|
|
556
|
-
current_session_key =
|
|
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":
|
|
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
|
-
|
|
568
|
-
.
|
|
569
|
-
.replace(
|
|
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.
|
|
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 =
|
|
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",
|
|
735
|
+
["auth:admin", "auth:org:admin"],
|
|
634
736
|
match=permutil.has_any,
|
|
635
737
|
host=request.headers.get("host"),
|
|
636
738
|
)
|
|
637
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
797
|
+
["auth:admin", "auth:org:admin"],
|
|
702
798
|
match=permutil.has_any,
|
|
703
799
|
host=request.headers.get("host"),
|
|
704
800
|
)
|
|
705
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
810
|
+
db.delete_session(session_id, ctx=ctx)
|
|
725
811
|
|
|
726
812
|
# Check if admin terminated their own session
|
|
727
|
-
current_terminated =
|
|
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 =
|
|
921
|
+
perms = db.list_permissions()
|
|
743
922
|
|
|
744
923
|
# Global admins see all permissions
|
|
745
|
-
if
|
|
746
|
-
return [
|
|
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.
|
|
751
|
-
return [
|
|
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
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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
|
|
1037
|
+
if old_scope == "auth:admin":
|
|
816
1038
|
raise ValueError("Cannot rename the master admin permission")
|
|
817
1039
|
|
|
818
|
-
querysafe.assert_safe(
|
|
819
|
-
querysafe.assert_safe(
|
|
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
|
-
|
|
824
|
-
if
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
846
|
-
if
|
|
847
|
-
raise ValueError("
|
|
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
|
-
|
|
1090
|
+
db.delete_permission(str(perm.uuid), ctx=ctx)
|
|
850
1091
|
return {"status": "ok"}
|