paskia 0.7.2__py3-none-any.whl → 0.8.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.
- paskia/_version.py +2 -2
- paskia/authsession.py +12 -49
- paskia/bootstrap.py +30 -25
- paskia/db/__init__.py +163 -401
- paskia/db/background.py +128 -0
- paskia/db/jsonl.py +132 -0
- paskia/db/operations.py +1241 -0
- paskia/db/structs.py +148 -0
- paskia/fastapi/admin.py +456 -215
- paskia/fastapi/api.py +16 -15
- paskia/fastapi/authz.py +7 -2
- paskia/fastapi/mainapp.py +2 -1
- paskia/fastapi/remote.py +20 -20
- paskia/fastapi/reset.py +9 -10
- paskia/fastapi/user.py +10 -18
- paskia/fastapi/ws.py +22 -19
- paskia/frontend-build/auth/admin/index.html +3 -3
- paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +8 -0
- paskia/frontend-build/auth/assets/admin-BeNu48FR.css +1 -0
- paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +1 -0
- paskia/frontend-build/auth/assets/{auth-BU_O38k2.css → auth-BKX7shEe.css} +1 -1
- paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +1 -0
- paskia/frontend-build/auth/index.html +3 -3
- paskia/globals.py +7 -10
- paskia/migrate/__init__.py +274 -0
- paskia/migrate/sql.py +381 -0
- paskia/util/permutil.py +16 -5
- paskia/util/sessionutil.py +3 -2
- paskia/util/userinfo.py +12 -26
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/METADATA +21 -25
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/RECORD +33 -29
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/entry_points.txt +1 -0
- paskia/db/sql.py +0 -1424
- paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +0 -8
- paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +0 -1
- paskia/frontend-build/auth/assets/admin-Df5_Damp.js +0 -1
- paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +0 -1
- paskia/util/tokens.py +0 -44
- {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/WHEEL +0 -0
paskia/db/__init__.py
CHANGED
|
@@ -1,415 +1,177 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Database module for WebAuthn passkey authentication.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from abc import ABC, abstractmethod
|
|
9
|
-
from dataclasses import dataclass, field
|
|
10
|
-
from datetime import datetime
|
|
11
|
-
from uuid import UUID
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class Permission:
|
|
16
|
-
id: str # String primary key (max 128 chars)
|
|
17
|
-
display_name: str
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@dataclass
|
|
21
|
-
class Role:
|
|
22
|
-
uuid: UUID
|
|
23
|
-
org_uuid: UUID
|
|
24
|
-
display_name: str
|
|
25
|
-
# List of permission IDs this role grants to its members
|
|
26
|
-
permissions: list[str] = field(default_factory=list) # permission IDs
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@dataclass
|
|
30
|
-
class Org:
|
|
31
|
-
uuid: UUID
|
|
32
|
-
display_name: str
|
|
33
|
-
# All permission IDs that the Org is allowed to grant to its roles
|
|
34
|
-
permissions: list[str] = field(default_factory=list) # permission IDs
|
|
35
|
-
# Roles belonging to this org
|
|
36
|
-
roles: list[Role] = field(default_factory=list)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@dataclass
|
|
40
|
-
class User:
|
|
41
|
-
uuid: UUID
|
|
42
|
-
display_name: str
|
|
43
|
-
role_uuid: UUID
|
|
44
|
-
created_at: datetime | None = None
|
|
45
|
-
last_seen: datetime | None = None
|
|
46
|
-
visits: int = 0
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@dataclass
|
|
50
|
-
class Credential:
|
|
51
|
-
uuid: UUID
|
|
52
|
-
credential_id: bytes # Long binary ID passed from the authenticator
|
|
53
|
-
user_uuid: UUID
|
|
54
|
-
aaguid: UUID
|
|
55
|
-
public_key: bytes
|
|
56
|
-
sign_count: int
|
|
57
|
-
created_at: datetime
|
|
58
|
-
last_used: datetime | None = None
|
|
59
|
-
last_verified: datetime | None = None
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
@dataclass
|
|
63
|
-
class Session:
|
|
64
|
-
key: bytes
|
|
65
|
-
user_uuid: UUID
|
|
66
|
-
credential_uuid: UUID
|
|
67
|
-
host: str
|
|
68
|
-
ip: str
|
|
69
|
-
user_agent: str
|
|
70
|
-
renewed: datetime
|
|
71
|
-
|
|
72
|
-
def metadata(self) -> dict:
|
|
73
|
-
"""Return session metadata for backwards compatibility."""
|
|
74
|
-
return {
|
|
75
|
-
"ip": self.ip,
|
|
76
|
-
"user_agent": self.user_agent,
|
|
77
|
-
"renewed": self.renewed.isoformat(),
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@dataclass
|
|
82
|
-
class ResetToken:
|
|
83
|
-
key: bytes
|
|
84
|
-
user_uuid: UUID
|
|
85
|
-
expiry: datetime
|
|
86
|
-
token_type: str
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@dataclass
|
|
90
|
-
class SessionContext:
|
|
91
|
-
session: Session
|
|
92
|
-
user: User
|
|
93
|
-
org: Org
|
|
94
|
-
role: Role
|
|
95
|
-
credential: Credential | None = None
|
|
96
|
-
permissions: list[Permission] | None = None
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
class DatabaseInterface(ABC):
|
|
100
|
-
"""Abstract base class defining the database interface.
|
|
101
|
-
|
|
102
|
-
This class defines the public API that database implementations should provide.
|
|
103
|
-
Implementations may use decorators like @with_session that modify method signatures
|
|
104
|
-
at runtime, so this interface focuses on the logical operations rather than
|
|
105
|
-
exact parameter matching.
|
|
106
|
-
"""
|
|
107
|
-
|
|
108
|
-
@abstractmethod
|
|
109
|
-
async def init_db(self) -> None:
|
|
110
|
-
"""Initialize database tables."""
|
|
111
|
-
pass
|
|
112
|
-
|
|
113
|
-
# User operations
|
|
114
|
-
@abstractmethod
|
|
115
|
-
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
|
116
|
-
"""Get user record by WebAuthn user UUID."""
|
|
117
|
-
|
|
118
|
-
@abstractmethod
|
|
119
|
-
async def create_user(self, user: User) -> None:
|
|
120
|
-
"""Create a new user."""
|
|
121
|
-
|
|
122
|
-
@abstractmethod
|
|
123
|
-
async def update_user_display_name(
|
|
124
|
-
self, user_uuid: UUID, display_name: str
|
|
125
|
-
) -> None:
|
|
126
|
-
"""Update a user's display name."""
|
|
127
|
-
|
|
128
|
-
# Role operations
|
|
129
|
-
@abstractmethod
|
|
130
|
-
async def create_role(self, role: Role) -> None:
|
|
131
|
-
"""Create new role."""
|
|
132
|
-
|
|
133
|
-
@abstractmethod
|
|
134
|
-
async def update_role(self, role: Role) -> None:
|
|
135
|
-
"""Update a role's display name and synchronize its permissions."""
|
|
136
|
-
|
|
137
|
-
@abstractmethod
|
|
138
|
-
async def delete_role(self, role_uuid: UUID) -> None:
|
|
139
|
-
"""Delete a role by UUID. Implementations may prevent deletion if users exist."""
|
|
140
|
-
|
|
141
|
-
# Credential operations
|
|
142
|
-
@abstractmethod
|
|
143
|
-
async def create_credential(self, credential: Credential) -> None:
|
|
144
|
-
"""Store a credential for a user."""
|
|
145
|
-
|
|
146
|
-
@abstractmethod
|
|
147
|
-
async def get_credential_by_id(self, credential_id: bytes) -> Credential:
|
|
148
|
-
"""Get credential by credential ID."""
|
|
149
|
-
|
|
150
|
-
@abstractmethod
|
|
151
|
-
async def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]:
|
|
152
|
-
"""Get all credential IDs for a user."""
|
|
153
|
-
|
|
154
|
-
@abstractmethod
|
|
155
|
-
async def update_credential(self, credential: Credential) -> None:
|
|
156
|
-
"""Update the sign count, created_at, last_used, and last_verified for a credential."""
|
|
157
|
-
|
|
158
|
-
@abstractmethod
|
|
159
|
-
async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None:
|
|
160
|
-
"""Delete a specific credential for a user."""
|
|
161
|
-
|
|
162
|
-
# Session operations
|
|
163
|
-
@abstractmethod
|
|
164
|
-
async def create_session(
|
|
165
|
-
self,
|
|
166
|
-
user_uuid: UUID,
|
|
167
|
-
key: bytes,
|
|
168
|
-
credential_uuid: UUID,
|
|
169
|
-
host: str,
|
|
170
|
-
ip: str,
|
|
171
|
-
user_agent: str,
|
|
172
|
-
renewed: datetime,
|
|
173
|
-
) -> None:
|
|
174
|
-
"""Create a new session."""
|
|
175
|
-
|
|
176
|
-
@abstractmethod
|
|
177
|
-
async def get_session(self, key: bytes) -> Session | None:
|
|
178
|
-
"""Get session by key."""
|
|
179
|
-
|
|
180
|
-
@abstractmethod
|
|
181
|
-
async def delete_session(self, key: bytes) -> None:
|
|
182
|
-
"""Delete session by key."""
|
|
183
|
-
|
|
184
|
-
@abstractmethod
|
|
185
|
-
async def update_session(
|
|
186
|
-
self,
|
|
187
|
-
key: bytes,
|
|
188
|
-
*,
|
|
189
|
-
ip: str,
|
|
190
|
-
user_agent: str,
|
|
191
|
-
renewed: datetime,
|
|
192
|
-
) -> Session | None:
|
|
193
|
-
"""Update session metadata and touch renewed timestamp."""
|
|
194
|
-
|
|
195
|
-
@abstractmethod
|
|
196
|
-
async def set_session_host(self, key: bytes, host: str) -> None:
|
|
197
|
-
"""Bind a session to a specific host if not already set."""
|
|
198
|
-
|
|
199
|
-
@abstractmethod
|
|
200
|
-
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
|
201
|
-
"""Return all sessions for a user (including other hosts)."""
|
|
202
|
-
|
|
203
|
-
@abstractmethod
|
|
204
|
-
async def cleanup(self) -> None:
|
|
205
|
-
"""Called periodically to clean up expired records."""
|
|
206
|
-
|
|
207
|
-
@abstractmethod
|
|
208
|
-
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
|
209
|
-
"""Delete all sessions belonging to the provided user."""
|
|
210
|
-
|
|
211
|
-
# Reset token operations
|
|
212
|
-
@abstractmethod
|
|
213
|
-
async def create_reset_token(
|
|
214
|
-
self,
|
|
215
|
-
user_uuid: UUID,
|
|
216
|
-
key: bytes,
|
|
217
|
-
expiry: datetime,
|
|
218
|
-
token_type: str,
|
|
219
|
-
) -> None:
|
|
220
|
-
"""Create a reset token for a user."""
|
|
4
|
+
Read: Access _db._data directly, use build_* to convert to public structs.
|
|
5
|
+
CTX: get_session_context(key) returns SessionContext with effective permissions.
|
|
6
|
+
Write: Functions validate and commit, or raise ValueError.
|
|
221
7
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
"""Retrieve a reset token by key."""
|
|
8
|
+
Usage:
|
|
9
|
+
from paskia import db
|
|
225
10
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
11
|
+
# Read (after init)
|
|
12
|
+
user_data = db._db._data.users[user_uuid]
|
|
13
|
+
user = db.build_user(user_uuid)
|
|
229
14
|
|
|
230
|
-
#
|
|
231
|
-
|
|
232
|
-
async def create_organization(self, org: Org) -> None:
|
|
233
|
-
"""Add a new organization."""
|
|
15
|
+
# Context
|
|
16
|
+
ctx = db.get_session_context(session_key)
|
|
234
17
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
@abstractmethod
|
|
240
|
-
async def list_organizations(self) -> list[Org]:
|
|
241
|
-
"""List all organizations with their roles and permission IDs."""
|
|
242
|
-
|
|
243
|
-
@abstractmethod
|
|
244
|
-
async def update_organization(self, org: Org) -> None:
|
|
245
|
-
"""Update organization options."""
|
|
246
|
-
|
|
247
|
-
@abstractmethod
|
|
248
|
-
async def delete_organization(self, org_uuid: UUID) -> None:
|
|
249
|
-
"""Delete organization by ID."""
|
|
250
|
-
|
|
251
|
-
@abstractmethod
|
|
252
|
-
async def add_user_to_organization(
|
|
253
|
-
self, user_uuid: UUID, org_id: str, role: str
|
|
254
|
-
) -> None:
|
|
255
|
-
"""Set a user's organization and role."""
|
|
256
|
-
|
|
257
|
-
@abstractmethod
|
|
258
|
-
async def transfer_user_to_organization(
|
|
259
|
-
self, user_uuid: UUID, new_org_id: str, new_role: str | None = None
|
|
260
|
-
) -> None:
|
|
261
|
-
"""Transfer a user to another organization with an optional role."""
|
|
262
|
-
|
|
263
|
-
@abstractmethod
|
|
264
|
-
async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]:
|
|
265
|
-
"""Get the organization and role for a user."""
|
|
266
|
-
|
|
267
|
-
@abstractmethod
|
|
268
|
-
async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]:
|
|
269
|
-
"""Get all users in an organization with their roles."""
|
|
270
|
-
|
|
271
|
-
@abstractmethod
|
|
272
|
-
async def get_roles_by_organization(self, org_id: str) -> list[Role]:
|
|
273
|
-
"""List roles belonging to an organization."""
|
|
274
|
-
|
|
275
|
-
@abstractmethod
|
|
276
|
-
async def get_user_role_in_organization(
|
|
277
|
-
self, user_uuid: UUID, org_id: str
|
|
278
|
-
) -> str | None:
|
|
279
|
-
"""Get a user's role in a specific organization."""
|
|
280
|
-
|
|
281
|
-
@abstractmethod
|
|
282
|
-
async def update_user_role_in_organization(
|
|
283
|
-
self, user_uuid: UUID, new_role: str
|
|
284
|
-
) -> None:
|
|
285
|
-
"""Update a user's role in their organization."""
|
|
286
|
-
|
|
287
|
-
# Permission operations
|
|
288
|
-
@abstractmethod
|
|
289
|
-
async def create_permission(self, permission: Permission) -> None:
|
|
290
|
-
"""Create a new permission."""
|
|
291
|
-
|
|
292
|
-
@abstractmethod
|
|
293
|
-
async def get_permission(self, permission_id: str) -> Permission:
|
|
294
|
-
"""Get permission by ID."""
|
|
295
|
-
|
|
296
|
-
@abstractmethod
|
|
297
|
-
async def list_permissions(self) -> list[Permission]:
|
|
298
|
-
"""List all permissions."""
|
|
299
|
-
|
|
300
|
-
@abstractmethod
|
|
301
|
-
async def update_permission(self, permission: Permission) -> None:
|
|
302
|
-
"""Update permission details."""
|
|
303
|
-
|
|
304
|
-
@abstractmethod
|
|
305
|
-
async def delete_permission(self, permission_id: str) -> None:
|
|
306
|
-
"""Delete permission by ID."""
|
|
307
|
-
|
|
308
|
-
@abstractmethod
|
|
309
|
-
async def rename_permission(
|
|
310
|
-
self, old_id: str, new_id: str, display_name: str
|
|
311
|
-
) -> None:
|
|
312
|
-
"""Rename a permission's ID (and display name) updating all references.
|
|
313
|
-
|
|
314
|
-
This must update:
|
|
315
|
-
- permissions.id (primary key)
|
|
316
|
-
- org_permissions.permission_id
|
|
317
|
-
- role_permissions.permission_id
|
|
318
|
-
"""
|
|
319
|
-
|
|
320
|
-
@abstractmethod
|
|
321
|
-
async def add_permission_to_organization(
|
|
322
|
-
self, org_id: str, permission_id: str
|
|
323
|
-
) -> None:
|
|
324
|
-
"""Add a permission to an organization."""
|
|
325
|
-
|
|
326
|
-
@abstractmethod
|
|
327
|
-
async def remove_permission_from_organization(
|
|
328
|
-
self, org_id: str, permission_id: str
|
|
329
|
-
) -> None:
|
|
330
|
-
"""Remove a permission from an organization."""
|
|
331
|
-
|
|
332
|
-
@abstractmethod
|
|
333
|
-
async def get_organization_permissions(self, org_id: str) -> list[Permission]:
|
|
334
|
-
"""Get all permissions assigned to an organization."""
|
|
335
|
-
|
|
336
|
-
@abstractmethod
|
|
337
|
-
async def get_permission_organizations(self, permission_id: str) -> list[Org]:
|
|
338
|
-
"""Get all organizations that have a specific permission."""
|
|
339
|
-
|
|
340
|
-
# Role-permission operations
|
|
341
|
-
@abstractmethod
|
|
342
|
-
async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None:
|
|
343
|
-
"""Add a permission to a role."""
|
|
344
|
-
|
|
345
|
-
@abstractmethod
|
|
346
|
-
async def remove_permission_from_role(
|
|
347
|
-
self, role_uuid: UUID, permission_id: str
|
|
348
|
-
) -> None:
|
|
349
|
-
"""Remove a permission from a role."""
|
|
350
|
-
|
|
351
|
-
@abstractmethod
|
|
352
|
-
async def get_role_permissions(self, role_uuid: UUID) -> list[Permission]:
|
|
353
|
-
"""List all permissions granted to a role."""
|
|
354
|
-
|
|
355
|
-
@abstractmethod
|
|
356
|
-
async def get_permission_roles(self, permission_id: str) -> list[Role]:
|
|
357
|
-
"""List all roles that grant a permission."""
|
|
358
|
-
|
|
359
|
-
@abstractmethod
|
|
360
|
-
async def get_role(self, role_uuid: UUID) -> Role:
|
|
361
|
-
"""Get a role by UUID, including its permission IDs."""
|
|
362
|
-
|
|
363
|
-
# Combined operations
|
|
364
|
-
@abstractmethod
|
|
365
|
-
async def login(self, user_uuid: UUID, credential: Credential) -> None:
|
|
366
|
-
"""Update user and credential timestamps after successful login."""
|
|
367
|
-
|
|
368
|
-
@abstractmethod
|
|
369
|
-
async def create_user_and_credential(
|
|
370
|
-
self, user: User, credential: Credential
|
|
371
|
-
) -> None:
|
|
372
|
-
"""Create a new user and their first credential in a transaction."""
|
|
373
|
-
|
|
374
|
-
@abstractmethod
|
|
375
|
-
async def get_session_context(
|
|
376
|
-
self, session_key: bytes, host: str | None = None
|
|
377
|
-
) -> SessionContext | None:
|
|
378
|
-
"""Get complete session context including user, organization, role, and permissions."""
|
|
379
|
-
|
|
380
|
-
# Combined atomic operations
|
|
381
|
-
@abstractmethod
|
|
382
|
-
async def create_credential_session(
|
|
383
|
-
self,
|
|
384
|
-
user_uuid: UUID,
|
|
385
|
-
credential: Credential,
|
|
386
|
-
reset_key: bytes | None,
|
|
387
|
-
session_key: bytes,
|
|
388
|
-
*,
|
|
389
|
-
display_name: str | None = None,
|
|
390
|
-
host: str | None = None,
|
|
391
|
-
ip: str | None = None,
|
|
392
|
-
user_agent: str | None = None,
|
|
393
|
-
) -> None:
|
|
394
|
-
"""Atomically add a credential and create a session.
|
|
395
|
-
|
|
396
|
-
Steps (single transaction):
|
|
397
|
-
1. Insert credential
|
|
398
|
-
2. Optionally delete old reset token if provided
|
|
399
|
-
3. Optionally update user's display name
|
|
400
|
-
4. Insert new session referencing the credential
|
|
401
|
-
5. Update user's last_seen and increment visits (treat as a login)
|
|
402
|
-
"""
|
|
18
|
+
# Write
|
|
19
|
+
db.create_user(user)
|
|
20
|
+
"""
|
|
403
21
|
|
|
22
|
+
from paskia.db.background import (
|
|
23
|
+
start_background,
|
|
24
|
+
start_cleanup,
|
|
25
|
+
stop_background,
|
|
26
|
+
stop_cleanup,
|
|
27
|
+
)
|
|
28
|
+
from paskia.db.operations import (
|
|
29
|
+
DB,
|
|
30
|
+
_db,
|
|
31
|
+
add_permission_to_organization,
|
|
32
|
+
add_permission_to_role,
|
|
33
|
+
build_credential,
|
|
34
|
+
build_org,
|
|
35
|
+
build_permission,
|
|
36
|
+
build_reset_token,
|
|
37
|
+
build_role,
|
|
38
|
+
build_session,
|
|
39
|
+
build_user,
|
|
40
|
+
cleanup_expired,
|
|
41
|
+
create_credential,
|
|
42
|
+
create_credential_session,
|
|
43
|
+
create_organization,
|
|
44
|
+
create_permission,
|
|
45
|
+
create_reset_token,
|
|
46
|
+
create_role,
|
|
47
|
+
create_session,
|
|
48
|
+
create_user,
|
|
49
|
+
delete_credential,
|
|
50
|
+
delete_organization,
|
|
51
|
+
delete_permission,
|
|
52
|
+
delete_reset_token,
|
|
53
|
+
delete_role,
|
|
54
|
+
delete_session,
|
|
55
|
+
delete_sessions_for_user,
|
|
56
|
+
delete_user,
|
|
57
|
+
get_credential_by_id,
|
|
58
|
+
get_credentials_by_user_uuid,
|
|
59
|
+
get_organization,
|
|
60
|
+
get_organization_users,
|
|
61
|
+
get_permission,
|
|
62
|
+
get_permission_by_scope,
|
|
63
|
+
get_permission_organizations,
|
|
64
|
+
get_reset_token,
|
|
65
|
+
get_role,
|
|
66
|
+
get_roles_by_organization,
|
|
67
|
+
get_session,
|
|
68
|
+
get_session_context,
|
|
69
|
+
get_user_by_uuid,
|
|
70
|
+
get_user_organization,
|
|
71
|
+
init,
|
|
72
|
+
list_organizations,
|
|
73
|
+
list_permissions,
|
|
74
|
+
list_sessions_for_user,
|
|
75
|
+
login,
|
|
76
|
+
remove_permission_from_organization,
|
|
77
|
+
remove_permission_from_role,
|
|
78
|
+
rename_permission,
|
|
79
|
+
set_session_host,
|
|
80
|
+
update_credential_sign_count,
|
|
81
|
+
update_organization_name,
|
|
82
|
+
update_permission,
|
|
83
|
+
update_role_name,
|
|
84
|
+
update_session,
|
|
85
|
+
update_user_display_name,
|
|
86
|
+
update_user_role,
|
|
87
|
+
update_user_role_in_organization,
|
|
88
|
+
)
|
|
89
|
+
from paskia.db.structs import (
|
|
90
|
+
Credential,
|
|
91
|
+
Org,
|
|
92
|
+
Permission,
|
|
93
|
+
ResetToken,
|
|
94
|
+
Role,
|
|
95
|
+
Session,
|
|
96
|
+
SessionContext,
|
|
97
|
+
User,
|
|
98
|
+
)
|
|
404
99
|
|
|
405
100
|
__all__ = [
|
|
406
|
-
|
|
101
|
+
# Types
|
|
407
102
|
"Credential",
|
|
408
|
-
"
|
|
409
|
-
"ResetToken",
|
|
410
|
-
"SessionContext",
|
|
103
|
+
"DB",
|
|
411
104
|
"Org",
|
|
412
|
-
"Role",
|
|
413
105
|
"Permission",
|
|
414
|
-
"
|
|
106
|
+
"ResetToken",
|
|
107
|
+
"Role",
|
|
108
|
+
"Session",
|
|
109
|
+
"SessionContext",
|
|
110
|
+
"User",
|
|
111
|
+
# Instance
|
|
112
|
+
"_db",
|
|
113
|
+
"init",
|
|
114
|
+
# Background
|
|
115
|
+
"start_background",
|
|
116
|
+
"stop_background",
|
|
117
|
+
"start_cleanup",
|
|
118
|
+
"stop_cleanup",
|
|
119
|
+
# Builders
|
|
120
|
+
"build_credential",
|
|
121
|
+
"build_org",
|
|
122
|
+
"build_permission",
|
|
123
|
+
"build_reset_token",
|
|
124
|
+
"build_role",
|
|
125
|
+
"build_session",
|
|
126
|
+
"build_user",
|
|
127
|
+
# Read ops
|
|
128
|
+
"get_credential_by_id",
|
|
129
|
+
"get_credentials_by_user_uuid",
|
|
130
|
+
"get_organization",
|
|
131
|
+
"get_organization_users",
|
|
132
|
+
"get_permission",
|
|
133
|
+
"get_permission_by_scope",
|
|
134
|
+
"get_permission_organizations",
|
|
135
|
+
"get_reset_token",
|
|
136
|
+
"get_role",
|
|
137
|
+
"get_roles_by_organization",
|
|
138
|
+
"get_session",
|
|
139
|
+
"get_session_context",
|
|
140
|
+
"get_user_by_uuid",
|
|
141
|
+
"get_user_organization",
|
|
142
|
+
"list_organizations",
|
|
143
|
+
"list_permissions",
|
|
144
|
+
"list_sessions_for_user",
|
|
145
|
+
# Write ops
|
|
146
|
+
"add_permission_to_organization",
|
|
147
|
+
"add_permission_to_role",
|
|
148
|
+
"cleanup_expired",
|
|
149
|
+
"create_credential",
|
|
150
|
+
"create_credential_session",
|
|
151
|
+
"create_organization",
|
|
152
|
+
"create_permission",
|
|
153
|
+
"create_reset_token",
|
|
154
|
+
"create_role",
|
|
155
|
+
"create_session",
|
|
156
|
+
"create_user",
|
|
157
|
+
"delete_credential",
|
|
158
|
+
"delete_organization",
|
|
159
|
+
"delete_permission",
|
|
160
|
+
"delete_reset_token",
|
|
161
|
+
"delete_role",
|
|
162
|
+
"delete_session",
|
|
163
|
+
"delete_sessions_for_user",
|
|
164
|
+
"delete_user",
|
|
165
|
+
"login",
|
|
166
|
+
"remove_permission_from_organization",
|
|
167
|
+
"remove_permission_from_role",
|
|
168
|
+
"rename_permission",
|
|
169
|
+
"update_credential_sign_count",
|
|
170
|
+
"update_organization_name",
|
|
171
|
+
"update_permission",
|
|
172
|
+
"update_role_name",
|
|
173
|
+
"update_session",
|
|
174
|
+
"update_user_display_name",
|
|
175
|
+
"update_user_role",
|
|
176
|
+
"update_user_role_in_organization",
|
|
415
177
|
]
|
paskia/db/background.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background task for database maintenance.
|
|
3
|
+
|
|
4
|
+
Periodically flushes pending changes to disk and cleans up expired items.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from paskia.db.jsonl import flush_changes
|
|
12
|
+
|
|
13
|
+
# Flush changes to disk every N seconds
|
|
14
|
+
FLUSH_INTERVAL = 1
|
|
15
|
+
# Cleanup expired items every N seconds (cheap when nothing to remove)
|
|
16
|
+
CLEANUP_INTERVAL = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
_background_task: asyncio.Task | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cleanup() -> None:
|
|
24
|
+
"""Remove expired sessions and reset tokens from the database."""
|
|
25
|
+
from paskia.db.operations import _db
|
|
26
|
+
|
|
27
|
+
if _db is None or _db._data is None:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
with _db.transaction("expiry"):
|
|
31
|
+
current_time = datetime.now(timezone.utc)
|
|
32
|
+
|
|
33
|
+
# Clean expired sessions
|
|
34
|
+
to_delete_sessions = [
|
|
35
|
+
k for k, s in _db._data.sessions.items() if s.expiry < current_time
|
|
36
|
+
]
|
|
37
|
+
for k in to_delete_sessions:
|
|
38
|
+
del _db._data.sessions[k]
|
|
39
|
+
|
|
40
|
+
# Clean expired reset tokens
|
|
41
|
+
to_delete_tokens = [
|
|
42
|
+
k for k, t in _db._data.reset_tokens.items() if t.expiry < current_time
|
|
43
|
+
]
|
|
44
|
+
for k in to_delete_tokens:
|
|
45
|
+
del _db._data.reset_tokens[k]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def flush() -> None:
|
|
49
|
+
"""Write all pending database changes to disk."""
|
|
50
|
+
from paskia.db.operations import _db
|
|
51
|
+
|
|
52
|
+
if _db is None:
|
|
53
|
+
_logger.warning("flush() called but _db is None")
|
|
54
|
+
return
|
|
55
|
+
await flush_changes(_db.db_path, _db._pending_changes)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _background_loop():
|
|
59
|
+
"""Background task that periodically flushes changes and cleans up."""
|
|
60
|
+
# Run cleanup immediately on startup to clear old expired items
|
|
61
|
+
cleanup()
|
|
62
|
+
await flush()
|
|
63
|
+
|
|
64
|
+
last_cleanup = datetime.now(timezone.utc)
|
|
65
|
+
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
await asyncio.sleep(FLUSH_INTERVAL)
|
|
69
|
+
# Flush pending changes to disk
|
|
70
|
+
await flush()
|
|
71
|
+
|
|
72
|
+
# Run cleanup less frequently
|
|
73
|
+
now = datetime.now(timezone.utc)
|
|
74
|
+
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
|
75
|
+
cleanup()
|
|
76
|
+
await flush() # Flush cleanup changes
|
|
77
|
+
last_cleanup = now
|
|
78
|
+
except asyncio.CancelledError:
|
|
79
|
+
# Final flush before exit
|
|
80
|
+
await flush()
|
|
81
|
+
break
|
|
82
|
+
except Exception:
|
|
83
|
+
_logger.debug("Error in database background loop", exc_info=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def start_background():
|
|
87
|
+
"""Start the background flush/cleanup task."""
|
|
88
|
+
global _background_task
|
|
89
|
+
|
|
90
|
+
# Check if task exists but is no longer running (e.g., after uvicorn reload)
|
|
91
|
+
if _background_task is not None:
|
|
92
|
+
if _background_task.done():
|
|
93
|
+
_logger.debug("Previous background task was done, restarting")
|
|
94
|
+
_background_task = None
|
|
95
|
+
else:
|
|
96
|
+
# Task exists and is running - but might be in a dead event loop
|
|
97
|
+
try:
|
|
98
|
+
# Check if task is in current event loop
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
100
|
+
task_loop = _background_task.get_loop()
|
|
101
|
+
if loop is not task_loop:
|
|
102
|
+
_logger.debug("Background task in different event loop, restarting")
|
|
103
|
+
_background_task = None
|
|
104
|
+
except Exception as e:
|
|
105
|
+
_logger.debug("Error checking background task loop: %s, restarting", e)
|
|
106
|
+
_background_task = None
|
|
107
|
+
|
|
108
|
+
if _background_task is None:
|
|
109
|
+
_background_task = asyncio.create_task(_background_loop())
|
|
110
|
+
else:
|
|
111
|
+
_logger.debug("Background task already running: %s", _background_task)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def stop_background():
|
|
115
|
+
"""Stop the background task and flush any pending changes."""
|
|
116
|
+
global _background_task
|
|
117
|
+
if _background_task:
|
|
118
|
+
_background_task.cancel()
|
|
119
|
+
try:
|
|
120
|
+
await _background_task
|
|
121
|
+
except asyncio.CancelledError:
|
|
122
|
+
pass
|
|
123
|
+
_background_task = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Aliases for backwards compatibility
|
|
127
|
+
start_cleanup = start_background
|
|
128
|
+
stop_cleanup = stop_background
|