paskia 0.9.0__py3-none-any.whl → 0.10.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/aaguid/__init__.py +5 -4
- paskia/authsession.py +4 -19
- paskia/db/__init__.py +2 -4
- paskia/db/background.py +3 -3
- paskia/db/jsonl.py +99 -111
- paskia/db/logging.py +318 -0
- paskia/db/migrations.py +19 -20
- paskia/db/operations.py +107 -196
- paskia/db/structs.py +236 -46
- paskia/fastapi/__main__.py +13 -6
- paskia/fastapi/admin.py +72 -195
- paskia/fastapi/api.py +56 -58
- paskia/fastapi/authz.py +3 -8
- paskia/fastapi/logging.py +261 -0
- paskia/fastapi/mainapp.py +14 -3
- paskia/fastapi/remote.py +11 -37
- paskia/fastapi/reset.py +0 -2
- paskia/fastapi/response.py +22 -0
- paskia/fastapi/user.py +7 -7
- paskia/fastapi/ws.py +14 -37
- paskia/fastapi/wschat.py +55 -2
- paskia/fastapi/wsutil.py +10 -2
- paskia/frontend-build/auth/admin/index.html +6 -6
- paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +1 -0
- paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +12 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-BOdNrlQB.css} +1 -1
- paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-BSusdAfp.js} +1 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +49 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +1 -0
- paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +1 -0
- paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-CmNtuH3s.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-BKq4T2K2.css} +1 -1
- paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +1 -0
- paskia/frontend-build/auth/assets/{forward-DmqVHZ7e.js → forward-C86Jm_Uq.js} +1 -1
- paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
- paskia/frontend-build/auth/assets/reset-D71FG0VL.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-D3AJx3_6.js → restricted-CW0drE_k.js} +1 -1
- paskia/frontend-build/auth/index.html +6 -6
- paskia/frontend-build/auth/restricted/index.html +5 -5
- paskia/frontend-build/int/forward/index.html +5 -5
- paskia/frontend-build/int/reset/index.html +4 -4
- paskia/migrate/__init__.py +9 -9
- paskia/migrate/sql.py +26 -19
- paskia/remoteauth.py +6 -6
- {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/METADATA +1 -1
- paskia-0.10.0.dist-info/RECORD +60 -0
- paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +0 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
- paskia-0.9.0.dist-info/RECORD +0 -57
- {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/WHEEL +0 -0
- {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/entry_points.txt +0 -0
paskia/fastapi/admin.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from datetime import timezone
|
|
3
2
|
from uuid import UUID
|
|
4
3
|
|
|
5
4
|
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
|
|
@@ -13,6 +12,7 @@ from paskia.db import Permission as PermDC
|
|
|
13
12
|
from paskia.db import Role as RoleDC
|
|
14
13
|
from paskia.db import User as UserDC
|
|
15
14
|
from paskia.fastapi import authz
|
|
15
|
+
from paskia.fastapi.response import MsgspecResponse
|
|
16
16
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
17
17
|
from paskia.globals import passkey
|
|
18
18
|
from paskia.util import (
|
|
@@ -20,46 +20,26 @@ from paskia.util import (
|
|
|
20
20
|
passphrase,
|
|
21
21
|
permutil,
|
|
22
22
|
querysafe,
|
|
23
|
-
useragent,
|
|
24
23
|
vitedev,
|
|
25
24
|
)
|
|
25
|
+
from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime
|
|
26
26
|
from paskia.util.hostutil import normalize_host
|
|
27
27
|
|
|
28
28
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def
|
|
32
|
-
""
|
|
33
|
-
effective_scopes = (
|
|
34
|
-
{p.scope for p in (ctx.permissions or [])}
|
|
35
|
-
if ctx.permissions
|
|
36
|
-
else set(ctx.role.permissions or [])
|
|
37
|
-
)
|
|
38
|
-
return "auth:admin" in effective_scopes
|
|
39
|
-
|
|
31
|
+
def master_admin(ctx) -> bool:
|
|
32
|
+
return any(p.scope == "auth:admin" for p in ctx.permissions)
|
|
40
33
|
|
|
41
|
-
def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
|
|
42
|
-
"""Check if user has org admin permission.
|
|
43
34
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
effective_scopes = (
|
|
48
|
-
{p.scope for p in (ctx.permissions or [])}
|
|
49
|
-
if ctx.permissions
|
|
50
|
-
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
|
|
51
38
|
)
|
|
52
|
-
if "auth:org:admin" not in effective_scopes:
|
|
53
|
-
return False
|
|
54
|
-
if org_uuid is None:
|
|
55
|
-
return True
|
|
56
|
-
# User must belong to the target org (via their role)
|
|
57
|
-
return ctx.org.uuid == org_uuid
|
|
58
39
|
|
|
59
40
|
|
|
60
41
|
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
|
61
|
-
|
|
62
|
-
return is_global_admin(ctx) or is_org_admin(ctx, org_uuid)
|
|
42
|
+
return master_admin(ctx) or org_admin(ctx, org_uuid)
|
|
63
43
|
|
|
64
44
|
|
|
65
45
|
@app.exception_handler(ValueError)
|
|
@@ -99,42 +79,38 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
|
|
99
79
|
host=request.headers.get("host"),
|
|
100
80
|
)
|
|
101
81
|
orgs = list(db.data().orgs.values())
|
|
102
|
-
if not
|
|
82
|
+
if not master_admin(ctx):
|
|
103
83
|
# Org admins can only see their own organization
|
|
104
84
|
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
|
105
85
|
|
|
106
|
-
def
|
|
107
|
-
return {
|
|
108
|
-
"uuid": str(r.uuid),
|
|
109
|
-
"org": str(r.org),
|
|
110
|
-
"display_name": r.display_name,
|
|
111
|
-
"permissions": list(r.permissions.keys()),
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async def org_to_dict(o):
|
|
86
|
+
def org_to_dict(o):
|
|
115
87
|
users = db.get_organization_users(o.uuid)
|
|
116
88
|
return {
|
|
117
|
-
"uuid":
|
|
89
|
+
"uuid": o.uuid,
|
|
118
90
|
"display_name": o.display_name,
|
|
119
|
-
"permissions": {
|
|
120
|
-
pid for pid, p in db.data().permissions.items() if o.uuid in p.orgs
|
|
121
|
-
},
|
|
91
|
+
"permissions": {p.uuid for p in o.permissions},
|
|
122
92
|
"roles": [
|
|
123
|
-
|
|
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
|
|
124
100
|
],
|
|
125
101
|
"users": [
|
|
126
102
|
{
|
|
127
|
-
"uuid":
|
|
103
|
+
"uuid": u.uuid,
|
|
128
104
|
"display_name": u.display_name,
|
|
129
105
|
"role": role_name,
|
|
130
106
|
"visits": u.visits,
|
|
131
|
-
"last_seen": u.last_seen
|
|
107
|
+
"last_seen": u.last_seen,
|
|
132
108
|
}
|
|
133
109
|
for (u, role_name) in users
|
|
134
110
|
],
|
|
135
111
|
}
|
|
136
112
|
|
|
137
|
-
return [
|
|
113
|
+
return MsgspecResponse([org_to_dict(o) for o in orgs])
|
|
138
114
|
|
|
139
115
|
|
|
140
116
|
@app.post("/orgs")
|
|
@@ -151,7 +127,7 @@ async def admin_create_org(
|
|
|
151
127
|
db.create_org(org, ctx=ctx)
|
|
152
128
|
# Grant requested permissions to the new org
|
|
153
129
|
for perm in permissions:
|
|
154
|
-
db.add_permission_to_org(str(org.uuid), perm)
|
|
130
|
+
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
|
155
131
|
|
|
156
132
|
return {"uuid": str(org.uuid)}
|
|
157
133
|
|
|
@@ -283,7 +259,8 @@ async def admin_create_role(
|
|
|
283
259
|
perms = payload.get("permissions") or []
|
|
284
260
|
if org_uuid not in db.data().orgs:
|
|
285
261
|
raise HTTPException(status_code=404, detail="Organization not found")
|
|
286
|
-
|
|
262
|
+
org = db.data().orgs[org_uuid]
|
|
263
|
+
grantable = {p.uuid for p in org.permissions}
|
|
287
264
|
|
|
288
265
|
# Normalize permission IDs to UUIDs
|
|
289
266
|
permission_uuids: set[UUID] = set()
|
|
@@ -324,7 +301,7 @@ async def admin_update_role_name(
|
|
|
324
301
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
325
302
|
)
|
|
326
303
|
role = db.data().roles.get(role_uuid)
|
|
327
|
-
if not role or role.
|
|
304
|
+
if not role or role.org_uuid != org_uuid:
|
|
328
305
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
329
306
|
|
|
330
307
|
display_name = payload.get("display_name")
|
|
@@ -356,7 +333,7 @@ async def admin_add_role_permission(
|
|
|
356
333
|
)
|
|
357
334
|
|
|
358
335
|
role = db.data().roles.get(role_uuid)
|
|
359
|
-
if not role or role.
|
|
336
|
+
if not role or role.org_uuid != org_uuid:
|
|
360
337
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
361
338
|
|
|
362
339
|
# Verify permission exists and org can grant it
|
|
@@ -391,7 +368,7 @@ async def admin_remove_role_permission(
|
|
|
391
368
|
)
|
|
392
369
|
|
|
393
370
|
role = db.data().roles.get(role_uuid)
|
|
394
|
-
if not role or role.
|
|
371
|
+
if not role or role.org_uuid != org_uuid:
|
|
395
372
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
396
373
|
|
|
397
374
|
# Sanity check: prevent admin from removing their own access
|
|
@@ -432,7 +409,7 @@ async def admin_delete_role(
|
|
|
432
409
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
433
410
|
)
|
|
434
411
|
role = db.data().roles.get(role_uuid)
|
|
435
|
-
if not role or role.
|
|
412
|
+
if not role or role.org_uuid != org_uuid:
|
|
436
413
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
437
414
|
|
|
438
415
|
# Sanity check: prevent admin from deleting their own role
|
|
@@ -468,8 +445,11 @@ async def admin_create_user(
|
|
|
468
445
|
if not display_name or not role_name:
|
|
469
446
|
raise ValueError("display_name and role are required")
|
|
470
447
|
|
|
471
|
-
|
|
472
|
-
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
|
+
)
|
|
473
453
|
if not role_obj:
|
|
474
454
|
raise ValueError("Role not found in organization")
|
|
475
455
|
user = UserDC.create(
|
|
@@ -507,7 +487,7 @@ async def admin_update_user_role(
|
|
|
507
487
|
raise ValueError("User not found")
|
|
508
488
|
if user_org.uuid != org_uuid:
|
|
509
489
|
raise ValueError("User does not belong to this organization")
|
|
510
|
-
roles =
|
|
490
|
+
roles = user_org.roles
|
|
511
491
|
if not any(r.display_name == new_role for r in roles):
|
|
512
492
|
raise ValueError("Role not found in organization")
|
|
513
493
|
|
|
@@ -572,11 +552,7 @@ async def admin_create_user_registration_link(
|
|
|
572
552
|
url = hostutil.reset_link_url(token)
|
|
573
553
|
return {
|
|
574
554
|
"url": url,
|
|
575
|
-
"expires": (
|
|
576
|
-
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
577
|
-
if expiry.tzinfo
|
|
578
|
-
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
579
|
-
),
|
|
555
|
+
"expires": format_datetime(expiry),
|
|
580
556
|
}
|
|
581
557
|
|
|
582
558
|
|
|
@@ -604,120 +580,39 @@ async def admin_get_user_detail(
|
|
|
604
580
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
605
581
|
)
|
|
606
582
|
user = db.data().users.get(user_uuid)
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
.
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if c.last_used
|
|
636
|
-
else None
|
|
637
|
-
)
|
|
638
|
-
),
|
|
639
|
-
"last_verified": (
|
|
640
|
-
c.last_verified.astimezone(timezone.utc)
|
|
641
|
-
.isoformat()
|
|
642
|
-
.replace("+00:00", "Z")
|
|
643
|
-
if c.last_verified and c.last_verified.tzinfo
|
|
644
|
-
else (
|
|
645
|
-
c.last_verified.replace(tzinfo=timezone.utc)
|
|
646
|
-
.isoformat()
|
|
647
|
-
.replace("+00:00", "Z")
|
|
648
|
-
if c.last_verified
|
|
649
|
-
else None
|
|
650
|
-
)
|
|
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,
|
|
651
611
|
)
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
)
|
|
657
|
-
|
|
658
|
-
aaguid_info = aaguid_mod.filter(aaguids)
|
|
659
|
-
|
|
660
|
-
# Get sessions for the user
|
|
661
|
-
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
|
662
|
-
session_records = [s for s in db.data().sessions.values() if s.user == user_uuid]
|
|
663
|
-
current_session_key = auth
|
|
664
|
-
sessions_payload: list[dict] = []
|
|
665
|
-
for entry in session_records:
|
|
666
|
-
renewed = entry.expiry - EXPIRES
|
|
667
|
-
sessions_payload.append(
|
|
668
|
-
{
|
|
669
|
-
"id": entry.key,
|
|
670
|
-
"credential": str(entry.credential),
|
|
671
|
-
"host": entry.host,
|
|
672
|
-
"ip": entry.ip,
|
|
673
|
-
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
|
674
|
-
"last_renewed": (
|
|
675
|
-
renewed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
676
|
-
if renewed.tzinfo
|
|
677
|
-
else renewed.replace(tzinfo=timezone.utc)
|
|
678
|
-
.isoformat()
|
|
679
|
-
.replace("+00:00", "Z")
|
|
680
|
-
),
|
|
681
|
-
"is_current": entry.key == current_session_key,
|
|
682
|
-
"is_current_host": bool(
|
|
683
|
-
normalized_request_host
|
|
684
|
-
and entry.host
|
|
685
|
-
and entry.host == normalized_request_host
|
|
686
|
-
),
|
|
687
|
-
}
|
|
688
|
-
)
|
|
689
|
-
|
|
690
|
-
return {
|
|
691
|
-
"display_name": user.display_name,
|
|
692
|
-
"org": {"display_name": user_org.display_name},
|
|
693
|
-
"role": role_name,
|
|
694
|
-
"visits": user.visits,
|
|
695
|
-
"created_at": (
|
|
696
|
-
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
697
|
-
if user.created_at and user.created_at.tzinfo
|
|
698
|
-
else (
|
|
699
|
-
user.created_at.replace(tzinfo=timezone.utc)
|
|
700
|
-
.isoformat()
|
|
701
|
-
.replace("+00:00", "Z")
|
|
702
|
-
if user.created_at
|
|
703
|
-
else None
|
|
704
|
-
)
|
|
705
|
-
),
|
|
706
|
-
"last_seen": (
|
|
707
|
-
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
708
|
-
if user.last_seen and user.last_seen.tzinfo
|
|
709
|
-
else (
|
|
710
|
-
user.last_seen.replace(tzinfo=timezone.utc)
|
|
711
|
-
.isoformat()
|
|
712
|
-
.replace("+00:00", "Z")
|
|
713
|
-
if user.last_seen
|
|
714
|
-
else None
|
|
715
|
-
)
|
|
716
|
-
),
|
|
717
|
-
"credentials": creds,
|
|
718
|
-
"aaguid_info": aaguid_info,
|
|
719
|
-
"sessions": sessions_payload,
|
|
720
|
-
}
|
|
612
|
+
for s in user.sessions
|
|
613
|
+
],
|
|
614
|
+
}
|
|
615
|
+
)
|
|
721
616
|
|
|
722
617
|
|
|
723
618
|
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
|
@@ -808,10 +703,10 @@ async def admin_delete_user_session(
|
|
|
808
703
|
)
|
|
809
704
|
|
|
810
705
|
target_session = db.data().sessions.get(session_id)
|
|
811
|
-
if not target_session or target_session.
|
|
706
|
+
if not target_session or target_session.user_uuid != user_uuid:
|
|
812
707
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
813
708
|
|
|
814
|
-
db.delete_session(session_id, ctx=ctx)
|
|
709
|
+
db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
|
|
815
710
|
|
|
816
711
|
# Check if admin terminated their own session
|
|
817
712
|
current_terminated = session_id == auth
|
|
@@ -821,14 +716,6 @@ async def admin_delete_user_session(
|
|
|
821
716
|
# -------------------- Permissions (global) --------------------
|
|
822
717
|
|
|
823
718
|
|
|
824
|
-
def _perm_to_dict(p):
|
|
825
|
-
"""Convert Permission to dict, omitting domain if None."""
|
|
826
|
-
d = {"uuid": str(p.uuid), "scope": p.scope, "display_name": p.display_name}
|
|
827
|
-
if p.domain is not None:
|
|
828
|
-
d["domain"] = p.domain
|
|
829
|
-
return d
|
|
830
|
-
|
|
831
|
-
|
|
832
719
|
def _validate_permission_domain(domain: str | None) -> None:
|
|
833
720
|
"""Validate that domain is rp_id or a subdomain of it."""
|
|
834
721
|
if domain is None:
|
|
@@ -919,18 +806,8 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
|
|
919
806
|
match=permutil.has_any,
|
|
920
807
|
host=request.headers.get("host"),
|
|
921
808
|
)
|
|
922
|
-
perms =
|
|
923
|
-
|
|
924
|
-
# Global admins see all permissions
|
|
925
|
-
if is_global_admin(ctx):
|
|
926
|
-
return [_perm_to_dict(p) for p in perms]
|
|
927
|
-
|
|
928
|
-
# Org admins only see permissions their org can grant (by UUID)
|
|
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]
|
|
933
|
-
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])
|
|
934
811
|
|
|
935
812
|
|
|
936
813
|
@app.post("/permissions")
|
paskia/fastapi/api.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from contextlib import suppress
|
|
3
|
-
from datetime import datetime, timedelta
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
4
|
|
|
5
5
|
from fastapi import (
|
|
6
6
|
Depends,
|
|
@@ -14,12 +14,9 @@ from fastapi.responses import JSONResponse
|
|
|
14
14
|
from fastapi.security import HTTPBearer
|
|
15
15
|
|
|
16
16
|
from paskia import db
|
|
17
|
-
from paskia.authsession import
|
|
18
|
-
EXPIRES,
|
|
19
|
-
get_reset,
|
|
20
|
-
refresh_session_token,
|
|
21
|
-
)
|
|
17
|
+
from paskia.authsession import EXPIRES, expires, get_reset
|
|
22
18
|
from paskia.fastapi import authz, session, user
|
|
19
|
+
from paskia.fastapi.response import MsgspecResponse
|
|
23
20
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
|
24
21
|
from paskia.globals import passkey as global_passkey
|
|
25
22
|
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
|
@@ -81,7 +78,7 @@ async def validate_token(
|
|
|
81
78
|
try:
|
|
82
79
|
ctx = await authz.verify(
|
|
83
80
|
auth,
|
|
84
|
-
perm,
|
|
81
|
+
" ".join(perm).split(),
|
|
85
82
|
host=request.headers.get("host"),
|
|
86
83
|
max_age=max_age,
|
|
87
84
|
)
|
|
@@ -90,44 +87,24 @@ async def validate_token(
|
|
|
90
87
|
raise
|
|
91
88
|
renewed = False
|
|
92
89
|
if auth:
|
|
93
|
-
consumed = EXPIRES - (ctx.session.expiry - datetime.now(
|
|
90
|
+
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
|
|
94
91
|
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"ctx": userinfo.format_session_context(ctx),
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
@app.get("/token-info")
|
|
116
|
-
async def token_info(credentials=Depends(bearer_auth)):
|
|
117
|
-
"""Get reset/device-add token info. Pass token via Bearer header."""
|
|
118
|
-
token = credentials.credentials
|
|
119
|
-
if not passphrase.is_well_formed(token):
|
|
120
|
-
raise HTTPException(400, "Invalid token format")
|
|
121
|
-
try:
|
|
122
|
-
reset_token = get_reset(token)
|
|
123
|
-
except ValueError as e:
|
|
124
|
-
raise HTTPException(401, str(e))
|
|
125
|
-
|
|
126
|
-
u = db.data().users.get(reset_token.user)
|
|
127
|
-
return {
|
|
128
|
-
"token_type": reset_token.token_type,
|
|
129
|
-
"display_name": u.display_name,
|
|
130
|
-
}
|
|
92
|
+
db.update_session(
|
|
93
|
+
auth,
|
|
94
|
+
ip=request.client.host if request.client else "",
|
|
95
|
+
user_agent=request.headers.get("user-agent") or "",
|
|
96
|
+
expiry=expires(),
|
|
97
|
+
ctx=ctx,
|
|
98
|
+
)
|
|
99
|
+
session.set_session_cookie(response, auth)
|
|
100
|
+
renewed = True
|
|
101
|
+
return MsgspecResponse(
|
|
102
|
+
{
|
|
103
|
+
"valid": True,
|
|
104
|
+
"renewed": renewed,
|
|
105
|
+
"ctx": userinfo.build_session_context(ctx),
|
|
106
|
+
}
|
|
107
|
+
)
|
|
131
108
|
|
|
132
109
|
|
|
133
110
|
@app.get("/forward")
|
|
@@ -154,7 +131,10 @@ async def forward_authentication(
|
|
|
154
131
|
"""
|
|
155
132
|
try:
|
|
156
133
|
ctx = await authz.verify(
|
|
157
|
-
auth,
|
|
134
|
+
auth,
|
|
135
|
+
" ".join(perm).split(),
|
|
136
|
+
host=request.headers.get("host"),
|
|
137
|
+
max_age=max_age,
|
|
158
138
|
)
|
|
159
139
|
# Build permission scopes for Remote-Groups header
|
|
160
140
|
role_permissions = (
|
|
@@ -170,11 +150,9 @@ async def forward_authentication(
|
|
|
170
150
|
"Remote-Role": str(ctx.role.uuid),
|
|
171
151
|
"Remote-Role-Name": ctx.role.display_name,
|
|
172
152
|
"Remote-Session-Expires": (
|
|
173
|
-
ctx.session.expiry.astimezone(
|
|
174
|
-
.isoformat()
|
|
175
|
-
.replace("+00:00", "Z")
|
|
153
|
+
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
176
154
|
if ctx.session.expiry.tzinfo
|
|
177
|
-
else ctx.session.expiry.replace(tzinfo=
|
|
155
|
+
else ctx.session.expiry.replace(tzinfo=UTC)
|
|
178
156
|
.isoformat()
|
|
179
157
|
.replace("+00:00", "Z")
|
|
180
158
|
),
|
|
@@ -233,28 +211,48 @@ async def api_user_info(
|
|
|
233
211
|
detail="Authentication required",
|
|
234
212
|
mode="login",
|
|
235
213
|
)
|
|
236
|
-
ctx = db.
|
|
214
|
+
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
|
237
215
|
if not ctx:
|
|
238
216
|
raise HTTPException(401, "Session expired")
|
|
239
217
|
|
|
240
|
-
return
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
218
|
+
return MsgspecResponse(
|
|
219
|
+
await userinfo.build_user_info(
|
|
220
|
+
user_uuid=ctx.user.uuid,
|
|
221
|
+
auth=auth,
|
|
222
|
+
session_record=ctx.session,
|
|
223
|
+
request_host=request.headers.get("host"),
|
|
224
|
+
)
|
|
245
225
|
)
|
|
246
226
|
|
|
247
227
|
|
|
228
|
+
@app.get("/token-info")
|
|
229
|
+
async def token_info(credentials=Depends(bearer_auth)):
|
|
230
|
+
"""Get reset/device-add token info. Pass token via Bearer header."""
|
|
231
|
+
token = credentials.credentials
|
|
232
|
+
if not passphrase.is_well_formed(token):
|
|
233
|
+
raise HTTPException(400, "Invalid token format")
|
|
234
|
+
try:
|
|
235
|
+
reset_token = get_reset(token)
|
|
236
|
+
except ValueError as e:
|
|
237
|
+
raise HTTPException(401, str(e))
|
|
238
|
+
|
|
239
|
+
u = reset_token.user
|
|
240
|
+
return {
|
|
241
|
+
"token_type": reset_token.token_type,
|
|
242
|
+
"display_name": u.display_name,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
248
246
|
@app.post("/logout")
|
|
249
247
|
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
250
248
|
if not auth:
|
|
251
249
|
return {"message": "Already logged out"}
|
|
252
250
|
host = request.headers.get("host")
|
|
253
|
-
ctx = db.
|
|
251
|
+
ctx = db.data().session_ctx(auth, host)
|
|
254
252
|
if not ctx:
|
|
255
253
|
return {"message": "Already logged out"}
|
|
256
254
|
with suppress(Exception):
|
|
257
|
-
db.delete_session(auth, ctx=ctx)
|
|
255
|
+
db.delete_session(auth, ctx=ctx, action="logout")
|
|
258
256
|
session.clear_session_cookie(response)
|
|
259
257
|
return {"message": "Logged out successfully"}
|
|
260
258
|
|
|
@@ -263,7 +261,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
|
263
261
|
async def api_set_session(
|
|
264
262
|
request: Request, response: Response, auth=Depends(bearer_auth)
|
|
265
263
|
):
|
|
266
|
-
ctx = db.
|
|
264
|
+
ctx = db.data().session_ctx(auth.credentials, request.headers.get("host"))
|
|
267
265
|
if not ctx:
|
|
268
266
|
raise HTTPException(401, "Session expired")
|
|
269
267
|
session.set_session_cookie(response, auth.credentials)
|
paskia/fastapi/authz.py
CHANGED
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
|
|
3
3
|
from fastapi import HTTPException
|
|
4
4
|
|
|
5
|
+
from paskia.fastapi.logging import log_permission_denied
|
|
5
6
|
from paskia.util import permutil, sessionutil
|
|
6
7
|
|
|
7
8
|
logger = logging.getLogger(__name__)
|
|
@@ -93,20 +94,14 @@ async def verify(
|
|
|
93
94
|
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
|
94
95
|
|
|
95
96
|
if not match(ctx, perm):
|
|
96
|
-
# Determine which permissions are missing for clearer diagnostics
|
|
97
97
|
effective_scopes = (
|
|
98
98
|
{p.scope for p in (ctx.permissions or [])}
|
|
99
99
|
if ctx.permissions
|
|
100
100
|
else set(ctx.role.permissions or [])
|
|
101
101
|
)
|
|
102
102
|
missing = sorted(set(perm) - effective_scopes)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
getattr(ctx.user, "uuid", "?"),
|
|
106
|
-
getattr(ctx.role, "display_name", "?"),
|
|
107
|
-
missing,
|
|
108
|
-
perm,
|
|
109
|
-
list(effective_scopes),
|
|
103
|
+
log_permission_denied(
|
|
104
|
+
ctx, perm, missing, require_all=(match == permutil.has_all)
|
|
110
105
|
)
|
|
111
106
|
raise AuthException(
|
|
112
107
|
status_code=403, mode="forbidden", detail="Permission required"
|