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.
Files changed (58) hide show
  1. paskia/_version.py +2 -2
  2. paskia/aaguid/__init__.py +5 -4
  3. paskia/authsession.py +4 -19
  4. paskia/db/__init__.py +2 -4
  5. paskia/db/background.py +3 -3
  6. paskia/db/jsonl.py +99 -111
  7. paskia/db/logging.py +318 -0
  8. paskia/db/migrations.py +19 -20
  9. paskia/db/operations.py +107 -196
  10. paskia/db/structs.py +236 -46
  11. paskia/fastapi/__main__.py +13 -6
  12. paskia/fastapi/admin.py +72 -195
  13. paskia/fastapi/api.py +56 -58
  14. paskia/fastapi/authz.py +3 -8
  15. paskia/fastapi/logging.py +261 -0
  16. paskia/fastapi/mainapp.py +14 -3
  17. paskia/fastapi/remote.py +11 -37
  18. paskia/fastapi/reset.py +0 -2
  19. paskia/fastapi/response.py +22 -0
  20. paskia/fastapi/user.py +7 -7
  21. paskia/fastapi/ws.py +14 -37
  22. paskia/fastapi/wschat.py +55 -2
  23. paskia/fastapi/wsutil.py +10 -2
  24. paskia/frontend-build/auth/admin/index.html +6 -6
  25. paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +1 -0
  26. paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +12 -0
  27. paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-BOdNrlQB.css} +1 -1
  28. paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-BSusdAfp.js} +1 -1
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +49 -0
  30. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +1 -0
  32. paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-CmNtuH3s.css} +1 -1
  33. paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-BKq4T2K2.css} +1 -1
  34. paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +1 -0
  35. paskia/frontend-build/auth/assets/{forward-DmqVHZ7e.js → forward-C86Jm_Uq.js} +1 -1
  36. paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
  37. paskia/frontend-build/auth/assets/reset-D71FG0VL.js +1 -0
  38. paskia/frontend-build/auth/assets/{restricted-D3AJx3_6.js → restricted-CW0drE_k.js} +1 -1
  39. paskia/frontend-build/auth/index.html +6 -6
  40. paskia/frontend-build/auth/restricted/index.html +5 -5
  41. paskia/frontend-build/int/forward/index.html +5 -5
  42. paskia/frontend-build/int/reset/index.html +4 -4
  43. paskia/migrate/__init__.py +9 -9
  44. paskia/migrate/sql.py +26 -19
  45. paskia/remoteauth.py +6 -6
  46. {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/METADATA +1 -1
  47. paskia-0.10.0.dist-info/RECORD +60 -0
  48. paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +0 -1
  49. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
  50. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
  51. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
  52. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
  53. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
  54. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
  55. paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
  56. paskia-0.9.0.dist-info/RECORD +0 -57
  57. {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/WHEEL +0 -0
  58. {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 is_global_admin(ctx) -> bool:
32
- """Check if user has global admin permission."""
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
- If org_uuid is provided, checks if user is admin of that specific org.
45
- If org_uuid is None, checks if user is admin of their own org.
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
- """Check if user can manage the specified organization."""
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 is_global_admin(ctx):
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 role_to_dict(r):
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": str(o.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
- role_to_dict(r) for r in db.data().roles.values() if r.org == o.uuid
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": str(u.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.isoformat() if u.last_seen else None,
107
+ "last_seen": u.last_seen,
132
108
  }
133
109
  for (u, role_name) in users
134
110
  ],
135
111
  }
136
112
 
137
- return [await org_to_dict(o) for o in orgs]
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
- grantable = {pid for pid, p in db.data().permissions.items() if org_uuid in p.orgs}
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.org != org_uuid:
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.org != org_uuid:
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.org != org_uuid:
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.org != org_uuid:
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
- roles = [r for r in db.data().roles.values() if r.org == org_uuid]
472
- role_obj = next((r for r in roles if r.display_name == role_name), None)
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 = [r for r in db.data().roles.values() if r.org == org_uuid]
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
- user_creds = [c for c in db.data().credentials.values() if c.user == user_uuid]
608
- creds: list[dict] = []
609
- aaguids: set[str] = set()
610
- for c in user_creds:
611
- aaguid_str = str(c.aaguid)
612
- aaguids.add(aaguid_str)
613
- creds.append(
614
- {
615
- "credential": str(c.uuid),
616
- "aaguid": aaguid_str,
617
- "created_at": (
618
- c.created_at.astimezone(timezone.utc)
619
- .isoformat()
620
- .replace("+00:00", "Z")
621
- if c.created_at.tzinfo
622
- else c.created_at.replace(tzinfo=timezone.utc)
623
- .isoformat()
624
- .replace("+00:00", "Z")
625
- ),
626
- "last_used": (
627
- c.last_used.astimezone(timezone.utc)
628
- .isoformat()
629
- .replace("+00:00", "Z")
630
- if c.last_used and c.last_used.tzinfo
631
- else (
632
- c.last_used.replace(tzinfo=timezone.utc)
633
- .isoformat()
634
- .replace("+00:00", "Z")
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
- if c.last_verified
653
- else None,
654
- "sign_count": c.sign_count,
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.user != user_uuid:
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 = list(db.data().permissions.values())
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, timezone
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(timezone.utc))
90
+ consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
94
91
  if not timedelta(0) < consumed < _REFRESH_INTERVAL:
95
- try:
96
- refresh_session_token(
97
- auth,
98
- ip=request.client.host if request.client else "",
99
- user_agent=request.headers.get("user-agent") or "",
100
- )
101
- session.set_session_cookie(response, auth)
102
- renewed = True
103
- except ValueError:
104
- # Session disappeared, e.g. due to concurrent logout; global handler will clear
105
- raise authz.AuthException(
106
- status_code=401, detail="Session expired", mode="login"
107
- )
108
- return {
109
- "valid": True,
110
- "renewed": renewed,
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, perm, host=request.headers.get("host"), max_age=max_age
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(timezone.utc)
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=timezone.utc)
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.get_session_context(auth, request.headers.get("host"))
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 await userinfo.format_user_info(
241
- user_uuid=ctx.user.uuid,
242
- auth=auth,
243
- session_record=ctx.session,
244
- request_host=request.headers.get("host"),
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.get_session_context(auth, host)
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.get_session_context(auth.credentials, request.headers.get("host"))
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
- logger.warning(
104
- "Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
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"