paskia 0.8.1__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.
- paskia/_version.py +2 -2
- paskia/aaguid/__init__.py +5 -4
- paskia/authsession.py +15 -43
- paskia/bootstrap.py +31 -103
- paskia/db/__init__.py +27 -55
- paskia/db/background.py +20 -40
- paskia/db/jsonl.py +196 -46
- paskia/db/logging.py +233 -0
- paskia/db/migrations.py +33 -0
- paskia/db/operations.py +409 -825
- paskia/db/structs.py +408 -94
- paskia/fastapi/__main__.py +25 -28
- paskia/fastapi/admin.py +147 -329
- paskia/fastapi/api.py +68 -110
- paskia/fastapi/logging.py +218 -0
- paskia/fastapi/mainapp.py +25 -8
- paskia/fastapi/remote.py +16 -39
- paskia/fastapi/reset.py +27 -19
- paskia/fastapi/response.py +22 -0
- paskia/fastapi/session.py +2 -2
- paskia/fastapi/user.py +24 -30
- paskia/fastapi/ws.py +25 -60
- paskia/fastapi/wschat.py +62 -0
- paskia/fastapi/wsutil.py +15 -2
- paskia/frontend-build/auth/admin/index.html +5 -5
- paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
- paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
- paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
- paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
- paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
- paskia/frontend-build/auth/index.html +5 -5
- paskia/frontend-build/auth/restricted/index.html +4 -4
- paskia/frontend-build/int/forward/index.html +4 -4
- paskia/frontend-build/int/reset/index.html +3 -3
- paskia/globals.py +2 -2
- paskia/migrate/__init__.py +67 -60
- paskia/migrate/sql.py +94 -37
- paskia/remoteauth.py +7 -8
- paskia/sansio.py +6 -12
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
- paskia-0.9.1.dist-info/RECORD +60 -0
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
- paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
- paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
- paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
- paskia-0.8.1.dist-info/RECORD +0 -55
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
- {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/entry_points.txt +0 -0
paskia/db/structs.py
CHANGED
|
@@ -1,43 +1,223 @@
|
|
|
1
|
-
from
|
|
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
|
|
8
|
+
import uuid7
|
|
9
|
+
|
|
10
|
+
from paskia import db
|
|
11
|
+
from paskia.util.hostutil import normalize_host
|
|
12
|
+
|
|
13
|
+
# Sentinel for uuid fields before they are set by create() or DB post init
|
|
14
|
+
_UUID_UNSET = UUID(int=0)
|
|
15
|
+
|
|
5
16
|
|
|
17
|
+
class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
|
18
|
+
"""Permission data structure.
|
|
19
|
+
|
|
20
|
+
Mutable fields: scope, display_name, domain, orgs
|
|
21
|
+
Immutable fields: None (all fields can be updated via update_permission)
|
|
22
|
+
uuid is generated at creation.
|
|
23
|
+
"""
|
|
6
24
|
|
|
7
|
-
class Permission(msgspec.Struct, omit_defaults=True):
|
|
8
|
-
uuid: UUID # UUID primary key
|
|
9
25
|
scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write")
|
|
10
26
|
display_name: str
|
|
11
27
|
domain: str | None = None # If set, scopes permission to this domain
|
|
28
|
+
orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
|
|
12
29
|
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
if not hasattr(self, "uuid"):
|
|
32
|
+
self.uuid: UUID = _UUID_UNSET
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def org_set(self) -> set[UUID]:
|
|
36
|
+
"""Get orgs that can grant this permission as a set."""
|
|
37
|
+
return set(self.orgs.keys())
|
|
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
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def create(
|
|
50
|
+
cls,
|
|
51
|
+
scope: str,
|
|
52
|
+
display_name: str,
|
|
53
|
+
domain: str | None = None,
|
|
54
|
+
) -> Permission:
|
|
55
|
+
"""Create a new Permission with auto-generated uuid7."""
|
|
56
|
+
perm = cls(
|
|
57
|
+
scope=scope,
|
|
58
|
+
display_name=display_name,
|
|
59
|
+
domain=domain,
|
|
60
|
+
)
|
|
61
|
+
perm.uuid = uuid7.create()
|
|
62
|
+
return perm
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Org(msgspec.Struct, dict=True):
|
|
66
|
+
"""Organization data structure."""
|
|
13
67
|
|
|
14
|
-
class Role(msgspec.Struct):
|
|
15
|
-
uuid: UUID
|
|
16
|
-
org_uuid: UUID
|
|
17
68
|
display_name: str
|
|
18
|
-
permissions: list[str] = [] # permission UUIDs this role grants
|
|
19
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]
|
|
20
78
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
|
|
92
|
+
class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
|
93
|
+
"""Role data structure.
|
|
94
|
+
|
|
95
|
+
Mutable fields: display_name, permissions
|
|
96
|
+
Immutable fields: org_uuid (set at creation, never modified)
|
|
97
|
+
uuid is generated at creation.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
org_uuid: UUID = msgspec.field(name="org")
|
|
23
101
|
display_name: str
|
|
24
|
-
permissions:
|
|
25
|
-
roles: list[Role] = [] # roles belonging to this org
|
|
102
|
+
permissions: dict[UUID, bool] = {} # permission_uuid -> True
|
|
26
103
|
|
|
104
|
+
def __post_init__(self):
|
|
105
|
+
if not hasattr(self, "uuid"):
|
|
106
|
+
self.uuid: UUID = _UUID_UNSET
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def permission_set(self) -> set[UUID]:
|
|
110
|
+
"""Get permissions as a set of UUIDs."""
|
|
111
|
+
return set(self.permissions.keys())
|
|
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
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def create(
|
|
134
|
+
cls,
|
|
135
|
+
org: UUID | Org,
|
|
136
|
+
display_name: str,
|
|
137
|
+
permissions: set[UUID] | None = None,
|
|
138
|
+
) -> Role:
|
|
139
|
+
"""Create a new Role with auto-generated uuid7."""
|
|
140
|
+
org_uuid = org if isinstance(org, UUID) else org.uuid
|
|
141
|
+
role = cls(
|
|
142
|
+
org_uuid=org_uuid,
|
|
143
|
+
display_name=display_name,
|
|
144
|
+
permissions={p: True for p in (permissions or set())},
|
|
145
|
+
)
|
|
146
|
+
role.uuid = uuid7.create()
|
|
147
|
+
return role
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class User(msgspec.Struct, dict=True):
|
|
151
|
+
"""User data structure.
|
|
152
|
+
|
|
153
|
+
Mutable fields: display_name, role_uuid, last_seen, visits
|
|
154
|
+
Immutable fields: created_at (set at creation, never modified)
|
|
155
|
+
uuid is derived from created_at using uuid7.
|
|
156
|
+
"""
|
|
27
157
|
|
|
28
|
-
class User(msgspec.Struct):
|
|
29
|
-
uuid: UUID
|
|
30
158
|
display_name: str
|
|
31
|
-
role_uuid: UUID
|
|
32
|
-
created_at: datetime
|
|
159
|
+
role_uuid: UUID = msgspec.field(name="role")
|
|
160
|
+
created_at: datetime
|
|
33
161
|
last_seen: datetime | None = None
|
|
34
162
|
visits: int = 0
|
|
35
163
|
|
|
164
|
+
def __post_init__(self):
|
|
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]
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def create(
|
|
195
|
+
cls,
|
|
196
|
+
display_name: str,
|
|
197
|
+
role: UUID | Role,
|
|
198
|
+
created_at: datetime | None = None,
|
|
199
|
+
) -> User:
|
|
200
|
+
"""Create a new User with auto-generated uuid7."""
|
|
201
|
+
role_uuid = role if isinstance(role, UUID) else role.uuid
|
|
202
|
+
user = cls(
|
|
203
|
+
display_name=display_name,
|
|
204
|
+
role_uuid=role_uuid,
|
|
205
|
+
created_at=created_at or datetime.now(UTC),
|
|
206
|
+
)
|
|
207
|
+
user.uuid = uuid7.create(user.created_at)
|
|
208
|
+
return user
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Credential(msgspec.Struct, dict=True):
|
|
212
|
+
"""Credential (passkey) data structure.
|
|
213
|
+
|
|
214
|
+
Mutable fields: sign_count, last_used, last_verified
|
|
215
|
+
Immutable fields: credential_id, user, aaguid, public_key, created_at
|
|
216
|
+
uuid is derived from created_at using uuid7.
|
|
217
|
+
"""
|
|
36
218
|
|
|
37
|
-
class Credential(msgspec.Struct):
|
|
38
|
-
uuid: UUID
|
|
39
219
|
credential_id: bytes # Long binary ID from the authenticator
|
|
40
|
-
user_uuid: UUID
|
|
220
|
+
user_uuid: UUID = msgspec.field(name="user")
|
|
41
221
|
aaguid: UUID
|
|
42
222
|
public_key: bytes
|
|
43
223
|
sign_count: int
|
|
@@ -45,16 +225,78 @@ class Credential(msgspec.Struct):
|
|
|
45
225
|
last_used: datetime | None = None
|
|
46
226
|
last_verified: datetime | None = None
|
|
47
227
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
228
|
+
def __post_init__(self):
|
|
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
|
+
]
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def create(
|
|
246
|
+
cls,
|
|
247
|
+
credential_id: bytes,
|
|
248
|
+
user: UUID | User,
|
|
249
|
+
aaguid: UUID,
|
|
250
|
+
public_key: bytes,
|
|
251
|
+
sign_count: int,
|
|
252
|
+
created_at: datetime | None = None,
|
|
253
|
+
) -> Credential:
|
|
254
|
+
"""Create a new Credential with auto-generated uuid7."""
|
|
255
|
+
user_uuid = user if isinstance(user, UUID) else user.uuid
|
|
256
|
+
now = created_at or datetime.now(UTC)
|
|
257
|
+
cred = cls(
|
|
258
|
+
credential_id=credential_id,
|
|
259
|
+
user_uuid=user_uuid,
|
|
260
|
+
aaguid=aaguid,
|
|
261
|
+
public_key=public_key,
|
|
262
|
+
sign_count=sign_count,
|
|
263
|
+
created_at=now,
|
|
264
|
+
last_used=now,
|
|
265
|
+
last_verified=now,
|
|
266
|
+
)
|
|
267
|
+
cred.uuid = uuid7.create(now)
|
|
268
|
+
return cred
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class Session(msgspec.Struct, dict=True):
|
|
272
|
+
"""Session data structure.
|
|
273
|
+
|
|
274
|
+
Mutable fields: expiry (updated on session refresh)
|
|
275
|
+
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
|
|
276
|
+
key is stored in the dict key, not in the struct.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
user_uuid: UUID = msgspec.field(name="user")
|
|
280
|
+
credential_uuid: UUID = msgspec.field(name="credential")
|
|
281
|
+
host: str
|
|
282
|
+
ip: str
|
|
283
|
+
user_agent: str
|
|
56
284
|
expiry: datetime
|
|
57
285
|
|
|
286
|
+
def __post_init__(self):
|
|
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]
|
|
299
|
+
|
|
58
300
|
def metadata(self) -> dict:
|
|
59
301
|
"""Return session metadata for backwards compatibility."""
|
|
60
302
|
return {
|
|
@@ -63,86 +305,158 @@ class Session(msgspec.Struct):
|
|
|
63
305
|
"expiry": self.expiry.isoformat(),
|
|
64
306
|
}
|
|
65
307
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
|
|
334
|
+
|
|
335
|
+
class ResetToken(msgspec.Struct, dict=True):
|
|
336
|
+
"""Reset/device-addition token data structure.
|
|
337
|
+
|
|
338
|
+
Immutable fields: All fields (tokens are created and deleted, never modified)
|
|
339
|
+
key is stored in the dict key, not in the struct.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
user_uuid: UUID = msgspec.field(name="user")
|
|
70
343
|
expiry: datetime
|
|
71
344
|
token_type: str
|
|
72
345
|
|
|
346
|
+
def __post_init__(self):
|
|
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]
|
|
354
|
+
|
|
73
355
|
|
|
74
356
|
class SessionContext(msgspec.Struct):
|
|
75
357
|
session: Session
|
|
76
358
|
user: User
|
|
77
359
|
org: Org
|
|
78
360
|
role: Role
|
|
79
|
-
credential: Credential
|
|
80
|
-
permissions: list[Permission]
|
|
361
|
+
credential: Credential
|
|
362
|
+
permissions: list[Permission] = []
|
|
81
363
|
|
|
82
364
|
|
|
83
365
|
# -------------------------------------------------------------------------
|
|
84
|
-
#
|
|
366
|
+
# Database storage structure
|
|
85
367
|
# -------------------------------------------------------------------------
|
|
86
368
|
|
|
87
369
|
|
|
88
|
-
class
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
orgs: dict[UUID,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
370
|
+
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|
371
|
+
"""In-memory database. Access fields directly for reads."""
|
|
372
|
+
|
|
373
|
+
permissions: dict[UUID, Permission] = {}
|
|
374
|
+
orgs: dict[UUID, Org] = {}
|
|
375
|
+
roles: dict[UUID, Role] = {}
|
|
376
|
+
users: dict[UUID, User] = {}
|
|
377
|
+
credentials: dict[UUID, Credential] = {}
|
|
378
|
+
sessions: dict[str, Session] = {}
|
|
379
|
+
reset_tokens: dict[bytes, ResetToken] = {}
|
|
380
|
+
|
|
381
|
+
def __post_init__(self):
|
|
382
|
+
# Store reference for persistence (not serialized)
|
|
383
|
+
self._store = None
|
|
384
|
+
# Set the key fields on all stored objects
|
|
385
|
+
for uuid, perm in self.permissions.items():
|
|
386
|
+
perm.uuid = uuid
|
|
387
|
+
for uuid, org in self.orgs.items():
|
|
388
|
+
org.uuid = uuid
|
|
389
|
+
for uuid, role in self.roles.items():
|
|
390
|
+
role.uuid = uuid
|
|
391
|
+
for uuid, user in self.users.items():
|
|
392
|
+
user.uuid = uuid
|
|
393
|
+
for uuid, cred in self.credentials.items():
|
|
394
|
+
cred.uuid = uuid
|
|
395
|
+
for key, session in self.sessions.items():
|
|
396
|
+
session.key = key
|
|
397
|
+
for key, token in self.reset_tokens.items():
|
|
398
|
+
token.key = key
|
|
399
|
+
|
|
400
|
+
def transaction(self, action, ctx=None, *, user=None):
|
|
401
|
+
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
|
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
|
+
)
|
paskia/fastapi/__main__.py
CHANGED
|
@@ -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,32 +183,13 @@ 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 = {
|
|
211
191
|
"log_level": "info",
|
|
192
|
+
"access_log": False, # We use custom AccessLogMiddleware instead
|
|
212
193
|
}
|
|
213
194
|
|
|
214
195
|
if devmode:
|
|
@@ -221,18 +202,34 @@ def main():
|
|
|
221
202
|
# Suppress uvicorn startup messages in dev mode
|
|
222
203
|
run_kwargs["log_level"] = "warning"
|
|
223
204
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
205
|
+
async def async_main():
|
|
206
|
+
await _globals.init(
|
|
207
|
+
rp_id=config.rp_id,
|
|
208
|
+
rp_name=config.rp_name,
|
|
209
|
+
origins=config.origins,
|
|
210
|
+
bootstrap=False,
|
|
211
|
+
)
|
|
212
|
+
await bootstrap_if_needed()
|
|
213
|
+
await flush()
|
|
214
|
+
|
|
215
|
+
if is_reset:
|
|
216
|
+
exit_code = reset_cmd.run(args.reset_query)
|
|
217
|
+
raise SystemExit(exit_code)
|
|
218
|
+
|
|
219
|
+
if len(endpoints) > 1:
|
|
227
220
|
async with asyncio.TaskGroup() as tg:
|
|
228
221
|
for ep in endpoints:
|
|
229
222
|
tg.create_task(
|
|
230
223
|
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
|
|
231
224
|
)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
225
|
+
else:
|
|
226
|
+
server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
|
|
227
|
+
await server.serve()
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
asyncio.run(async_main())
|
|
231
|
+
except KeyboardInterrupt:
|
|
232
|
+
pass
|
|
236
233
|
|
|
237
234
|
|
|
238
235
|
if __name__ == "__main__":
|