paskia 0.9.0__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ """Custom access logging middleware for FastAPI/Uvicorn."""
2
+
3
+ import logging
4
+ import sys
5
+ import time
6
+ from ipaddress import IPv6Address
7
+
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+
12
+ logger = logging.getLogger("paskia.access")
13
+
14
+ _RESET = "\033[0m"
15
+ _STATUS_INFO = "\033[32m" # 1xx (green)
16
+ _STATUS_OK = "\033[92m" # 2xx (bright green)
17
+ _STATUS_REDIRECT = "\033[32m" # 3xx (green)
18
+ _STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
19
+ _STATUS_SERVER_ERR = "\033[1;31m" # 5xx (bright red)
20
+ _METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
21
+ _METHOD_WRITE = "\033[1;34m" # POST, PUT, DELETE, PATCH (bright blue)
22
+ _HOST = "\033[1;30m" # hostname (dark grey)
23
+ _PATH = "\033[0m" # path (default)
24
+ _TIMING = "\033[2m" # timing (dim)
25
+ _WS_OPEN = "\033[1;33m" # WebSocket connect (bright yellow)
26
+ _WS_CLOSE = "\033[0;33m" # WebSocket disconnect (yellow)
27
+ _WS_STATUS = "\033[1;30m" # WebSocket close status (dark grey)
28
+
29
+
30
+ def format_ipv6_network(ip: str) -> str:
31
+ """Format IPv6 address to show only network part (first 64 bits)."""
32
+ try:
33
+ addr = IPv6Address(ip)
34
+ # Get the integer representation and mask to first 64 bits
35
+ network_int = int(addr) >> 64
36
+ # Format as IPv6 with trailing ::
37
+ # Split into 4 groups of 16 bits
38
+ groups = []
39
+ for _ in range(4):
40
+ groups.insert(0, format(network_int & 0xFFFF, "x"))
41
+ network_int >>= 16
42
+ # Compress consecutive zero groups
43
+ result = ":".join(groups) + "::"
44
+ # Simplify leading zeros in groups and compress
45
+ return str(IPv6Address(result + "0"))
46
+ except Exception:
47
+ return ip
48
+
49
+
50
+ def format_client_ip(ip: str) -> str:
51
+ """Format client IP, compressing IPv6 to network part only."""
52
+ if not ip or ip == "-":
53
+ return "-"
54
+ if ":" in ip:
55
+ return format_ipv6_network(ip)
56
+ return ip
57
+
58
+
59
+ def status_color(status: int) -> str:
60
+ """Return color code based on HTTP status."""
61
+ if status < 200:
62
+ return _STATUS_INFO
63
+ if status < 300:
64
+ return _STATUS_OK
65
+ if status < 400:
66
+ return _STATUS_REDIRECT
67
+ if status < 500:
68
+ return _STATUS_CLIENT_ERR
69
+ return _STATUS_SERVER_ERR
70
+
71
+
72
+ def method_color(method: str) -> str:
73
+ """Return color code based on HTTP method."""
74
+ if method in ("GET", "HEAD", "OPTIONS"):
75
+ return _METHOD_READ
76
+ return _METHOD_WRITE
77
+
78
+
79
+ def format_access_log(
80
+ client: str, status: int, method: str, host: str, path: str, duration_ms: float
81
+ ) -> str:
82
+ """Format access log line with colors and aligned fields."""
83
+ use_color = sys.stderr.isatty()
84
+
85
+ # Format components with fixed widths for alignment
86
+ ip = format_client_ip(client).ljust(15) # IPv4 max 15 chars
87
+ timing = f"{duration_ms:.0f}ms"
88
+ method_padded = method.ljust(7) # Longest method is OPTIONS (7)
89
+
90
+ if use_color:
91
+ status_str = f"{status_color(status)}{status}{_RESET}"
92
+ timing_str = f"{_TIMING}{timing}{_RESET}"
93
+ method_str = f"{method_color(method)}{method_padded}{_RESET}"
94
+ host_str = f"{_HOST}{host}{_RESET}"
95
+ path_str = f"{_PATH}{path}{_RESET}"
96
+ else:
97
+ status_str = str(status)
98
+ timing_str = timing
99
+ method_str = method_padded
100
+ host_str = host
101
+ path_str = path
102
+
103
+ # Format: "IP STATUS METHOD host path TIMING"
104
+ return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
105
+
106
+
107
+ # WebSocket connection counter (mod 100)
108
+ _ws_counter = 0
109
+
110
+
111
+ def _next_ws_id() -> int:
112
+ """Get next WebSocket connection ID (0-99)."""
113
+ global _ws_counter
114
+ ws_id = _ws_counter
115
+ _ws_counter = (_ws_counter + 1) % 100
116
+ return ws_id
117
+
118
+
119
+ def log_ws_open(client: str, host: str, path: str) -> int:
120
+ """Log WebSocket connection open. Returns connection ID for use in close."""
121
+ use_color = sys.stderr.isatty()
122
+ ws_id = _next_ws_id()
123
+
124
+ ip = format_client_ip(client).ljust(15)
125
+ id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
126
+
127
+ if use_color:
128
+ # 🔌 aligned with status (takes ~2 char width), ID aligned with method
129
+ prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
130
+ host_str = f"{_HOST}{host}{_RESET}"
131
+ path_str = f"{_PATH}{path}{_RESET}"
132
+ else:
133
+ prefix = f"WS+ {id_str}"
134
+ host_str = host
135
+ path_str = path
136
+
137
+ logger.info(f"{ip} {prefix} {host_str}{path_str}")
138
+ return ws_id
139
+
140
+
141
+ # WebSocket close codes to human-readable status
142
+ WS_CLOSE_CODES = {
143
+ 1000: "ok",
144
+ 1001: "going away",
145
+ 1002: "protocol error",
146
+ 1003: "unsupported",
147
+ 1005: "no status",
148
+ 1006: "abnormal",
149
+ 1007: "invalid data",
150
+ 1008: "policy violation",
151
+ 1009: "too large",
152
+ 1010: "extension required",
153
+ 1011: "server error",
154
+ 1012: "restarting",
155
+ 1013: "try again",
156
+ 1014: "bad gateway",
157
+ 1015: "tls error",
158
+ }
159
+
160
+
161
+ def log_ws_close(
162
+ client: str, ws_id: int, close_code: int | None, duration_ms: float
163
+ ) -> None:
164
+ """Log WebSocket connection close with duration and status."""
165
+ use_color = sys.stderr.isatty()
166
+
167
+ ip = format_client_ip(client).ljust(15)
168
+ id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
169
+ timing = f"{duration_ms:.0f}ms"
170
+
171
+ # Convert close code to status text
172
+ if close_code is None:
173
+ status = "closed"
174
+ else:
175
+ status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
176
+
177
+ if use_color:
178
+ # 🔌 aligned with status, ID aligned with method
179
+ prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
180
+ status_str = f"{_WS_STATUS}{status}{_RESET}"
181
+ timing_str = f"{_TIMING}{timing}{_RESET}"
182
+ else:
183
+ prefix = f"WS- {id_str}"
184
+ status_str = status
185
+ timing_str = timing
186
+
187
+ logger.info(f"{ip} {prefix} {status_str} {timing_str}")
188
+
189
+
190
+ class AccessLogMiddleware(BaseHTTPMiddleware):
191
+ """Middleware that logs HTTP requests with custom format."""
192
+
193
+ async def dispatch(self, request: Request, call_next) -> Response:
194
+ start = time.perf_counter()
195
+ response = await call_next(request)
196
+ duration_ms = (time.perf_counter() - start) * 1000
197
+
198
+ client = request.client.host if request.client else "-"
199
+ host = request.headers.get("host", "-")
200
+ method = request.method
201
+ path = request.url.path
202
+ if request.url.query:
203
+ path = f"{path}?{request.url.query}"
204
+ status = response.status_code
205
+
206
+ line = format_access_log(client, status, method, host, path, duration_ms)
207
+ logger.info(line)
208
+
209
+ return response
210
+
211
+
212
+ def configure_access_logging():
213
+ """Configure the access logger to output to stderr."""
214
+ handler = logging.StreamHandler(sys.stderr)
215
+ handler.setFormatter(logging.Formatter("%(message)s"))
216
+ logger.addHandler(handler)
217
+ logger.setLevel(logging.INFO)
218
+ logger.propagate = False
paskia/fastapi/mainapp.py CHANGED
@@ -10,10 +10,16 @@ from fastapi_vue import Frontend
10
10
 
11
11
  from paskia import globals
12
12
  from paskia.db import start_background, stop_background
13
+ from paskia.db.logging import configure_db_logging
13
14
  from paskia.fastapi import admin, api, auth_host, ws
15
+ from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
14
16
  from paskia.fastapi.session import AUTH_COOKIE
15
17
  from paskia.util import hostutil, passphrase, vitedev
16
18
 
19
+ # Configure custom logging
20
+ configure_access_logging()
21
+ configure_db_logging()
22
+
17
23
  # Vue Frontend static files
18
24
  frontend = Frontend(
19
25
  Path(__file__).parent.parent / "frontend-build",
@@ -48,10 +54,11 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
48
54
  # Re-raise to fail fast
49
55
  raise
50
56
 
51
- # Restore info level logging after startup (suppressed during uvicorn init in dev mode)
57
+ # Restore uvicorn info logging (suppressed during startup in dev mode)
58
+ # Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
52
59
  if frontend.devmode:
53
60
  logging.getLogger("uvicorn").setLevel(logging.INFO)
54
- logging.getLogger("uvicorn.access").setLevel(logging.INFO)
61
+ logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
55
62
 
56
63
  await frontend.load()
57
64
  await start_background()
@@ -67,6 +74,9 @@ app = FastAPI(
67
74
  openapi_url=None,
68
75
  )
69
76
 
77
+ # Custom access logging (uvicorn's access_log is disabled)
78
+ app.add_middleware(AccessLogMiddleware)
79
+
70
80
  # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
71
81
  app.middleware("http")(auth_host.redirect_middleware)
72
82
 
paskia/fastapi/remote.py CHANGED
@@ -324,7 +324,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
324
324
  token_str = passphrase.generate()
325
325
  expiry = expires()
326
326
  db.create_reset_token(
327
- user_uuid=cred.user,
327
+ user_uuid=cred.user_uuid,
328
328
  passphrase=token_str,
329
329
  expiry=expiry,
330
330
  token_type="device addition",
@@ -333,7 +333,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
333
333
  # Also create a session so the device is logged in
334
334
  normalized_host = hostutil.normalize_host(request.host)
335
335
  session_token = db.login(
336
- user_uuid=cred.user,
336
+ user_uuid=cred.user_uuid,
337
337
  credential_uuid=cred.uuid,
338
338
  sign_count=new_sign_count,
339
339
  host=normalized_host,
@@ -346,7 +346,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
346
346
 
347
347
  normalized_host = hostutil.normalize_host(request.host)
348
348
  session_token = db.login(
349
- user_uuid=cred.user,
349
+ user_uuid=cred.user_uuid,
350
350
  credential_uuid=cred.uuid,
351
351
  sign_count=new_sign_count,
352
352
  host=normalized_host,
@@ -359,7 +359,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
359
359
  completed = await remoteauth.instance.complete_request(
360
360
  token=request.key,
361
361
  session_token=session_token,
362
- user_uuid=cred.user,
362
+ user_uuid=cred.user_uuid,
363
363
  credential_uuid=cred.uuid,
364
364
  reset_token=reset_token,
365
365
  )
paskia/fastapi/reset.py CHANGED
@@ -10,8 +10,6 @@ display name. If multiple users match, they are listed and the command
10
10
  aborts. A new one-time reset link is always created.
11
11
  """
12
12
 
13
- from __future__ import annotations
14
-
15
13
  import asyncio
16
14
  from uuid import UUID
17
15
 
@@ -0,0 +1,22 @@
1
+ """FastAPI response utilities for msgspec.Struct serialization."""
2
+
3
+ import msgspec
4
+ from fastapi import Response
5
+
6
+
7
+ class MsgspecResponse(Response):
8
+ """Response that uses msgspec for JSON encoding.
9
+
10
+ Use this for returning msgspec.Struct, dict, or list with proper serialization.
11
+ """
12
+
13
+ media_type = "application/json"
14
+
15
+ def __init__(
16
+ self,
17
+ content: msgspec.Struct | dict | list,
18
+ status_code: int = 200,
19
+ headers: dict | None = None,
20
+ ):
21
+ body = msgspec.json.encode(content)
22
+ super().__init__(content=body, status_code=status_code, headers=headers)
paskia/fastapi/user.py CHANGED
@@ -1,4 +1,4 @@
1
- from datetime import timezone
1
+ from datetime import UTC
2
2
  from uuid import UUID
3
3
 
4
4
  from fastapi import (
@@ -43,7 +43,7 @@ async def user_update_display_name(
43
43
  status_code=401, detail="Authentication Required", mode="login"
44
44
  )
45
45
  host = request.headers.get("host")
46
- ctx = db.get_session_context(auth, host)
46
+ ctx = db.data().session_ctx(auth, host)
47
47
  if not ctx:
48
48
  raise authz.AuthException(
49
49
  status_code=401, detail="Session expired", mode="login"
@@ -62,7 +62,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
62
62
  if not auth:
63
63
  return {"message": "Already logged out"}
64
64
  host = request.headers.get("host")
65
- ctx = db.get_session_context(auth, host)
65
+ ctx = db.data().session_ctx(auth, host)
66
66
  if not ctx:
67
67
  raise authz.AuthException(
68
68
  status_code=401, detail="Session expired", mode="login"
@@ -84,14 +84,14 @@ async def api_delete_session(
84
84
  status_code=401, detail="Authentication Required", mode="login"
85
85
  )
86
86
  host = request.headers.get("host")
87
- ctx = db.get_session_context(auth, host)
87
+ ctx = db.data().session_ctx(auth, host)
88
88
  if not ctx:
89
89
  raise authz.AuthException(
90
90
  status_code=401, detail="Session expired", mode="login"
91
91
  )
92
92
 
93
93
  target_session = db.data().sessions.get(session_id)
94
- if not target_session or target_session.user != ctx.user.uuid:
94
+ if not target_session or target_session.user_uuid != ctx.user.uuid:
95
95
  raise HTTPException(status_code=404, detail="Session not found")
96
96
 
97
97
  db.delete_session(session_id, ctx=ctx)
@@ -141,8 +141,8 @@ async def api_create_link(
141
141
  "message": "Registration link generated successfully",
142
142
  "url": url,
143
143
  "expires": (
144
- expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
144
+ expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
145
145
  if expiry.tzinfo
146
- else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
146
+ else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
147
147
  ),
148
148
  }
paskia/fastapi/ws.py CHANGED
@@ -38,11 +38,11 @@ async def websocket_register_add(
38
38
  f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
39
39
  )
40
40
  s = get_reset(reset)
41
- user_uuid = s.user
41
+ user_uuid = s.user_uuid
42
42
  else:
43
43
  # Require recent authentication for adding a new passkey
44
44
  ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
45
- user_uuid = ctx.session.user
45
+ user_uuid = ctx.session.user_uuid
46
46
  s = ctx.session
47
47
 
48
48
  # Get user information and determine effective user_name for this registration
@@ -91,7 +91,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
91
91
  session_user_uuid = None
92
92
  credential_ids = None
93
93
  if auth:
94
- ctx = db.get_session_context(auth, host)
94
+ ctx = db.data().session_ctx(auth, host)
95
95
  if ctx:
96
96
  session_user_uuid = ctx.user.uuid
97
97
  credential_ids = db.get_user_credential_ids(session_user_uuid) or None
@@ -99,7 +99,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
99
99
  cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids)
100
100
 
101
101
  # If reauth mode, verify the credential belongs to the session's user
102
- if session_user_uuid and cred.user != session_user_uuid:
102
+ if session_user_uuid and cred.user_uuid != session_user_uuid:
103
103
  raise ValueError("This passkey belongs to a different account")
104
104
 
105
105
  # Create session and update user/credential in a single transaction
@@ -114,7 +114,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
114
114
  raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
115
115
 
116
116
  token = db.login(
117
- user_uuid=cred.user,
117
+ user_uuid=cred.user_uuid,
118
118
  credential_uuid=cred.uuid,
119
119
  sign_count=new_sign_count,
120
120
  host=normalized_host,
@@ -125,7 +125,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
125
125
 
126
126
  await ws.send_json(
127
127
  {
128
- "user": str(cred.user),
128
+ "user": str(cred.user_uuid),
129
129
  "session_token": token,
130
130
  }
131
131
  )
paskia/fastapi/wsutil.py CHANGED
@@ -3,6 +3,7 @@ Shared WebSocket utilities for FastAPI endpoints.
3
3
  """
4
4
 
5
5
  import logging
6
+ import time
6
7
  from functools import wraps
7
8
 
8
9
  import base64url
@@ -10,6 +11,7 @@ from fastapi import WebSocket, WebSocketDisconnect
10
11
  from webauthn.helpers.exceptions import InvalidAuthenticationResponse
11
12
 
12
13
  from paskia.fastapi import authz
14
+ from paskia.fastapi.logging import log_ws_close, log_ws_open
13
15
  from paskia.globals import passkey
14
16
  from paskia.util import pow
15
17
 
@@ -19,11 +21,19 @@ def websocket_error_handler(func):
19
21
 
20
22
  @wraps(func)
21
23
  async def wrapper(ws: WebSocket, *args, **kwargs):
24
+ client = ws.client.host if ws.client else "-"
25
+ host = ws.headers.get("host", "-")
26
+ path = ws.url.path
27
+
28
+ start = time.perf_counter()
29
+ ws_id = log_ws_open(client, host, path)
30
+ close_code = None
31
+
22
32
  try:
23
33
  await ws.accept()
24
34
  return await func(ws, *args, **kwargs)
25
- except WebSocketDisconnect:
26
- pass
35
+ except WebSocketDisconnect as e:
36
+ close_code = e.code
27
37
  except authz.AuthException as e:
28
38
  await ws.send_json(
29
39
  {
@@ -36,6 +46,9 @@ def websocket_error_handler(func):
36
46
  except Exception:
37
47
  logging.exception("Internal Server Error")
38
48
  await ws.send_json({"status": 500, "detail": "Internal Server Error"})
49
+ finally:
50
+ duration_ms = (time.perf_counter() - start) * 1000
51
+ log_ws_close(client, ws_id, close_code, duration_ms)
39
52
 
40
53
  return wrapper
41
54
 
@@ -14,7 +14,7 @@ Or via the CLI entry point (if installed):
14
14
  import argparse
15
15
  import asyncio
16
16
  import re
17
- from datetime import datetime, timezone
17
+ from datetime import UTC, datetime
18
18
  from uuid import UUID
19
19
 
20
20
  import base64url
@@ -154,7 +154,7 @@ async def migrate_from_sql(
154
154
  if perm_uuid:
155
155
  new_permissions[perm_uuid] = True
156
156
  new_role = Role(
157
- org=role.org_uuid,
157
+ org_uuid=role.org_uuid,
158
158
  display_name=role.display_name,
159
159
  permissions=new_permissions,
160
160
  )
@@ -172,8 +172,8 @@ async def migrate_from_sql(
172
172
  user_key: UUID = legacy_user.uuid
173
173
  new_user = User(
174
174
  display_name=legacy_user.display_name,
175
- role=legacy_user.role_uuid,
176
- created_at=legacy_user.created_at or datetime.now(timezone.utc),
175
+ role_uuid=legacy_user.role_uuid,
176
+ created_at=legacy_user.created_at or datetime.now(UTC),
177
177
  last_seen=legacy_user.last_seen,
178
178
  visits=legacy_user.visits,
179
179
  )
@@ -190,7 +190,7 @@ async def migrate_from_sql(
190
190
  cred_key: UUID = legacy_cred.uuid
191
191
  new_cred = Credential(
192
192
  credential_id=legacy_cred.credential_id,
193
- user=legacy_cred.user_uuid,
193
+ user_uuid=legacy_cred.user_uuid,
194
194
  aaguid=legacy_cred.aaguid,
195
195
  public_key=legacy_cred.public_key,
196
196
  sign_count=legacy_cred.sign_count,
@@ -217,8 +217,8 @@ async def migrate_from_sql(
217
217
  # Already in new format or unknown - try to use as-is
218
218
  session_key = base64url.enc(old_key[:12])
219
219
  db.sessions[session_key] = Session(
220
- user=sess.user_uuid,
221
- credential=sess.credential_uuid,
220
+ user_uuid=sess.user_uuid,
221
+ credential_uuid=sess.credential_uuid,
222
222
  host=sess.host,
223
223
  ip=sess.ip,
224
224
  user_agent=sess.user_agent,
@@ -241,14 +241,14 @@ async def migrate_from_sql(
241
241
  # Already in new format or unknown - truncate to 9 bytes
242
242
  token_key = old_key[:9]
243
243
  db.reset_tokens[token_key] = ResetToken(
244
- user=token.user_uuid,
244
+ user_uuid=token.user_uuid,
245
245
  expiry=token.expiry,
246
246
  token_type=token.token_type,
247
247
  )
248
248
  print(f" Migrated {len(token_models)} reset tokens")
249
249
 
250
250
  # Queue and flush all changes using the transaction mechanism
251
- with db.transaction("migrate"):
251
+ with db.transaction("migrate:sql"):
252
252
  pass # All data already added to _data, transaction commits on exit
253
253
 
254
254
  await store.flush()
paskia/migrate/sql.py CHANGED
@@ -9,7 +9,7 @@ DO NOT use this module for new code. Use paskia.db instead.
9
9
 
10
10
  from contextlib import asynccontextmanager
11
11
  from dataclasses import dataclass
12
- from datetime import datetime, timezone
12
+ from datetime import UTC, datetime
13
13
  from uuid import UUID
14
14
 
15
15
  from sqlalchemy import (
@@ -25,11 +25,6 @@ from sqlalchemy.dialects.sqlite import BLOB
25
25
  from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
26
26
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
27
27
 
28
- from paskia.db import (
29
- Org,
30
- Role,
31
- )
32
-
33
28
 
34
29
  # Legacy User class for SQL schema (uses 'role_uuid' not 'role')
35
30
  @dataclass
@@ -71,6 +66,17 @@ class _LegacyRole:
71
66
  permissions: list[str] | None = None
72
67
 
73
68
 
69
+ # Legacy Org class for SQL schema (has mutable permissions/roles lists)
70
+ @dataclass
71
+ class _LegacyOrg:
72
+ """Org as stored in the old SQL schema with mutable permissions/roles."""
73
+
74
+ uuid: UUID
75
+ display_name: str
76
+ permissions: list[str] | None = None
77
+ roles: list[_LegacyRole] | None = None
78
+
79
+
74
80
  # Legacy Session class for SQL schema (uses 'key' as field, 'user_uuid', 'credential_uuid')
75
81
  @dataclass
76
82
  class _LegacySession:
@@ -112,8 +118,8 @@ def _normalize_dt(value: datetime | None) -> datetime | None:
112
118
  if value is None:
113
119
  return None
114
120
  if value.tzinfo is None:
115
- return value.replace(tzinfo=timezone.utc)
116
- return value.astimezone(timezone.utc)
121
+ return value.replace(tzinfo=UTC)
122
+ return value.astimezone(UTC)
117
123
 
118
124
 
119
125
  class Base(DeclarativeBase):
@@ -128,12 +134,13 @@ class OrgModel(Base):
128
134
 
129
135
  def as_dataclass(self):
130
136
  # Base Org without permissions/roles (filled by data accessors)
131
- org = Org(display_name=self.display_name)
132
- org.uuid = UUID(bytes=self.uuid)
133
- return org
137
+ return _LegacyOrg(
138
+ uuid=UUID(bytes=self.uuid),
139
+ display_name=self.display_name,
140
+ )
134
141
 
135
142
  @staticmethod
136
- def from_dataclass(org: Org):
143
+ def from_dataclass(org: _LegacyOrg):
137
144
  return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name)
138
145
 
139
146
 
@@ -172,7 +179,7 @@ class UserModel(Base):
172
179
  LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
173
180
  )
174
181
  created_at: Mapped[datetime] = mapped_column(
175
- DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
182
+ DateTime(timezone=True), default=lambda: datetime.now(UTC)
176
183
  )
177
184
  last_seen: Mapped[datetime | None] = mapped_column(
178
185
  DateTime(timezone=True), nullable=True
@@ -195,7 +202,7 @@ class UserModel(Base):
195
202
  uuid=user.uuid.bytes,
196
203
  display_name=user.display_name,
197
204
  role_uuid=user.role_uuid.bytes,
198
- created_at=user.created_at or datetime.now(timezone.utc),
205
+ created_at=user.created_at or datetime.now(UTC),
199
206
  last_seen=user.last_seen,
200
207
  visits=user.visits,
201
208
  )
@@ -215,7 +222,7 @@ class CredentialModel(Base):
215
222
  public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
216
223
  sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
217
224
  created_at: Mapped[datetime] = mapped_column(
218
- DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
225
+ DateTime(timezone=True), default=lambda: datetime.now(UTC)
219
226
  )
220
227
  last_used: Mapped[datetime | None] = mapped_column(
221
228
  DateTime(timezone=True), nullable=True
@@ -255,7 +262,7 @@ class SessionModel(Base):
255
262
  user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
256
263
  renewed: Mapped[datetime] = mapped_column(
257
264
  DateTime(timezone=True),
258
- default=lambda: datetime.now(timezone.utc),
265
+ default=lambda: datetime.now(UTC),
259
266
  nullable=False,
260
267
  )
261
268
 
@@ -388,7 +395,7 @@ class DB:
388
395
  result = await session.execute(select(PermissionModel))
389
396
  return [p.as_dataclass() for p in result.scalars().all()]
390
397
 
391
- async def list_organizations(self) -> list[Org]:
398
+ async def list_organizations(self) -> list[_LegacyOrg]:
392
399
  async with self.session() as session:
393
400
  # Load all orgs
394
401
  orgs_result = await session.execute(select(OrgModel))
@@ -415,13 +422,13 @@ class DB:
415
422
  perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id)
416
423
 
417
424
  # Build org dataclasses with roles and permission IDs
418
- roles_by_org: dict[bytes, list[Role]] = {}
425
+ roles_by_org: dict[bytes, list[_LegacyRole]] = {}
419
426
  for rm in role_models:
420
427
  r_dc = rm.as_dataclass()
421
428
  r_dc.permissions = perms_by_role.get(rm.uuid, [])
422
429
  roles_by_org.setdefault(rm.org_uuid, []).append(r_dc)
423
430
 
424
- orgs: list[Org] = []
431
+ orgs: list[_LegacyOrg] = []
425
432
  for om in org_models:
426
433
  o_dc = om.as_dataclass()
427
434
  o_dc.permissions = perms_by_org.get(om.uuid, [])