paskia 0.8.1__py3-none-any.whl → 0.9.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 (53) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +14 -27
  3. paskia/bootstrap.py +31 -103
  4. paskia/db/__init__.py +25 -51
  5. paskia/db/background.py +17 -37
  6. paskia/db/jsonl.py +168 -6
  7. paskia/db/migrations.py +34 -0
  8. paskia/db/operations.py +400 -723
  9. paskia/db/structs.py +214 -90
  10. paskia/fastapi/__main__.py +24 -28
  11. paskia/fastapi/admin.py +101 -160
  12. paskia/fastapi/api.py +47 -83
  13. paskia/fastapi/mainapp.py +13 -6
  14. paskia/fastapi/remote.py +16 -39
  15. paskia/fastapi/reset.py +27 -17
  16. paskia/fastapi/session.py +2 -2
  17. paskia/fastapi/user.py +21 -27
  18. paskia/fastapi/ws.py +27 -62
  19. paskia/fastapi/wschat.py +62 -0
  20. paskia/frontend-build/auth/admin/index.html +5 -5
  21. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  22. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  23. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  24. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  25. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  26. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  27. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  28. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  29. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  30. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  31. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  32. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  33. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  34. paskia/frontend-build/auth/index.html +5 -5
  35. paskia/frontend-build/auth/restricted/index.html +4 -4
  36. paskia/frontend-build/int/forward/index.html +4 -4
  37. paskia/frontend-build/int/reset/index.html +3 -3
  38. paskia/globals.py +2 -2
  39. paskia/migrate/__init__.py +62 -55
  40. paskia/migrate/sql.py +72 -22
  41. paskia/remoteauth.py +1 -2
  42. paskia/sansio.py +6 -12
  43. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/METADATA +1 -1
  44. paskia-0.9.0.dist-info/RECORD +57 -0
  45. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  46. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  47. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  48. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  49. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  50. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  51. paskia-0.8.1.dist-info/RECORD +0 -55
  52. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
  53. {paskia-0.8.1.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
paskia/db/structs.py CHANGED
@@ -1,43 +1,149 @@
1
- from datetime import datetime
1
+ from datetime import datetime, timezone
2
2
  from uuid import UUID
3
3
 
4
4
  import msgspec
5
+ import uuid7
5
6
 
7
+ # Sentinel for uuid fields before they are set by create() or DB post init
8
+ _UUID_UNSET = UUID(int=0)
9
+
10
+
11
+ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
12
+ """Permission data structure.
13
+
14
+ Mutable fields: scope, display_name, domain, orgs
15
+ Immutable fields: None (all fields can be updated via update_permission)
16
+ uuid is generated at creation.
17
+ """
6
18
 
7
- class Permission(msgspec.Struct, omit_defaults=True):
8
- uuid: UUID # UUID primary key
9
19
  scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write")
10
20
  display_name: str
11
21
  domain: str | None = None # If set, scopes permission to this domain
22
+ orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
12
23
 
24
+ def __post_init__(self):
25
+ self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
26
+
27
+ @property
28
+ def org_set(self) -> set[UUID]:
29
+ """Get orgs that can grant this permission as a set."""
30
+ return set(self.orgs.keys())
31
+
32
+ @classmethod
33
+ def create(
34
+ cls,
35
+ scope: str,
36
+ display_name: str,
37
+ domain: str | None = None,
38
+ ) -> "Permission":
39
+ """Create a new Permission with auto-generated uuid7."""
40
+ perm = cls(
41
+ scope=scope,
42
+ display_name=display_name,
43
+ domain=domain,
44
+ )
45
+ perm.uuid = uuid7.create()
46
+ return perm
47
+
48
+
49
+ class Role(msgspec.Struct, dict=True, omit_defaults=True):
50
+ """Role data structure.
51
+
52
+ Mutable fields: display_name, permissions
53
+ Immutable fields: org (set at creation, never modified)
54
+ uuid is generated at creation.
55
+ """
13
56
 
14
- class Role(msgspec.Struct):
15
- uuid: UUID
16
- org_uuid: UUID
57
+ org: UUID
17
58
  display_name: str
18
- permissions: list[str] = [] # permission UUIDs this role grants
59
+ permissions: dict[UUID, bool] = {} # permission_uuid -> True
19
60
 
61
+ def __post_init__(self):
62
+ self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
63
+
64
+ @property
65
+ def permission_set(self) -> set[UUID]:
66
+ """Get permissions as a set of UUIDs."""
67
+ return set(self.permissions.keys())
68
+
69
+ @classmethod
70
+ def create(
71
+ cls,
72
+ org: UUID,
73
+ display_name: str,
74
+ permissions: set[UUID] | None = None,
75
+ ) -> "Role":
76
+ """Create a new Role with auto-generated uuid7."""
77
+ role = cls(
78
+ org=org,
79
+ display_name=display_name,
80
+ permissions={p: True for p in (permissions or set())},
81
+ )
82
+ role.uuid = uuid7.create()
83
+ return role
84
+
85
+
86
+ class Org(msgspec.Struct, dict=True):
87
+ """Organization data structure."""
20
88
 
21
- class Org(msgspec.Struct):
22
- uuid: UUID
23
89
  display_name: str
24
- permissions: list[str] = [] # permission UUIDs this org can grant
25
- roles: list[Role] = [] # roles belonging to this org
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
26
100
 
27
101
 
28
- class User(msgspec.Struct):
29
- uuid: UUID
102
+ class User(msgspec.Struct, dict=True):
103
+ """User data structure.
104
+
105
+ Mutable fields: display_name, role, last_seen, visits
106
+ Immutable fields: created_at (set at creation, never modified)
107
+ uuid is derived from created_at using uuid7.
108
+ """
109
+
30
110
  display_name: str
31
- role_uuid: UUID
32
- created_at: datetime | None = None
111
+ role: UUID
112
+ created_at: datetime
33
113
  last_seen: datetime | None = None
34
114
  visits: int = 0
35
115
 
116
+ def __post_init__(self):
117
+ self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
118
+
119
+ @classmethod
120
+ def create(
121
+ cls,
122
+ display_name: str,
123
+ role: UUID,
124
+ created_at: datetime | None = None,
125
+ ) -> "User":
126
+ """Create a new User with auto-generated uuid7."""
127
+
128
+ user = cls(
129
+ display_name=display_name,
130
+ role=role,
131
+ created_at=created_at or datetime.now(timezone.utc),
132
+ )
133
+ user.uuid = uuid7.create(user.created_at)
134
+ return user
135
+
136
+
137
+ class Credential(msgspec.Struct, dict=True):
138
+ """Credential (passkey) data structure.
139
+
140
+ Mutable fields: sign_count, last_used, last_verified
141
+ Immutable fields: credential_id, user, aaguid, public_key, created_at
142
+ uuid is derived from created_at using uuid7.
143
+ """
36
144
 
37
- class Credential(msgspec.Struct):
38
- uuid: UUID
39
145
  credential_id: bytes # Long binary ID from the authenticator
40
- user_uuid: UUID
146
+ user: UUID
41
147
  aaguid: UUID
42
148
  public_key: bytes
43
149
  sign_count: int
@@ -45,16 +151,53 @@ class Credential(msgspec.Struct):
45
151
  last_used: datetime | None = None
46
152
  last_verified: datetime | None = None
47
153
 
154
+ def __post_init__(self):
155
+ self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
156
+
157
+ @classmethod
158
+ def create(
159
+ cls,
160
+ credential_id: bytes,
161
+ user: UUID,
162
+ aaguid: UUID,
163
+ public_key: bytes,
164
+ sign_count: int,
165
+ created_at: datetime | None = None,
166
+ ) -> "Credential":
167
+ """Create a new Credential with auto-generated uuid7."""
168
+ now = created_at or datetime.now(timezone.utc)
169
+ cred = cls(
170
+ credential_id=credential_id,
171
+ user=user,
172
+ aaguid=aaguid,
173
+ public_key=public_key,
174
+ sign_count=sign_count,
175
+ created_at=now,
176
+ last_used=now,
177
+ last_verified=now,
178
+ )
179
+ cred.uuid = uuid7.create(now)
180
+ return cred
181
+
182
+
183
+ class Session(msgspec.Struct, dict=True):
184
+ """Session data structure.
185
+
186
+ Mutable fields: expiry (updated on session refresh)
187
+ Immutable fields: user, credential, host, ip, user_agent
188
+ key is stored in the dict key, not in the struct.
189
+ """
48
190
 
49
- class Session(msgspec.Struct):
50
- key: str
51
- user_uuid: UUID
52
- credential_uuid: UUID
53
- host: str | None
54
- ip: str | None
55
- user_agent: str | None
191
+ user: UUID
192
+ credential: UUID
193
+ host: str
194
+ ip: str
195
+ user_agent: str
56
196
  expiry: datetime
57
197
 
198
+ def __post_init__(self):
199
+ self.key: str = "" # Convenience field, not serialized
200
+
58
201
  def metadata(self) -> dict:
59
202
  """Return session metadata for backwards compatibility."""
60
203
  return {
@@ -64,85 +207,66 @@ class Session(msgspec.Struct):
64
207
  }
65
208
 
66
209
 
67
- class ResetToken(msgspec.Struct):
68
- key: bytes
69
- user_uuid: UUID
210
+ class ResetToken(msgspec.Struct, dict=True):
211
+ """Reset/device-addition token data structure.
212
+
213
+ Immutable fields: All fields (tokens are created and deleted, never modified)
214
+ key is stored in the dict key, not in the struct.
215
+ """
216
+
217
+ user: UUID
70
218
  expiry: datetime
71
219
  token_type: str
72
220
 
221
+ def __post_init__(self):
222
+ self.key: bytes = b"" # Convenience field, not serialized
223
+
73
224
 
74
225
  class SessionContext(msgspec.Struct):
75
226
  session: Session
76
227
  user: User
77
228
  org: Org
78
229
  role: Role
79
- credential: Credential | None = None
80
- permissions: list[Permission] | None = None
230
+ credential: Credential
231
+ permissions: list[Permission] = []
81
232
 
82
233
 
83
234
  # -------------------------------------------------------------------------
84
- # Internal storage types (different structure for efficient storage)
235
+ # Database storage structure
85
236
  # -------------------------------------------------------------------------
86
237
 
87
238
 
88
- class _PermissionData(msgspec.Struct, omit_defaults=True):
89
- scope: str # Permission scope identifier
90
- display_name: str
91
- domain: str | None = None
92
- orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
93
-
94
-
95
- class _OrgData(msgspec.Struct):
96
- display_name: str
97
- created_at: datetime | None = None
239
+ class DB(msgspec.Struct, dict=True, omit_defaults=False):
240
+ """In-memory database. Access fields directly for reads."""
98
241
 
99
-
100
- class _RoleData(msgspec.Struct):
101
- org: UUID
102
- display_name: str
103
- permissions: dict[UUID, bool] = {} # permission_uuid -> True
104
-
105
-
106
- class _UserData(msgspec.Struct):
107
- display_name: str
108
- role: UUID
109
- created_at: datetime
110
- last_seen: datetime | None
111
- visits: int
112
-
113
-
114
- class _CredentialData(msgspec.Struct):
115
- credential_id: bytes
116
- user: UUID
117
- aaguid: UUID
118
- public_key: bytes
119
- sign_count: int
120
- created_at: datetime
121
- last_used: datetime | None
122
- last_verified: datetime | None
123
-
124
-
125
- class _SessionData(msgspec.Struct):
126
- user: UUID
127
- credential: UUID
128
- host: str | None
129
- ip: str | None
130
- user_agent: str | None
131
- expiry: datetime
132
-
133
-
134
- class _ResetTokenData(msgspec.Struct):
135
- user: UUID
136
- expiry: datetime
137
- token_type: str
138
-
139
-
140
- class _DatabaseData(msgspec.Struct, omit_defaults=True):
141
- permissions: dict[UUID, _PermissionData]
142
- orgs: dict[UUID, _OrgData]
143
- roles: dict[UUID, _RoleData]
144
- users: dict[UUID, _UserData]
145
- credentials: dict[UUID, _CredentialData]
146
- sessions: dict[str, _SessionData]
147
- reset_tokens: dict[bytes, _ResetTokenData]
242
+ permissions: dict[UUID, Permission] = {}
243
+ orgs: dict[UUID, Org] = {}
244
+ roles: dict[UUID, Role] = {}
245
+ users: dict[UUID, User] = {}
246
+ credentials: dict[UUID, Credential] = {}
247
+ sessions: dict[str, Session] = {}
248
+ reset_tokens: dict[bytes, ResetToken] = {}
148
249
  v: int = 0
250
+
251
+ def __post_init__(self):
252
+ # Store reference for persistence (not serialized)
253
+ self._store = None
254
+ # Set the key fields on all stored objects
255
+ for uuid, perm in self.permissions.items():
256
+ perm.uuid = uuid
257
+ for uuid, org in self.orgs.items():
258
+ org.uuid = uuid
259
+ for uuid, role in self.roles.items():
260
+ role.uuid = uuid
261
+ for uuid, user in self.users.items():
262
+ user.uuid = uuid
263
+ for uuid, cred in self.credentials.items():
264
+ cred.uuid = uuid
265
+ for key, session in self.sessions.items():
266
+ session.key = key
267
+ for key, token in self.reset_tokens.items():
268
+ token.key = key
269
+
270
+ def transaction(self, action, ctx=None, *, user=None):
271
+ """Wrap writes in transaction. Delegates to JsonlStore."""
272
+ return self._store.transaction(action, ctx, user=user)
@@ -5,13 +5,13 @@ import logging
5
5
  import os
6
6
  from urllib.parse import urlparse
7
7
 
8
- import uvicorn
9
8
  from fastapi_vue.hostutil import parse_endpoint
10
9
  from uvicorn import Config, Server
11
10
 
12
11
  from paskia import globals as _globals
13
12
  from paskia.bootstrap import bootstrap_if_needed
14
13
  from paskia.config import PaskiaConfig
14
+ from paskia.db.background import flush
15
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
@@ -183,28 +183,8 @@ def main():
183
183
  }
184
184
  os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
185
185
 
186
- # Initialize globals (without bootstrap yet)
187
- asyncio.run(
188
- _globals.init(
189
- rp_id=config.rp_id,
190
- rp_name=config.rp_name,
191
- origins=config.origins,
192
- bootstrap=False,
193
- )
194
- )
195
-
196
- # Print startup configuration
197
186
  startupbox.print_startup_config(config)
198
187
 
199
- # Bootstrap after startup box is printed
200
- asyncio.run(bootstrap_if_needed())
201
-
202
- # Handle reset command (no server start)
203
- if is_reset:
204
- exit_code = reset_cmd.run(args.reset_query)
205
- raise SystemExit(exit_code)
206
-
207
- # Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set
208
188
  devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
209
189
 
210
190
  run_kwargs: dict = {
@@ -221,18 +201,34 @@ def main():
221
201
  # Suppress uvicorn startup messages in dev mode
222
202
  run_kwargs["log_level"] = "warning"
223
203
 
224
- if len(endpoints) > 1:
225
- # Run separate servers for multiple endpoints (e.g. IPv4 + IPv6)
226
- async def serve_all():
204
+ async def async_main():
205
+ await _globals.init(
206
+ rp_id=config.rp_id,
207
+ rp_name=config.rp_name,
208
+ origins=config.origins,
209
+ bootstrap=False,
210
+ )
211
+ await bootstrap_if_needed()
212
+ await flush()
213
+
214
+ if is_reset:
215
+ exit_code = reset_cmd.run(args.reset_query)
216
+ raise SystemExit(exit_code)
217
+
218
+ if len(endpoints) > 1:
227
219
  async with asyncio.TaskGroup() as tg:
228
220
  for ep in endpoints:
229
221
  tg.create_task(
230
222
  Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
231
223
  )
232
-
233
- asyncio.run(serve_all())
234
- else:
235
- uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0])
224
+ else:
225
+ server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
226
+ await server.serve()
227
+
228
+ try:
229
+ asyncio.run(async_main())
230
+ except KeyboardInterrupt:
231
+ pass
236
232
 
237
233
 
238
234
  if __name__ == "__main__":