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/db/structs.py CHANGED
@@ -1,9 +1,15 @@
1
- from datetime import datetime, timezone
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from datetime import UTC, datetime
2
5
  from uuid import UUID
3
6
 
4
7
  import msgspec
5
8
  import uuid7
6
9
 
10
+ from paskia import db
11
+ from paskia.util.hostutil import normalize_host
12
+
7
13
  # Sentinel for uuid fields before they are set by create() or DB post init
8
14
  _UUID_UNSET = UUID(int=0)
9
15
 
@@ -22,20 +28,30 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
22
28
  orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
23
29
 
24
30
  def __post_init__(self):
25
- self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
31
+ if not hasattr(self, "uuid"):
32
+ self.uuid: UUID = _UUID_UNSET
26
33
 
27
34
  @property
28
35
  def org_set(self) -> set[UUID]:
29
36
  """Get orgs that can grant this permission as a set."""
30
37
  return set(self.orgs.keys())
31
38
 
39
+ @property
40
+ def orgs_list(self) -> list[Org]:
41
+ """Get list of Org objects that can grant this permission."""
42
+ return [
43
+ db.data().orgs[org_uuid]
44
+ for org_uuid in self.orgs.keys()
45
+ if org_uuid in db.data().orgs
46
+ ]
47
+
32
48
  @classmethod
33
49
  def create(
34
50
  cls,
35
51
  scope: str,
36
52
  display_name: str,
37
53
  domain: str | None = None,
38
- ) -> "Permission":
54
+ ) -> Permission:
39
55
  """Create a new Permission with auto-generated uuid7."""
40
56
  perm = cls(
41
57
  scope=scope,
@@ -46,36 +62,84 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
46
62
  return perm
47
63
 
48
64
 
65
+ class Org(msgspec.Struct, dict=True):
66
+ """Organization data structure."""
67
+
68
+ display_name: str
69
+
70
+ def __post_init__(self):
71
+ if not hasattr(self, "uuid"):
72
+ self.uuid: UUID = _UUID_UNSET
73
+
74
+ @property
75
+ def roles(self) -> list[Role]:
76
+ """Get all roles that belong to this organization."""
77
+ return [r for r in db.data().roles.values() if r.org_uuid == self.uuid]
78
+
79
+ @property
80
+ def permissions(self) -> list[Permission]:
81
+ """Get all permissions that this organization can grant."""
82
+ return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
83
+
84
+ @classmethod
85
+ def create(cls, display_name: str) -> Org:
86
+ """Create a new Org with auto-generated uuid7."""
87
+ org = cls(display_name=display_name)
88
+ org.uuid = uuid7.create()
89
+ return org
90
+
91
+
49
92
  class Role(msgspec.Struct, dict=True, omit_defaults=True):
50
93
  """Role data structure.
51
94
 
52
95
  Mutable fields: display_name, permissions
53
- Immutable fields: org (set at creation, never modified)
96
+ Immutable fields: org_uuid (set at creation, never modified)
54
97
  uuid is generated at creation.
55
98
  """
56
99
 
57
- org: UUID
100
+ org_uuid: UUID = msgspec.field(name="org")
58
101
  display_name: str
59
102
  permissions: dict[UUID, bool] = {} # permission_uuid -> True
60
103
 
61
104
  def __post_init__(self):
62
- self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
105
+ if not hasattr(self, "uuid"):
106
+ self.uuid: UUID = _UUID_UNSET
63
107
 
64
108
  @property
65
109
  def permission_set(self) -> set[UUID]:
66
110
  """Get permissions as a set of UUIDs."""
67
111
  return set(self.permissions.keys())
68
112
 
113
+ @property
114
+ def permissions_list(self) -> list[Permission]:
115
+ """Get list of Permission objects for this role."""
116
+ return [
117
+ db.data().permissions[perm_uuid]
118
+ for perm_uuid in self.permissions.keys()
119
+ if perm_uuid in db.data().permissions
120
+ ]
121
+
122
+ @property
123
+ def org(self) -> Org:
124
+ """Get the organization object this role belongs to."""
125
+ return db.data().orgs[self.org_uuid]
126
+
127
+ @property
128
+ def users(self) -> list[User]:
129
+ """Get all users that have this role."""
130
+ return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
131
+
69
132
  @classmethod
70
133
  def create(
71
134
  cls,
72
- org: UUID,
135
+ org: UUID | Org,
73
136
  display_name: str,
74
137
  permissions: set[UUID] | None = None,
75
- ) -> "Role":
138
+ ) -> Role:
76
139
  """Create a new Role with auto-generated uuid7."""
140
+ org_uuid = org if isinstance(org, UUID) else org.uuid
77
141
  role = cls(
78
- org=org,
142
+ org_uuid=org_uuid,
79
143
  display_name=display_name,
80
144
  permissions={p: True for p in (permissions or set())},
81
145
  )
@@ -83,52 +147,62 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
83
147
  return role
84
148
 
85
149
 
86
- class Org(msgspec.Struct, dict=True):
87
- """Organization data structure."""
88
-
89
- display_name: str
90
-
91
- def __post_init__(self):
92
- self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
93
-
94
- @classmethod
95
- def create(cls, display_name: str) -> "Org":
96
- """Create a new Org with auto-generated uuid7."""
97
- org = cls(display_name=display_name)
98
- org.uuid = uuid7.create()
99
- return org
100
-
101
-
102
150
  class User(msgspec.Struct, dict=True):
103
151
  """User data structure.
104
152
 
105
- Mutable fields: display_name, role, last_seen, visits
153
+ Mutable fields: display_name, role_uuid, last_seen, visits
106
154
  Immutable fields: created_at (set at creation, never modified)
107
155
  uuid is derived from created_at using uuid7.
108
156
  """
109
157
 
110
158
  display_name: str
111
- role: UUID
159
+ role_uuid: UUID = msgspec.field(name="role")
112
160
  created_at: datetime
113
161
  last_seen: datetime | None = None
114
162
  visits: int = 0
115
163
 
116
164
  def __post_init__(self):
117
- self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
165
+ if not hasattr(self, "uuid"):
166
+ self.uuid: UUID = _UUID_UNSET
167
+
168
+ @property
169
+ def role(self) -> Role:
170
+ """Get the role object this user has."""
171
+ return db.data().roles[self.role_uuid]
172
+
173
+ @property
174
+ def org(self) -> Org:
175
+ """Get the organization this user belongs to (via role)."""
176
+ return self.role.org
177
+
178
+ @property
179
+ def credentials(self) -> list[Credential]:
180
+ """Get all credentials for this user."""
181
+ return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
182
+
183
+ @property
184
+ def sessions(self) -> list[Session]:
185
+ """Get all sessions for this user."""
186
+ return [s for s in db.data().sessions.values() if s.user_uuid == self.uuid]
187
+
188
+ @property
189
+ def reset_tokens(self) -> list[ResetToken]:
190
+ """Get all reset tokens for this user."""
191
+ return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
118
192
 
119
193
  @classmethod
120
194
  def create(
121
195
  cls,
122
196
  display_name: str,
123
- role: UUID,
197
+ role: UUID | Role,
124
198
  created_at: datetime | None = None,
125
- ) -> "User":
199
+ ) -> User:
126
200
  """Create a new User with auto-generated uuid7."""
127
-
201
+ role_uuid = role if isinstance(role, UUID) else role.uuid
128
202
  user = cls(
129
203
  display_name=display_name,
130
- role=role,
131
- created_at=created_at or datetime.now(timezone.utc),
204
+ role_uuid=role_uuid,
205
+ created_at=created_at or datetime.now(UTC),
132
206
  )
133
207
  user.uuid = uuid7.create(user.created_at)
134
208
  return user
@@ -143,7 +217,7 @@ class Credential(msgspec.Struct, dict=True):
143
217
  """
144
218
 
145
219
  credential_id: bytes # Long binary ID from the authenticator
146
- user: UUID
220
+ user_uuid: UUID = msgspec.field(name="user")
147
221
  aaguid: UUID
148
222
  public_key: bytes
149
223
  sign_count: int
@@ -152,23 +226,37 @@ class Credential(msgspec.Struct, dict=True):
152
226
  last_verified: datetime | None = None
153
227
 
154
228
  def __post_init__(self):
155
- self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
229
+ if not hasattr(self, "uuid"):
230
+ self.uuid: UUID = _UUID_UNSET
231
+
232
+ @property
233
+ def user(self) -> User:
234
+ """Get the User object for this credential."""
235
+ return db.data().users[self.user_uuid]
236
+
237
+ @property
238
+ def sessions(self) -> list[Session]:
239
+ """Get all sessions using this credential."""
240
+ return [
241
+ s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
242
+ ]
156
243
 
157
244
  @classmethod
158
245
  def create(
159
246
  cls,
160
247
  credential_id: bytes,
161
- user: UUID,
248
+ user: UUID | User,
162
249
  aaguid: UUID,
163
250
  public_key: bytes,
164
251
  sign_count: int,
165
252
  created_at: datetime | None = None,
166
- ) -> "Credential":
253
+ ) -> Credential:
167
254
  """Create a new Credential with auto-generated uuid7."""
168
- now = created_at or datetime.now(timezone.utc)
255
+ user_uuid = user if isinstance(user, UUID) else user.uuid
256
+ now = created_at or datetime.now(UTC)
169
257
  cred = cls(
170
258
  credential_id=credential_id,
171
- user=user,
259
+ user_uuid=user_uuid,
172
260
  aaguid=aaguid,
173
261
  public_key=public_key,
174
262
  sign_count=sign_count,
@@ -184,19 +272,30 @@ class Session(msgspec.Struct, dict=True):
184
272
  """Session data structure.
185
273
 
186
274
  Mutable fields: expiry (updated on session refresh)
187
- Immutable fields: user, credential, host, ip, user_agent
275
+ Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
188
276
  key is stored in the dict key, not in the struct.
189
277
  """
190
278
 
191
- user: UUID
192
- credential: UUID
279
+ user_uuid: UUID = msgspec.field(name="user")
280
+ credential_uuid: UUID = msgspec.field(name="credential")
193
281
  host: str
194
282
  ip: str
195
283
  user_agent: str
196
284
  expiry: datetime
197
285
 
198
286
  def __post_init__(self):
199
- self.key: str = "" # Convenience field, not serialized
287
+ if not hasattr(self, "key"):
288
+ self.key: str = ""
289
+
290
+ @property
291
+ def user(self) -> User:
292
+ """Get the User object for this session."""
293
+ return db.data().users[self.user_uuid]
294
+
295
+ @property
296
+ def credential(self) -> Credential:
297
+ """Get the Credential object for this session."""
298
+ return db.data().credentials[self.credential_uuid]
200
299
 
201
300
  def metadata(self) -> dict:
202
301
  """Return session metadata for backwards compatibility."""
@@ -206,6 +305,32 @@ class Session(msgspec.Struct, dict=True):
206
305
  "expiry": self.expiry.isoformat(),
207
306
  }
208
307
 
308
+ @classmethod
309
+ def create(
310
+ cls,
311
+ user: UUID | User,
312
+ credential: UUID | Credential,
313
+ host: str,
314
+ ip: str,
315
+ user_agent: str,
316
+ expiry: datetime,
317
+ ) -> Session:
318
+ """Create a new Session with auto-generated key."""
319
+ user_uuid = user if isinstance(user, UUID) else user.uuid
320
+ credential_uuid = (
321
+ credential if isinstance(credential, UUID) else credential.uuid
322
+ )
323
+ session = cls(
324
+ user_uuid=user_uuid,
325
+ credential_uuid=credential_uuid,
326
+ host=host,
327
+ ip=ip,
328
+ user_agent=user_agent,
329
+ expiry=expiry,
330
+ )
331
+ session.key = secrets.token_urlsafe(12)
332
+ return session
333
+
209
334
 
210
335
  class ResetToken(msgspec.Struct, dict=True):
211
336
  """Reset/device-addition token data structure.
@@ -214,12 +339,18 @@ class ResetToken(msgspec.Struct, dict=True):
214
339
  key is stored in the dict key, not in the struct.
215
340
  """
216
341
 
217
- user: UUID
342
+ user_uuid: UUID = msgspec.field(name="user")
218
343
  expiry: datetime
219
344
  token_type: str
220
345
 
221
346
  def __post_init__(self):
222
- self.key: bytes = b"" # Convenience field, not serialized
347
+ if not hasattr(self, "key"):
348
+ self.key: bytes = b""
349
+
350
+ @property
351
+ def user(self) -> User:
352
+ """Get the User object for this reset token."""
353
+ return db.data().users[self.user_uuid]
223
354
 
224
355
 
225
356
  class SessionContext(msgspec.Struct):
@@ -246,7 +377,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
246
377
  credentials: dict[UUID, Credential] = {}
247
378
  sessions: dict[str, Session] = {}
248
379
  reset_tokens: dict[bytes, ResetToken] = {}
249
- v: int = 0
250
380
 
251
381
  def __post_init__(self):
252
382
  # Store reference for persistence (not serialized)
@@ -270,3 +400,63 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
270
400
  def transaction(self, action, ctx=None, *, user=None):
271
401
  """Wrap writes in transaction. Delegates to JsonlStore."""
272
402
  return self._store.transaction(action, ctx, user=user)
403
+
404
+ def session_ctx(
405
+ self, session_key: str, host: str | None = None
406
+ ) -> SessionContext | None:
407
+ """Get full session context with effective permissions.
408
+
409
+ Args:
410
+ session_key: The session key string
411
+ host: Optional host for binding/validation and domain-scoped permissions
412
+
413
+ Returns:
414
+ SessionContext if valid, None if session not found, expired, or host mismatch
415
+ """
416
+ try:
417
+ s = self.sessions[session_key]
418
+ except KeyError:
419
+ return None
420
+
421
+ # Validate host matches (sessions are always created with a host)
422
+ if s.host != host:
423
+ # Session bound to different host
424
+ return None
425
+
426
+ try:
427
+ user = s.user
428
+ role = user.role
429
+ org = role.org
430
+ credential = s.credential
431
+ except KeyError:
432
+ return None
433
+
434
+ # Effective permissions: role's permissions that the org can grant
435
+ # Also filter by domain if host is provided
436
+ org_perm_uuids = {p.uuid for p in org.permissions}
437
+ normalized_host = normalize_host(host)
438
+ host_without_port = (
439
+ normalized_host.rsplit(":", 1)[0] if normalized_host else None
440
+ )
441
+
442
+ effective_perms = []
443
+ for perm_uuid in role.permission_set:
444
+ if perm_uuid not in org_perm_uuids:
445
+ continue
446
+ try:
447
+ p = self.permissions[perm_uuid]
448
+ except KeyError:
449
+ continue
450
+ # Check domain restriction
451
+ if p.domain is not None and p.domain != host_without_port:
452
+ continue
453
+ effective_perms.append(p)
454
+
455
+ return SessionContext(
456
+ session=s,
457
+ user=user,
458
+ org=org,
459
+ role=role,
460
+ credential=credential,
461
+ permissions=effective_perms,
462
+ )
@@ -7,12 +7,12 @@ from urllib.parse import urlparse
7
7
 
8
8
  from fastapi_vue.hostutil import parse_endpoint
9
9
  from uvicorn import Config, Server
10
+ from uvicorn import run as uvicorn_run
10
11
 
11
12
  from paskia import globals as _globals
12
13
  from paskia.bootstrap import bootstrap_if_needed
13
14
  from paskia.config import PaskiaConfig
14
15
  from paskia.db.background import flush
15
- from paskia.fastapi import app as fastapi_app
16
16
  from paskia.fastapi import reset as reset_cmd
17
17
  from paskia.util import startupbox
18
18
  from paskia.util.hostutil import normalize_origin
@@ -188,7 +188,8 @@ def main():
188
188
  devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
189
189
 
190
190
  run_kwargs: dict = {
191
- "log_level": "info",
191
+ "log_level": "warning", # Suppress startup messages; we use custom logging
192
+ "access_log": False, # We use custom AccessLogMiddleware instead
192
193
  }
193
194
 
194
195
  if devmode:
@@ -198,8 +199,6 @@ def main():
198
199
  raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
199
200
  run_kwargs["reload"] = True
200
201
  run_kwargs["reload_dirs"] = ["paskia"]
201
- # Suppress uvicorn startup messages in dev mode
202
- run_kwargs["log_level"] = "warning"
203
202
 
204
203
  async def async_main():
205
204
  await _globals.init(
@@ -219,10 +218,18 @@ def main():
219
218
  async with asyncio.TaskGroup() as tg:
220
219
  for ep in endpoints:
221
220
  tg.create_task(
222
- Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
221
+ Server(
222
+ Config(app="paskia.fastapi:app", **run_kwargs, **ep)
223
+ ).serve()
223
224
  )
225
+ elif devmode:
226
+ # Use uvicorn.run for proper reload support (it handles subprocess spawning)
227
+ ep = endpoints[0]
228
+ uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
224
229
  else:
225
- server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
230
+ server = Server(
231
+ Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
232
+ )
226
233
  await server.serve()
227
234
 
228
235
  try: