paskia 0.8.0__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 (67) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +14 -27
  3. paskia/bootstrap.py +31 -103
  4. paskia/config.py +0 -1
  5. paskia/db/__init__.py +26 -51
  6. paskia/db/background.py +17 -37
  7. paskia/db/jsonl.py +168 -6
  8. paskia/db/migrations.py +34 -0
  9. paskia/db/operations.py +400 -723
  10. paskia/db/structs.py +214 -90
  11. paskia/fastapi/__main__.py +89 -189
  12. paskia/fastapi/admin.py +103 -162
  13. paskia/fastapi/api.py +49 -85
  14. paskia/fastapi/mainapp.py +30 -19
  15. paskia/fastapi/remote.py +16 -39
  16. paskia/fastapi/reset.py +27 -17
  17. paskia/fastapi/session.py +2 -2
  18. paskia/fastapi/user.py +21 -27
  19. paskia/fastapi/ws.py +27 -62
  20. paskia/fastapi/wschat.py +62 -0
  21. paskia/frontend-build/auth/admin/index.html +5 -5
  22. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  23. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  24. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  25. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  26. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  27. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  28. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  29. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  30. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  31. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  32. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  33. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  34. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  35. paskia/frontend-build/auth/index.html +5 -5
  36. paskia/frontend-build/auth/restricted/index.html +4 -4
  37. paskia/frontend-build/int/forward/index.html +4 -4
  38. paskia/frontend-build/int/reset/index.html +3 -3
  39. paskia/globals.py +2 -2
  40. paskia/migrate/__init__.py +62 -55
  41. paskia/migrate/sql.py +72 -22
  42. paskia/remoteauth.py +1 -2
  43. paskia/sansio.py +6 -12
  44. {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/METADATA +3 -2
  45. paskia-0.9.0.dist-info/RECORD +57 -0
  46. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  47. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  48. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  49. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  50. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  51. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  52. paskia/util/frontend.py +0 -75
  53. paskia/util/hostutil.py +0 -76
  54. paskia/util/htmlutil.py +0 -47
  55. paskia/util/passphrase.py +0 -20
  56. paskia/util/permutil.py +0 -43
  57. paskia/util/pow.py +0 -45
  58. paskia/util/querysafe.py +0 -11
  59. paskia/util/sessionutil.py +0 -38
  60. paskia/util/startupbox.py +0 -75
  61. paskia/util/timeutil.py +0 -47
  62. paskia/util/useragent.py +0 -10
  63. paskia/util/userinfo.py +0 -145
  64. paskia/util/wordlist.py +0 -54
  65. paskia-0.8.0.dist-info/RECORD +0 -68
  66. {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/WHEEL +0 -0
  67. {paskia-0.8.0.dist-info → paskia-0.9.0.dist-info}/entry_points.txt +0 -0
paskia/db/operations.py CHANGED
@@ -1,34 +1,27 @@
1
1
  """
2
2
  Database for WebAuthn passkey authentication.
3
3
 
4
- Read operations: Access _db._data directly, use build_* helpers to get public structs.
4
+ Read operations: Access _db directly, use build_* helpers to get public structs.
5
5
  Context lookup: get_session_context() returns full SessionContext with effective permissions.
6
6
  Write operations: Functions that validate and commit, or raise ValueError.
7
7
  """
8
8
 
9
9
  import hashlib
10
- import json
11
10
  import logging
12
11
  import os
13
12
  import secrets
14
- import sys
15
- from collections import deque
16
- from contextlib import contextmanager
17
13
  from datetime import datetime, timezone
18
- from pathlib import Path
19
- from typing import Any
20
14
  from uuid import UUID
21
15
 
22
- import msgspec
16
+ import uuid7
23
17
 
18
+ from paskia.config import SESSION_LIFETIME
24
19
  from paskia.db.jsonl import (
25
20
  DB_PATH_DEFAULT,
26
- _ChangeRecord,
27
- compute_diff,
28
- create_change_record,
29
- load_jsonl,
21
+ JsonlStore,
30
22
  )
31
23
  from paskia.db.structs import (
24
+ DB,
32
25
  Credential,
33
26
  Org,
34
27
  Permission,
@@ -37,220 +30,32 @@ from paskia.db.structs import (
37
30
  Session,
38
31
  SessionContext,
39
32
  User,
40
- _CredentialData,
41
- _DatabaseData,
42
- _OrgData,
43
- _PermissionData,
44
- _ResetTokenData,
45
- _RoleData,
46
- _SessionData,
47
- _UserData,
48
33
  )
34
+ from paskia.util.hostutil import normalize_host
35
+ from paskia.util.passphrase import generate as generate_passphrase
49
36
  from paskia.util.passphrase import is_well_formed as _is_passphrase
50
37
 
51
38
  _logger = logging.getLogger(__name__)
52
39
 
53
- # msgspec encoder/decoder
54
- _json_encoder = msgspec.json.Encoder()
55
- _json_decoder = msgspec.json.Decoder(_DatabaseData)
56
-
57
-
58
- class DB:
59
- """In-memory database with JSONL persistence.
60
-
61
- Access data directly via _data for reads.
62
- Use transaction() context manager for writes.
63
- """
64
-
65
- def __init__(self, db_path: str = DB_PATH_DEFAULT):
66
- self.db_path = Path(db_path)
67
- self._data = _DatabaseData(
68
- permissions={},
69
- orgs={},
70
- roles={},
71
- users={},
72
- credentials={},
73
- sessions={},
74
- reset_tokens={},
75
- )
76
- self._previous_builtins: dict[str, Any] = {}
77
- self._pending_changes: deque[_ChangeRecord] = deque()
78
- self._current_action: str = "system"
79
- self._current_user: str | None = None
80
-
81
- async def load(self, db_path: str | None = None) -> None:
82
- """Load data from JSONL change log.
83
-
84
- If file doesn't exist or is empty, keeps the initialized empty structure and
85
- sets _previous_builtins to {} for creating a new database.
86
- """
87
- if db_path is not None:
88
- self.db_path = Path(db_path)
89
- try:
90
- data_dict = await load_jsonl(self.db_path)
91
- if data_dict: # Only decode if we have data
92
- self._data = _json_decoder.decode(_json_encoder.encode(data_dict))
93
- # Track the JSONL file state directly - this is what we diff against
94
- self._previous_builtins = data_dict
95
- # If data_dict is empty, keep initialized _data and _previous_builtins = {}
96
- except ValueError:
97
- if self.db_path.exists():
98
- raise # File exists but failed to load - re-raise
99
- # File doesn't exist: keep initialized _data, _previous_builtins stays {}
100
-
101
- def _queue_change(self) -> None:
102
- current = msgspec.to_builtins(self._data)
103
- diff = compute_diff(self._previous_builtins, current)
104
- if diff:
105
- self._pending_changes.append(
106
- create_change_record(self._current_action, diff, self._current_user)
107
- )
108
- self._previous_builtins = current
109
- # Log the change with user display name if available
110
- user_display = None
111
- if self._current_user:
112
- try:
113
- user_uuid = UUID(self._current_user)
114
- if user_uuid in self._data.users:
115
- user_display = self._data.users[user_uuid].display_name
116
- except (ValueError, KeyError):
117
- user_display = self._current_user
118
-
119
- diff_json = json.dumps(diff, default=str)
120
- if user_display:
121
- print(
122
- f"{self._current_action} by {user_display}: {diff_json}",
123
- file=sys.stderr,
124
- )
125
- else:
126
- print(f"{self._current_action}: {diff_json}", file=sys.stderr)
127
-
128
- @contextmanager
129
- def transaction(
130
- self,
131
- action: str,
132
- ctx: SessionContext | None = None,
133
- *,
134
- user: str | None = None,
135
- ):
136
- """Wrap writes in transaction. Queues change on successful exit.
137
-
138
- Args:
139
- action: Describes the operation (e.g., "Created user", "Login")
140
- ctx: Session context of user performing the action (None for system operations)
141
- user: User UUID string (alternative to ctx when full context unavailable)
142
- """
143
- old_action = self._current_action
144
- old_user = self._current_user
145
- self._current_action = action
146
- # Prefer ctx.user.uuid if ctx provided, otherwise use user param
147
- self._current_user = str(ctx.user.uuid) if ctx else user
148
- try:
149
- yield
150
- self._queue_change()
151
- finally:
152
- self._current_action = old_action
153
- self._current_user = old_user
154
-
155
-
156
- # Global instance, always available (empty until init() loads data)
40
+ # Global database instance (empty until init() loads data)
157
41
  _db = DB()
42
+ _store = JsonlStore(_db)
43
+ _db._store = _store
44
+ _initialized = False
158
45
 
159
46
 
160
47
  async def init(*args, **kwargs):
161
- """Load database and start background flush task."""
162
- from paskia.db.background import start_background
163
-
48
+ """Load database from JSONL file."""
49
+ global _db, _initialized
50
+ if _initialized:
51
+ _logger.debug("Database already initialized, skipping reload")
52
+ return
164
53
  db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
165
54
  if db_path.startswith("json:"):
166
55
  db_path = db_path[5:]
167
- await _db.load(db_path)
168
- await start_background()
169
-
170
-
171
- # -------------------------------------------------------------------------
172
- # Builders: Convert internal _*Data to public structs
173
- # -------------------------------------------------------------------------
174
-
175
-
176
- def build_permission(uuid: UUID) -> Permission:
177
- p = _db._data.permissions[uuid]
178
- return Permission(
179
- uuid=uuid, scope=p.scope, display_name=p.display_name, domain=p.domain
180
- )
181
-
182
-
183
- def build_user(uuid: UUID) -> User:
184
- u = _db._data.users[uuid]
185
- return User(
186
- uuid=uuid,
187
- display_name=u.display_name,
188
- role_uuid=u.role,
189
- created_at=u.created_at,
190
- last_seen=u.last_seen,
191
- visits=u.visits,
192
- )
193
-
194
-
195
- def build_role(uuid: UUID) -> Role:
196
- r = _db._data.roles[uuid]
197
- return Role(
198
- uuid=uuid,
199
- org_uuid=r.org,
200
- display_name=r.display_name,
201
- permissions=[str(pid) for pid in r.permissions.keys()],
202
- )
203
-
204
-
205
- def build_org(uuid: UUID, include_roles: bool = False) -> Org:
206
- o = _db._data.orgs[uuid]
207
- perm_uuids = [
208
- str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs
209
- ]
210
- org = Org(uuid=uuid, display_name=o.display_name, permissions=perm_uuids)
211
- if include_roles:
212
- org.roles = [
213
- build_role(rid) for rid, r in _db._data.roles.items() if r.org == uuid
214
- ]
215
- return org
216
-
217
-
218
- def build_credential(uuid: UUID) -> Credential:
219
- c = _db._data.credentials[uuid]
220
- return Credential(
221
- uuid=uuid,
222
- credential_id=c.credential_id,
223
- user_uuid=c.user,
224
- aaguid=c.aaguid,
225
- public_key=c.public_key,
226
- sign_count=c.sign_count,
227
- created_at=c.created_at,
228
- last_used=c.last_used,
229
- last_verified=c.last_verified,
230
- )
231
-
232
-
233
- def build_session(key: str) -> Session:
234
- s = _db._data.sessions[key]
235
- return Session(
236
- key=key,
237
- user_uuid=s.user,
238
- credential_uuid=s.credential,
239
- host=s.host,
240
- ip=s.ip,
241
- user_agent=s.user_agent,
242
- expiry=s.expiry,
243
- )
244
-
245
-
246
- def build_reset_token(key: bytes) -> ResetToken:
247
- t = _db._data.reset_tokens[key]
248
- return ResetToken(
249
- key=key,
250
- user_uuid=t.user,
251
- expiry=t.expiry,
252
- token_type=t.token_type,
253
- )
56
+ await _store.load(db_path)
57
+ _db = _store.db
58
+ _initialized = True
254
59
 
255
60
 
256
61
  # -------------------------------------------------------------------------
@@ -258,155 +63,46 @@ def build_reset_token(key: bytes) -> ResetToken:
258
63
  # -------------------------------------------------------------------------
259
64
 
260
65
 
261
- def get_permission(permission_id: str | UUID) -> Permission | None:
262
- """Get permission by UUID or scope.
263
-
264
- For backwards compatibility, this accepts either:
265
- - A UUID string (the primary key)
266
- - A scope string (searches for matching scope)
267
- """
268
- # First try as UUID key
269
- if isinstance(permission_id, UUID):
270
- if permission_id in _db._data.permissions:
271
- return build_permission(permission_id)
272
- else:
273
- try:
274
- uuid = UUID(permission_id)
275
- if uuid in _db._data.permissions:
276
- return build_permission(uuid)
277
- except ValueError:
278
- pass
279
- # Fall back to scope search
280
- for uuid, p in _db._data.permissions.items():
281
- if p.scope == str(permission_id):
282
- return build_permission(uuid)
283
- return None
284
-
285
-
286
- def get_permission_by_scope(scope: str) -> Permission | None:
287
- """Get permission by scope identifier."""
288
- for uuid, p in _db._data.permissions.items():
289
- if p.scope == scope:
290
- return build_permission(uuid)
291
- return None
292
-
293
-
294
- def list_permissions() -> list[Permission]:
295
- """List all permissions."""
296
- return [build_permission(uuid) for uuid in _db._data.permissions]
297
-
298
-
299
- def get_permission_organizations(scope: str) -> list[Org]:
300
- """Get organizations that can grant a permission scope."""
301
- for p in _db._data.permissions.values():
302
- if p.scope == scope:
303
- return [build_org(org_uuid) for org_uuid in p.orgs]
304
- return []
305
-
306
-
307
- def get_organization(uuid: str | UUID) -> Org | None:
308
- """Get organization by UUID."""
309
- if isinstance(uuid, str):
310
- uuid = UUID(uuid)
311
- return build_org(uuid, include_roles=True) if uuid in _db._data.orgs else None
312
-
313
-
314
- def list_organizations() -> list[Org]:
315
- """List all organizations."""
316
- return [build_org(uuid, include_roles=True) for uuid in _db._data.orgs]
317
-
318
-
319
- def get_organization_users(org_uuid: str | UUID) -> list[tuple[User, str]]:
320
- """Get all users in an organization with their role names."""
321
- if isinstance(org_uuid, str):
322
- org_uuid = UUID(org_uuid)
323
- role_map = {
324
- rid: r.display_name for rid, r in _db._data.roles.items() if r.org == org_uuid
325
- }
326
- return [
327
- (build_user(uid), role_map[u.role])
328
- for uid, u in _db._data.users.items()
329
- if u.role in role_map
330
- ]
331
-
332
-
333
- def get_role(uuid: str | UUID) -> Role | None:
334
- """Get role by UUID."""
335
- if isinstance(uuid, str):
336
- uuid = UUID(uuid)
337
- return build_role(uuid) if uuid in _db._data.roles else None
338
-
339
-
340
- def get_roles_by_organization(org_uuid: str | UUID) -> list[Role]:
341
- """Get all roles in an organization."""
342
- if isinstance(org_uuid, str):
343
- org_uuid = UUID(org_uuid)
344
- return [build_role(rid) for rid, r in _db._data.roles.items() if r.org == org_uuid]
345
-
346
-
347
- def get_user_by_uuid(uuid: str | UUID) -> User | None:
348
- """Get user by UUID."""
349
- if isinstance(uuid, str):
350
- uuid = UUID(uuid)
351
- return build_user(uuid) if uuid in _db._data.users else None
352
-
353
-
354
- def get_user_organization(user_uuid: str | UUID) -> tuple[Org, str]:
66
+ def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
355
67
  """Get the organization a user belongs to and their role name.
356
68
 
357
69
  Raises ValueError if user not found.
70
+
71
+ Call sites:
72
+ - Get user's organization when updating user role (admin.py:493)
73
+ - Get user's organization for user credential listing (admin.py:530)
74
+ - Get user's organization for user details API (admin.py:579)
75
+ - Get user's organization for updating user display name (admin.py:721)
76
+ - Get user's organization for deleting user credential (admin.py:754)
77
+ - Get user's organization for deleting user session (admin.py:783)
358
78
  """
359
- if isinstance(user_uuid, str):
360
- user_uuid = UUID(user_uuid)
361
- if user_uuid not in _db._data.users:
79
+ if user_uuid not in _db.users:
362
80
  raise ValueError(f"User {user_uuid} not found")
363
- role_uuid = _db._data.users[user_uuid].role
364
- if role_uuid not in _db._data.roles:
81
+ role_uuid = _db.users[user_uuid].role
82
+ if role_uuid not in _db.roles:
365
83
  raise ValueError(f"Role {role_uuid} not found")
366
- role_data = _db._data.roles[role_uuid]
84
+ role_data = _db.roles[role_uuid]
367
85
  org_uuid = role_data.org
368
- return build_org(org_uuid, include_roles=True), role_data.display_name
369
-
370
-
371
- def get_credential_by_id(credential_id: bytes) -> Credential | None:
372
- """Get credential by credential_id (the authenticator's ID)."""
373
- for uuid, c in _db._data.credentials.items():
374
- if c.credential_id == credential_id:
375
- return build_credential(uuid)
376
- return None
86
+ return _db.orgs[org_uuid], role_data.display_name
377
87
 
378
88
 
379
- def get_credentials_by_user_uuid(user_uuid: str | UUID) -> list[Credential]:
380
- """Get all credentials for a user."""
381
- if isinstance(user_uuid, str):
382
- user_uuid = UUID(user_uuid)
383
- return [
384
- build_credential(cid)
385
- for cid, c in _db._data.credentials.items()
386
- if c.user == user_uuid
387
- ]
89
+ def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
90
+ """Get all users in an organization with their role names.
388
91
 
92
+ Returns list of (User, role_display_name) tuples.
93
+ """
94
+ role_map = {
95
+ rid: r.display_name for rid, r in _db.roles.items() if r.org == org_uuid
96
+ }
97
+ return [(u, role_map[u.role]) for u in _db.users.values() if u.role in role_map]
389
98
 
390
- def get_session(key: str) -> Session | None:
391
- """Get session by key."""
392
- if key not in _db._data.sessions:
393
- return None
394
- s = _db._data.sessions[key]
395
- if s.expiry < datetime.now(timezone.utc):
396
- return None
397
- return build_session(key)
398
99
 
100
+ def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
101
+ """Get credential IDs for a user (for WebAuthn exclude lists).
399
102
 
400
- def list_sessions_for_user(user_uuid: str | UUID) -> list[Session]:
401
- """Get all active sessions for a user."""
402
- if isinstance(user_uuid, str):
403
- user_uuid = UUID(user_uuid)
404
- now = datetime.now(timezone.utc)
405
- return [
406
- build_session(key)
407
- for key, s in _db._data.sessions.items()
408
- if s.user == user_uuid and s.expiry >= now
409
- ]
103
+ Returns empty list if user has no credentials.
104
+ """
105
+ return [c.credential_id for c in _db.credentials.values() if c.user == user_uuid]
410
106
 
411
107
 
412
108
  def _reset_key(passphrase: str) -> bytes:
@@ -421,14 +117,13 @@ def _reset_key(passphrase: str) -> bytes:
421
117
 
422
118
 
423
119
  def get_reset_token(passphrase: str) -> ResetToken | None:
424
- """Get reset token by passphrase."""
120
+ """Get reset token by passphrase.
121
+
122
+ Call sites:
123
+ - Get reset token to validate it (authsession.py:34)
124
+ """
425
125
  key = _reset_key(passphrase)
426
- if key not in _db._data.reset_tokens:
427
- return None
428
- t = _db._data.reset_tokens[key]
429
- if t.expiry < datetime.now(timezone.utc):
430
- return None
431
- return build_reset_token(key)
126
+ return _db.reset_tokens.get(key)
432
127
 
433
128
 
434
129
  # -------------------------------------------------------------------------
@@ -447,68 +142,65 @@ def get_session_context(
447
142
 
448
143
  Returns:
449
144
  SessionContext if valid, None if session not found, expired, or host mismatch
145
+
146
+ Call sites:
147
+ - Example usage in docstring (db/__init__.py:16)
148
+ - Get session context from auth token (util/permutil.py:43)
450
149
  """
451
- from paskia.util.hostutil import normalize_host
452
150
 
453
- if session_key not in _db._data.sessions:
151
+ if session_key not in _db.sessions:
454
152
  return None
455
153
 
456
- s = _db._data.sessions[session_key]
154
+ s = _db.sessions[session_key]
457
155
  if s.expiry < datetime.now(timezone.utc):
458
156
  return None
459
157
 
460
- # Handle host binding
461
- if host is not None:
462
- if s.host is None:
463
- # Bind session to this host
464
- with _db.transaction("host_binding"):
465
- s.host = host
466
- elif s.host != host:
467
- # Session bound to different host
468
- return None
158
+ # Validate host matches (sessions are always created with a host)
159
+ if host is not None and s.host != host:
160
+ # Session bound to different host
161
+ return None
469
162
 
470
163
  # Validate user exists
471
- if s.user not in _db._data.users:
164
+ if s.user not in _db.users:
472
165
  return None
473
166
 
474
167
  # Validate role exists
475
- role_uuid = _db._data.users[s.user].role
476
- if role_uuid not in _db._data.roles:
168
+ role_uuid = _db.users[s.user].role
169
+ if role_uuid not in _db.roles:
477
170
  return None
478
171
 
479
172
  # Validate org exists
480
- org_uuid = _db._data.roles[role_uuid].org
481
- if org_uuid not in _db._data.orgs:
173
+ org_uuid = _db.roles[role_uuid].org
174
+ if org_uuid not in _db.orgs:
482
175
  return None
483
176
 
484
- session = build_session(session_key)
485
- user = build_user(s.user)
486
- role = build_role(role_uuid)
487
- org = build_org(org_uuid)
488
- credential = (
489
- build_credential(s.credential)
490
- if s.credential in _db._data.credentials
491
- else None
492
- )
177
+ session = _db.sessions[session_key]
178
+ user = _db.users[s.user]
179
+ role = _db.roles[role_uuid]
180
+ org = _db.orgs[org_uuid]
181
+
182
+ # Credential must exist (sessions are cascade-deleted when credential is deleted)
183
+ if s.credential not in _db.credentials:
184
+ return None
185
+ credential = _db.credentials[s.credential]
493
186
 
494
187
  # Effective permissions: role's permissions that the org can grant
495
188
  # Also filter by domain if host is provided
496
- org_perm_uuids = set(org.permissions) # Set of permission UUID strings
189
+ org_perm_uuids = {pid for pid, p in _db.permissions.items() if org_uuid in p.orgs}
497
190
  normalized_host = normalize_host(host)
498
191
  host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
499
192
 
500
193
  effective_perms = []
501
- for perm_uuid_str in role.permissions:
502
- if perm_uuid_str not in org_perm_uuids:
194
+ for perm_uuid in role.permission_set:
195
+ if perm_uuid not in org_perm_uuids:
503
196
  continue
504
- perm_uuid = UUID(perm_uuid_str)
505
- if perm_uuid not in _db._data.permissions:
197
+ if perm_uuid not in _db.permissions:
506
198
  continue
507
- p = _db._data.permissions[perm_uuid]
199
+ p = _db.permissions[perm_uuid]
508
200
  # Check domain restriction
509
201
  if p.domain is not None and p.domain != host_without_port:
510
202
  continue
511
- effective_perms.append(build_permission(perm_uuid))
203
+ effective_perms.append(_db.permissions[perm_uuid])
512
204
 
513
205
  return SessionContext(
514
206
  session=session,
@@ -516,7 +208,7 @@ def get_session_context(
516
208
  org=org,
517
209
  role=role,
518
210
  credential=credential,
519
- permissions=effective_perms or None,
211
+ permissions=effective_perms,
520
212
  )
521
213
 
522
214
 
@@ -527,473 +219,361 @@ def get_session_context(
527
219
 
528
220
  def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
529
221
  """Create a new permission."""
530
- if perm.uuid in _db._data.permissions:
222
+ if perm.uuid in _db.permissions:
531
223
  raise ValueError(f"Permission {perm.uuid} already exists")
532
- with _db.transaction("Created permission", ctx):
533
- _db._data.permissions[perm.uuid] = _PermissionData(
534
- scope=perm.scope,
535
- display_name=perm.display_name,
536
- domain=perm.domain,
537
- orgs={},
538
- )
539
-
540
-
541
- def update_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
542
- """Update a permission's scope, display_name, and domain."""
543
- if perm.uuid not in _db._data.permissions:
544
- raise ValueError(f"Permission {perm.uuid} not found")
545
- with _db.transaction("Updated permission", ctx):
546
- _db._data.permissions[perm.uuid].scope = perm.scope
547
- _db._data.permissions[perm.uuid].display_name = perm.display_name
548
- _db._data.permissions[perm.uuid].domain = perm.domain
224
+ with _db.transaction("admin:create_permission", ctx):
225
+ _db.permissions[perm.uuid] = perm
549
226
 
550
227
 
551
- def rename_permission(
552
- old_scope: str,
553
- new_scope: str,
228
+ def update_permission(
229
+ uuid: UUID,
230
+ scope: str,
554
231
  display_name: str,
555
232
  domain: str | None = None,
556
233
  *,
557
234
  ctx: SessionContext | None = None,
558
235
  ) -> None:
559
- """Rename a permission's scope. The UUID remains the same.
236
+ """Update a permission's scope, display_name, and domain.
560
237
 
561
- Since roles reference permissions by UUID, no role updates are needed.
562
- Note: Scopes do not need to be unique (same scope with different domains is valid).
238
+ Only these fields can be modified; created_at and other metadata remain immutable.
563
239
  """
564
- # Find permission by old scope
565
- key = None
566
- for pid, p in _db._data.permissions.items():
567
- if p.scope == old_scope:
568
- key = pid
569
- break
570
- if not key:
571
- raise ValueError(f"Permission with scope '{old_scope}' not found")
572
-
573
- with _db.transaction("Renamed permission", ctx):
574
- # Update the permission
575
- _db._data.permissions[key].scope = new_scope
576
- _db._data.permissions[key].display_name = display_name
577
- _db._data.permissions[key].domain = domain
240
+ if uuid not in _db.permissions:
241
+ raise ValueError(f"Permission {uuid} not found")
242
+ with _db.transaction("admin:update_permission", ctx):
243
+ _db.permissions[uuid].scope = scope
244
+ _db.permissions[uuid].display_name = display_name
245
+ _db.permissions[uuid].domain = domain
578
246
 
579
247
 
580
- def delete_permission(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
248
+ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
581
249
  """Delete a permission and remove it from all roles."""
582
- if isinstance(uuid, str):
583
- uuid = UUID(uuid)
584
- if uuid not in _db._data.permissions:
250
+ if uuid not in _db.permissions:
585
251
  raise ValueError(f"Permission {uuid} not found")
586
- with _db.transaction("Deleted permission", ctx):
252
+ with _db.transaction("admin:delete_permission", ctx):
587
253
  # Remove this permission from all roles
588
- for role in _db._data.roles.values():
254
+ for role in _db.roles.values():
589
255
  role.permissions.pop(uuid, None)
590
- del _db._data.permissions[uuid]
256
+ del _db.permissions[uuid]
591
257
 
592
258
 
593
- def create_organization(org: Org, *, ctx: SessionContext | None = None) -> None:
259
+ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
594
260
  """Create a new organization with an Administration role.
595
261
 
596
262
  Automatically creates an 'Administration' role with auth:org:admin permission.
597
263
  """
598
- if org.uuid in _db._data.orgs:
264
+ if org.uuid in _db.orgs:
599
265
  raise ValueError(f"Organization {org.uuid} already exists")
600
- with _db.transaction("Created organization", ctx):
601
- _db._data.orgs[org.uuid] = _OrgData(
602
- display_name=org.display_name, created_at=datetime.now(timezone.utc)
603
- )
604
- # Grant listed permissions to this org (org.permissions contains UUIDs now)
605
- for perm_uuid_str in org.permissions:
606
- perm_uuid = (
607
- UUID(perm_uuid_str) if isinstance(perm_uuid_str, str) else perm_uuid_str
608
- )
609
- if perm_uuid in _db._data.permissions:
610
- _db._data.permissions[perm_uuid].orgs[org.uuid] = True
266
+ with _db.transaction("admin:create_org", ctx):
267
+ new_org = Org(display_name=org.display_name)
268
+ _db.orgs[org.uuid] = new_org
269
+ new_org.uuid = org.uuid
611
270
  # Create Administration role with org admin permission
612
- import uuid7
613
271
 
614
272
  admin_role_uuid = uuid7.create()
615
273
  # Find the auth:org:admin permission UUID
616
274
  org_admin_perm_uuid = None
617
- for pid, p in _db._data.permissions.items():
275
+ for pid, p in _db.permissions.items():
618
276
  if p.scope == "auth:org:admin":
619
277
  org_admin_perm_uuid = pid
620
278
  break
621
279
  role_permissions = {org_admin_perm_uuid: True} if org_admin_perm_uuid else {}
622
- _db._data.roles[admin_role_uuid] = _RoleData(
280
+ admin_role = Role(
623
281
  org=org.uuid,
624
282
  display_name="Administration",
625
283
  permissions=role_permissions,
626
284
  )
285
+ admin_role.uuid = admin_role_uuid
286
+ _db.roles[admin_role_uuid] = admin_role
627
287
 
628
288
 
629
- def update_organization_name(
630
- uuid: str | UUID,
289
+ def update_org_name(
290
+ uuid: UUID,
631
291
  display_name: str,
632
292
  *,
633
293
  ctx: SessionContext | None = None,
634
294
  ) -> None:
635
295
  """Update organization display name."""
636
- if isinstance(uuid, str):
637
- uuid = UUID(uuid)
638
- if uuid not in _db._data.orgs:
296
+ if uuid not in _db.orgs:
639
297
  raise ValueError(f"Organization {uuid} not found")
640
- with _db.transaction("Renamed organization", ctx):
641
- _db._data.orgs[uuid].display_name = display_name
298
+ with _db.transaction("admin:update_org_name", ctx):
299
+ _db.orgs[uuid].display_name = display_name
642
300
 
643
301
 
644
- def delete_organization(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
302
+ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
645
303
  """Delete organization and all its roles/users."""
646
- if isinstance(uuid, str):
647
- uuid = UUID(uuid)
648
- if uuid not in _db._data.orgs:
304
+ if uuid not in _db.orgs:
649
305
  raise ValueError(f"Organization {uuid} not found")
650
- with _db.transaction("Deleted organization", ctx):
306
+ with _db.transaction("admin:delete_org", ctx):
651
307
  # Remove org from all permissions
652
- for p in _db._data.permissions.values():
308
+ for p in _db.permissions.values():
653
309
  p.orgs.pop(uuid, None)
654
310
  # Delete roles in this org
655
- role_uuids = [rid for rid, r in _db._data.roles.items() if r.org == uuid]
311
+ role_uuids = [rid for rid, r in _db.roles.items() if r.org == uuid]
656
312
  for rid in role_uuids:
657
- del _db._data.roles[rid]
313
+ del _db.roles[rid]
658
314
  # Delete users with those roles
659
- user_uuids = [uid for uid, u in _db._data.users.items() if u.role in role_uuids]
315
+ user_uuids = [uid for uid, u in _db.users.items() if u.role in role_uuids]
660
316
  for uid in user_uuids:
661
- del _db._data.users[uid]
662
- del _db._data.orgs[uuid]
317
+ del _db.users[uid]
318
+ del _db.orgs[uuid]
663
319
 
664
320
 
665
- def add_permission_to_organization(
666
- org_uuid: str | UUID,
667
- permission_id: str | UUID,
321
+ def add_permission_to_org(
322
+ org_uuid: UUID,
323
+ permission_uuid: UUID,
668
324
  *,
669
325
  ctx: SessionContext | None = None,
670
326
  ) -> None:
671
327
  """Grant a permission to an organization by UUID."""
672
- if isinstance(org_uuid, str):
673
- org_uuid = UUID(org_uuid)
674
- if org_uuid not in _db._data.orgs:
328
+ if org_uuid not in _db.orgs:
675
329
  raise ValueError(f"Organization {org_uuid} not found")
676
330
 
677
- # Convert permission_id to UUID
678
- if isinstance(permission_id, str):
679
- try:
680
- permission_uuid = UUID(permission_id)
681
- except ValueError:
682
- # It's a scope - look up the UUID (backwards compat)
683
- for pid, p in _db._data.permissions.items():
684
- if p.scope == permission_id:
685
- permission_uuid = pid
686
- break
687
- else:
688
- raise ValueError(f"Permission {permission_id} not found")
689
- else:
690
- permission_uuid = permission_id
691
-
692
- if permission_uuid not in _db._data.permissions:
331
+ if permission_uuid not in _db.permissions:
693
332
  raise ValueError(f"Permission {permission_uuid} not found")
694
333
 
695
- with _db.transaction("Granted org permission", ctx):
696
- _db._data.permissions[permission_uuid].orgs[org_uuid] = True
334
+ with _db.transaction("admin:add_permission_to_org", ctx):
335
+ _db.permissions[permission_uuid].orgs[org_uuid] = True
697
336
 
698
337
 
699
- def remove_permission_from_organization(
700
- org_uuid: str | UUID,
701
- permission_id: str | UUID,
338
+ def remove_permission_from_org(
339
+ org_uuid: UUID,
340
+ permission_uuid: UUID,
702
341
  *,
703
342
  ctx: SessionContext | None = None,
704
343
  ) -> None:
705
344
  """Remove a permission from an organization by UUID."""
706
- if isinstance(org_uuid, str):
707
- org_uuid = UUID(org_uuid)
708
- if org_uuid not in _db._data.orgs:
345
+ if org_uuid not in _db.orgs:
709
346
  raise ValueError(f"Organization {org_uuid} not found")
710
347
 
711
- # Convert permission_id to UUID
712
- if isinstance(permission_id, str):
713
- try:
714
- permission_uuid = UUID(permission_id)
715
- except ValueError:
716
- # It's a scope - look up the UUID (backwards compat)
717
- for pid, p in _db._data.permissions.items():
718
- if p.scope == permission_id:
719
- permission_uuid = pid
720
- break
721
- else:
722
- return # Permission not found, silently return
723
- else:
724
- permission_uuid = permission_id
725
-
726
- if permission_uuid not in _db._data.permissions:
348
+ if permission_uuid not in _db.permissions:
727
349
  return # Permission not found, silently return
728
350
 
729
- with _db.transaction("Revoked org permission", ctx):
730
- _db._data.permissions[permission_uuid].orgs.pop(org_uuid, None)
351
+ with _db.transaction("admin:remove_permission_from_org", ctx):
352
+ _db.permissions[permission_uuid].orgs.pop(org_uuid, None)
731
353
 
732
354
 
733
355
  def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
734
356
  """Create a new role."""
735
- if role.uuid in _db._data.roles:
357
+ if role.uuid in _db.roles:
736
358
  raise ValueError(f"Role {role.uuid} already exists")
737
- if role.org_uuid not in _db._data.orgs:
738
- raise ValueError(f"Organization {role.org_uuid} not found")
739
- with _db.transaction("Created role", ctx):
740
- _db._data.roles[role.uuid] = _RoleData(
741
- org=role.org_uuid,
742
- display_name=role.display_name,
743
- permissions={UUID(pid): True for pid in role.permissions},
744
- )
359
+ if role.org not in _db.orgs:
360
+ raise ValueError(f"Organization {role.org} not found")
361
+ with _db.transaction("admin:create_role", ctx):
362
+ _db.roles[role.uuid] = role
745
363
 
746
364
 
747
365
  def update_role_name(
748
- uuid: str | UUID,
366
+ uuid: UUID,
749
367
  display_name: str,
750
368
  *,
751
369
  ctx: SessionContext | None = None,
752
370
  ) -> None:
753
371
  """Update role display name."""
754
- if isinstance(uuid, str):
755
- uuid = UUID(uuid)
756
- if uuid not in _db._data.roles:
372
+ if uuid not in _db.roles:
757
373
  raise ValueError(f"Role {uuid} not found")
758
- with _db.transaction("Renamed role", ctx):
759
- _db._data.roles[uuid].display_name = display_name
374
+ with _db.transaction("admin:update_role_name", ctx):
375
+ _db.roles[uuid].display_name = display_name
760
376
 
761
377
 
762
378
  def add_permission_to_role(
763
- role_uuid: str | UUID,
764
- permission_uuid: str | UUID,
379
+ role_uuid: UUID,
380
+ permission_uuid: UUID,
765
381
  *,
766
382
  ctx: SessionContext | None = None,
767
383
  ) -> None:
768
384
  """Add permission to role by UUID."""
769
- if isinstance(role_uuid, str):
770
- role_uuid = UUID(role_uuid)
771
- if isinstance(permission_uuid, str):
772
- permission_uuid = UUID(permission_uuid)
773
- if role_uuid not in _db._data.roles:
385
+ if role_uuid not in _db.roles:
774
386
  raise ValueError(f"Role {role_uuid} not found")
775
- if permission_uuid not in _db._data.permissions:
387
+ if permission_uuid not in _db.permissions:
776
388
  raise ValueError(f"Permission {permission_uuid} not found")
777
- with _db.transaction("Granted role permission", ctx):
778
- _db._data.roles[role_uuid].permissions[permission_uuid] = True
389
+ with _db.transaction("admin:add_permission_to_role", ctx):
390
+ _db.roles[role_uuid].permissions[permission_uuid] = True
779
391
 
780
392
 
781
393
  def remove_permission_from_role(
782
- role_uuid: str | UUID,
783
- permission_uuid: str | UUID,
394
+ role_uuid: UUID,
395
+ permission_uuid: UUID,
784
396
  *,
785
397
  ctx: SessionContext | None = None,
786
398
  ) -> None:
787
399
  """Remove permission from role by UUID."""
788
- if isinstance(role_uuid, str):
789
- role_uuid = UUID(role_uuid)
790
- if isinstance(permission_uuid, str):
791
- permission_uuid = UUID(permission_uuid)
792
- if role_uuid not in _db._data.roles:
400
+ if role_uuid not in _db.roles:
793
401
  raise ValueError(f"Role {role_uuid} not found")
794
- with _db.transaction("Revoked role permission", ctx):
795
- _db._data.roles[role_uuid].permissions.pop(permission_uuid, None)
402
+ with _db.transaction("admin:remove_permission_from_role", ctx):
403
+ _db.roles[role_uuid].permissions.pop(permission_uuid, None)
796
404
 
797
405
 
798
- def delete_role(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
406
+ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
799
407
  """Delete a role."""
800
- if isinstance(uuid, str):
801
- uuid = UUID(uuid)
802
- if uuid not in _db._data.roles:
408
+ if uuid not in _db.roles:
803
409
  raise ValueError(f"Role {uuid} not found")
804
410
  # Check no users have this role
805
- if any(u.role == uuid for u in _db._data.users.values()):
411
+ if any(u.role == uuid for u in _db.users.values()):
806
412
  raise ValueError(f"Cannot delete role {uuid}: users still assigned")
807
- with _db.transaction("Deleted role", ctx):
808
- del _db._data.roles[uuid]
413
+ with _db.transaction("admin:delete_role", ctx):
414
+ del _db.roles[uuid]
809
415
 
810
416
 
811
417
  def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
812
418
  """Create a new user."""
813
- if new_user.uuid in _db._data.users:
419
+ if new_user.uuid in _db.users:
814
420
  raise ValueError(f"User {new_user.uuid} already exists")
815
- if new_user.role_uuid not in _db._data.roles:
816
- raise ValueError(f"Role {new_user.role_uuid} not found")
817
- with _db.transaction("Created user", ctx):
818
- _db._data.users[new_user.uuid] = _UserData(
819
- display_name=new_user.display_name,
820
- role=new_user.role_uuid,
821
- created_at=new_user.created_at or datetime.now(timezone.utc),
822
- last_seen=new_user.last_seen,
823
- visits=new_user.visits,
824
- )
421
+ if new_user.role not in _db.roles:
422
+ raise ValueError(f"Role {new_user.role} not found")
423
+ with _db.transaction("admin:create_user", ctx):
424
+ _db.users[new_user.uuid] = new_user
825
425
 
826
426
 
827
427
  def update_user_display_name(
828
- uuid: str | UUID,
428
+ uuid: UUID,
829
429
  display_name: str,
830
430
  *,
831
431
  ctx: SessionContext | None = None,
832
432
  ) -> None:
833
433
  """Update user display name.
834
434
 
835
- For self-service (user updating own name), ctx can be None and user is derived from uuid.
836
- For admin operations, ctx should be provided.
435
+ The acting user should be logged via ctx.
436
+ For self-service (user updating own name), pass user's ctx.
437
+ For admin operations, pass admin's ctx.
837
438
  """
838
439
  if isinstance(uuid, str):
839
440
  uuid = UUID(uuid)
840
- if uuid not in _db._data.users:
441
+ if uuid not in _db.users:
841
442
  raise ValueError(f"User {uuid} not found")
842
- # For self-service, derive user from the uuid being modified
843
- user_str = str(uuid) if not ctx else None
844
- with _db.transaction("Renamed user", ctx, user=user_str):
845
- _db._data.users[uuid].display_name = display_name
443
+ with _db.transaction("update_user_display_name", ctx):
444
+ _db.users[uuid].display_name = display_name
846
445
 
847
446
 
848
447
  def update_user_role(
849
- uuid: str | UUID,
850
- role_uuid: str | UUID,
448
+ uuid: UUID,
449
+ role_uuid: UUID,
851
450
  *,
852
451
  ctx: SessionContext | None = None,
853
452
  ) -> None:
854
453
  """Update user's role."""
855
- if isinstance(uuid, str):
856
- uuid = UUID(uuid)
857
- if isinstance(role_uuid, str):
858
- role_uuid = UUID(role_uuid)
859
- if uuid not in _db._data.users:
454
+ if uuid not in _db.users:
860
455
  raise ValueError(f"User {uuid} not found")
861
- if role_uuid not in _db._data.roles:
456
+ if role_uuid not in _db.roles:
862
457
  raise ValueError(f"Role {role_uuid} not found")
863
- with _db.transaction("Changed user role", ctx):
864
- _db._data.users[uuid].role = role_uuid
458
+ with _db.transaction("admin:update_user_role", ctx):
459
+ _db.users[uuid].role = role_uuid
865
460
 
866
461
 
867
462
  def update_user_role_in_organization(
868
- user_uuid: str | UUID,
463
+ user_uuid: UUID,
869
464
  role_name: str,
870
465
  *,
871
466
  ctx: SessionContext | None = None,
872
467
  ) -> None:
873
468
  """Update user's role by role name within their current organization."""
874
- if isinstance(user_uuid, str):
875
- user_uuid = UUID(user_uuid)
876
- if user_uuid not in _db._data.users:
469
+ if user_uuid not in _db.users:
877
470
  raise ValueError(f"User {user_uuid} not found")
878
- current_role_uuid = _db._data.users[user_uuid].role
879
- if current_role_uuid not in _db._data.roles:
471
+ current_role_uuid = _db.users[user_uuid].role
472
+ if current_role_uuid not in _db.roles:
880
473
  raise ValueError("Current role not found")
881
- org_uuid = _db._data.roles[current_role_uuid].org
474
+ org_uuid = _db.roles[current_role_uuid].org
882
475
  # Find role by name in the same org
883
476
  new_role_uuid = None
884
- for rid, r in _db._data.roles.items():
477
+ for rid, r in _db.roles.items():
885
478
  if r.org == org_uuid and r.display_name == role_name:
886
479
  new_role_uuid = rid
887
480
  break
888
481
  if new_role_uuid is None:
889
482
  raise ValueError(f"Role '{role_name}' not found in organization")
890
- with _db.transaction("Changed user role", ctx):
891
- _db._data.users[user_uuid].role = new_role_uuid
483
+ with _db.transaction("admin:update_user_role", ctx):
484
+ _db.users[user_uuid].role = new_role_uuid
892
485
 
893
486
 
894
- def delete_user(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
487
+ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
895
488
  """Delete user and their credentials/sessions."""
896
- if isinstance(uuid, str):
897
- uuid = UUID(uuid)
898
- if uuid not in _db._data.users:
489
+ if uuid not in _db.users:
899
490
  raise ValueError(f"User {uuid} not found")
900
- with _db.transaction("Deleted user", ctx):
491
+ with _db.transaction("admin:delete_user", ctx):
901
492
  # Delete credentials
902
- cred_uuids = [cid for cid, c in _db._data.credentials.items() if c.user == uuid]
493
+ cred_uuids = [cid for cid, c in _db.credentials.items() if c.user == uuid]
903
494
  for cid in cred_uuids:
904
- del _db._data.credentials[cid]
495
+ del _db.credentials[cid]
905
496
  # Delete sessions
906
- sess_keys = [k for k, s in _db._data.sessions.items() if s.user == uuid]
497
+ sess_keys = [k for k, s in _db.sessions.items() if s.user == uuid]
907
498
  for k in sess_keys:
908
- del _db._data.sessions[k]
499
+ del _db.sessions[k]
909
500
  # Delete reset tokens
910
- token_keys = [k for k, t in _db._data.reset_tokens.items() if t.user == uuid]
501
+ token_keys = [k for k, t in _db.reset_tokens.items() if t.user == uuid]
911
502
  for k in token_keys:
912
- del _db._data.reset_tokens[k]
913
- del _db._data.users[uuid]
503
+ del _db.reset_tokens[k]
504
+ del _db.users[uuid]
914
505
 
915
506
 
916
507
  def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
917
508
  """Create a new credential."""
918
- if cred.uuid in _db._data.credentials:
509
+ if cred.uuid in _db.credentials:
919
510
  raise ValueError(f"Credential {cred.uuid} already exists")
920
- if cred.user_uuid not in _db._data.users:
921
- raise ValueError(f"User {cred.user_uuid} not found")
922
- with _db.transaction("Added credential", ctx):
923
- _db._data.credentials[cred.uuid] = _CredentialData(
924
- credential_id=cred.credential_id,
925
- user=cred.user_uuid,
926
- aaguid=cred.aaguid,
927
- public_key=cred.public_key,
928
- sign_count=cred.sign_count,
929
- created_at=cred.created_at,
930
- last_used=cred.last_used,
931
- last_verified=cred.last_verified,
932
- )
511
+ if cred.user not in _db.users:
512
+ raise ValueError(f"User {cred.user} not found")
513
+ with _db.transaction("create_credential", ctx):
514
+ _db.credentials[cred.uuid] = cred
933
515
 
934
516
 
935
517
  def update_credential_sign_count(
936
- uuid: str | UUID,
518
+ uuid: UUID,
937
519
  sign_count: int,
938
520
  last_used: datetime | None = None,
939
521
  *,
940
522
  ctx: SessionContext | None = None,
941
523
  ) -> None:
942
524
  """Update credential sign count and last_used."""
943
- if isinstance(uuid, str):
944
- uuid = UUID(uuid)
945
- if uuid not in _db._data.credentials:
525
+ if uuid not in _db.credentials:
946
526
  raise ValueError(f"Credential {uuid} not found")
947
- with _db.transaction("Updated credential", ctx):
948
- _db._data.credentials[uuid].sign_count = sign_count
527
+ with _db.transaction("update_credential_sign_count", ctx):
528
+ _db.credentials[uuid].sign_count = sign_count
949
529
  if last_used:
950
- _db._data.credentials[uuid].last_used = last_used
530
+ _db.credentials[uuid].last_used = last_used
951
531
 
952
532
 
953
533
  def delete_credential(
954
- uuid: str | UUID,
955
- user_uuid: str | UUID | None = None,
534
+ uuid: UUID,
535
+ user_uuid: UUID | None = None,
956
536
  *,
957
537
  ctx: SessionContext | None = None,
958
538
  ) -> None:
959
- """Delete a credential.
539
+ """Delete a credential and all sessions using it.
960
540
 
961
541
  If user_uuid is provided, validates that the credential belongs to that user.
962
542
  """
963
- if isinstance(uuid, str):
964
- uuid = UUID(uuid)
965
- if uuid not in _db._data.credentials:
543
+ if uuid not in _db.credentials:
966
544
  raise ValueError(f"Credential {uuid} not found")
967
545
  if user_uuid is not None:
968
- if isinstance(user_uuid, str):
969
- user_uuid = UUID(user_uuid)
970
- cred_user = _db._data.credentials[uuid].user
546
+ cred_user = _db.credentials[uuid].user
971
547
  if cred_user != user_uuid:
972
548
  raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
973
- with _db.transaction("Deleted credential", ctx):
974
- del _db._data.credentials[uuid]
549
+ with _db.transaction("delete_credential", ctx):
550
+ # Delete all sessions using this credential
551
+ keys = [k for k, s in _db.sessions.items() if s.credential == uuid]
552
+ for k in keys:
553
+ del _db.sessions[k]
554
+ del _db.credentials[uuid]
975
555
 
976
556
 
977
557
  def create_session(
978
558
  key: str,
979
559
  user_uuid: UUID,
980
560
  credential_uuid: UUID,
981
- host: str | None,
982
- ip: str | None,
983
- user_agent: str | None,
561
+ host: str,
562
+ ip: str,
563
+ user_agent: str,
984
564
  expiry: datetime,
985
565
  *,
986
566
  ctx: SessionContext | None = None,
987
567
  ) -> None:
988
568
  """Create a new session."""
989
- if key in _db._data.sessions:
569
+ if key in _db.sessions:
990
570
  raise ValueError("Session already exists")
991
- if user_uuid not in _db._data.users:
571
+ if user_uuid not in _db.users:
992
572
  raise ValueError(f"User {user_uuid} not found")
993
- if credential_uuid not in _db._data.credentials:
573
+ if credential_uuid not in _db.credentials:
994
574
  raise ValueError(f"Credential {credential_uuid} not found")
995
- with _db.transaction("Created session", ctx):
996
- _db._data.sessions[key] = _SessionData(
575
+ with _db.transaction("create_session", ctx):
576
+ _db.sessions[key] = Session(
997
577
  user=user_uuid,
998
578
  credential=credential_uuid,
999
579
  host=host,
@@ -1013,10 +593,10 @@ def update_session(
1013
593
  ctx: SessionContext | None = None,
1014
594
  ) -> None:
1015
595
  """Update session metadata."""
1016
- if key not in _db._data.sessions:
596
+ if key not in _db.sessions:
1017
597
  raise ValueError("Session not found")
1018
- with _db.transaction("Updated session", ctx):
1019
- s = _db._data.sessions[key]
598
+ with _db.transaction("update_session", ctx):
599
+ s = _db.sessions[key]
1020
600
  if host is not None:
1021
601
  s.host = host
1022
602
  if ip is not None:
@@ -1035,33 +615,29 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
1035
615
  def delete_session(key: str, *, ctx: SessionContext | None = None) -> None:
1036
616
  """Delete a session.
1037
617
 
1038
- For logout (user deleting own session), ctx can be None and user is derived from session.
1039
- For admin operations, ctx should be provided.
618
+ The acting user should be logged via ctx.
619
+ For user logout, pass ctx of the user's session.
620
+ For admin terminating a session, pass admin's ctx.
1040
621
  """
1041
- if key not in _db._data.sessions:
622
+ if key not in _db.sessions:
1042
623
  raise ValueError("Session not found")
1043
- # For self-service logout, derive user from the session being deleted
1044
- user_str = str(_db._data.sessions[key].user) if not ctx else None
1045
- with _db.transaction("Deleted session", ctx, user=user_str):
1046
- del _db._data.sessions[key]
624
+ with _db.transaction("delete_session", ctx):
625
+ del _db.sessions[key]
1047
626
 
1048
627
 
1049
628
  def delete_sessions_for_user(
1050
- user_uuid: str | UUID, *, ctx: SessionContext | None = None
629
+ user_uuid: UUID, *, ctx: SessionContext | None = None
1051
630
  ) -> None:
1052
631
  """Delete all sessions for a user.
1053
632
 
1054
- For logout-all (user deleting own sessions), ctx can be None and user is derived from user_uuid.
1055
- For admin operations, ctx should be provided.
633
+ The acting user should be logged via ctx.
634
+ For user logout-all, pass ctx of the user's session.
635
+ For admin bulk termination, pass admin's ctx.
1056
636
  """
1057
- if isinstance(user_uuid, str):
1058
- user_uuid = UUID(user_uuid)
1059
- # For self-service, derive user from the user_uuid param
1060
- user_str = str(user_uuid) if not ctx else None
1061
- with _db.transaction("Deleted user sessions", ctx, user=user_str):
1062
- keys = [k for k, s in _db._data.sessions.items() if s.user == user_uuid]
637
+ with _db.transaction("admin:delete_sessions_for_user", ctx):
638
+ keys = [k for k, s in _db.sessions.items() if s.user == user_uuid]
1063
639
  for k in keys:
1064
- del _db._data.sessions[k]
640
+ del _db.sessions[k]
1065
641
 
1066
642
 
1067
643
  def create_reset_token(
@@ -1074,28 +650,28 @@ def create_reset_token(
1074
650
  ) -> None:
1075
651
  """Create a reset token from a passphrase.
1076
652
 
1077
- For self-service (user creating own recovery link), ctx can be None and user is derived from user_uuid.
1078
- For admin operations, ctx should be provided.
653
+ The acting user should be logged via ctx.
654
+ For self-service (user creating own recovery link), pass user's ctx.
655
+ For admin operations, pass admin's ctx.
656
+ For system operations (bootstrap), pass neither to log no user.
1079
657
  """
1080
658
  key = _reset_key(passphrase)
1081
- if key in _db._data.reset_tokens:
659
+ if key in _db.reset_tokens:
1082
660
  raise ValueError("Reset token already exists")
1083
- if user_uuid not in _db._data.users:
661
+ if user_uuid not in _db.users:
1084
662
  raise ValueError(f"User {user_uuid} not found")
1085
- # For self-service, derive user from the user_uuid param
1086
- user_str = str(user_uuid) if not ctx else None
1087
- with _db.transaction("Created reset token", ctx, user=user_str):
1088
- _db._data.reset_tokens[key] = _ResetTokenData(
663
+ with _db.transaction("create_reset_token", ctx):
664
+ _db.reset_tokens[key] = ResetToken(
1089
665
  user=user_uuid, expiry=expiry, token_type=token_type
1090
666
  )
1091
667
 
1092
668
 
1093
669
  def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
1094
670
  """Delete a reset token."""
1095
- if key not in _db._data.reset_tokens:
671
+ if key not in _db.reset_tokens:
1096
672
  raise ValueError("Reset token not found")
1097
- with _db.transaction("Deleted reset token", ctx):
1098
- del _db._data.reset_tokens[key]
673
+ with _db.transaction("delete_reset_token", ctx):
674
+ del _db.reset_tokens[key]
1099
675
 
1100
676
 
1101
677
  # -------------------------------------------------------------------------
@@ -1107,16 +683,14 @@ def cleanup_expired() -> int:
1107
683
  """Remove expired sessions and reset tokens. Returns count removed."""
1108
684
  now = datetime.now(timezone.utc)
1109
685
  count = 0
1110
- with _db.transaction("Cleaned up expired"):
1111
- expired_sessions = [k for k, s in _db._data.sessions.items() if s.expiry < now]
686
+ with _db.transaction("expiry"):
687
+ expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
1112
688
  for k in expired_sessions:
1113
- del _db._data.sessions[k]
689
+ del _db.sessions[k]
1114
690
  count += 1
1115
- expired_tokens = [
1116
- k for k, t in _db._data.reset_tokens.items() if t.expiry < now
1117
- ]
691
+ expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
1118
692
  for k in expired_tokens:
1119
- del _db._data.reset_tokens[k]
693
+ del _db.reset_tokens[k]
1120
694
  count += 1
1121
695
  return count
1122
696
 
@@ -1132,11 +706,12 @@ def _create_token() -> str:
1132
706
 
1133
707
 
1134
708
  def login(
1135
- user_uuid: str | UUID,
1136
- credential: Credential,
1137
- host: str | None,
1138
- ip: str | None,
1139
- user_agent: str | None,
709
+ user_uuid: UUID,
710
+ credential_uuid: UUID,
711
+ sign_count: int,
712
+ host: str,
713
+ ip: str,
714
+ user_agent: str,
1140
715
  expiry: datetime,
1141
716
  ) -> str:
1142
717
  """Update user/credential on login and create session in a single transaction.
@@ -1152,24 +727,24 @@ def login(
1152
727
  if isinstance(user_uuid, str):
1153
728
  user_uuid = UUID(user_uuid)
1154
729
  now = datetime.now(timezone.utc)
1155
- if user_uuid not in _db._data.users:
730
+ if user_uuid not in _db.users:
1156
731
  raise ValueError(f"User {user_uuid} not found")
1157
- if credential.uuid not in _db._data.credentials:
1158
- raise ValueError(f"Credential {credential.uuid} not found")
732
+ if credential_uuid not in _db.credentials:
733
+ raise ValueError(f"Credential {credential_uuid} not found")
1159
734
 
1160
735
  session_key = _create_token()
1161
736
  user_str = str(user_uuid)
1162
- with _db.transaction("User logged in", user=user_str):
737
+ with _db.transaction("login", user=user_str):
1163
738
  # Update user
1164
- _db._data.users[user_uuid].last_seen = now
1165
- _db._data.users[user_uuid].visits += 1
739
+ _db.users[user_uuid].last_seen = now
740
+ _db.users[user_uuid].visits += 1
1166
741
  # Update credential
1167
- _db._data.credentials[credential.uuid].sign_count = credential.sign_count
1168
- _db._data.credentials[credential.uuid].last_used = now
742
+ _db.credentials[credential_uuid].sign_count = sign_count
743
+ _db.credentials[credential_uuid].last_used = now
1169
744
  # Create session
1170
- _db._data.sessions[session_key] = _SessionData(
745
+ _db.sessions[session_key] = Session(
1171
746
  user=user_uuid,
1172
- credential=credential.uuid,
747
+ credential=credential_uuid,
1173
748
  host=host,
1174
749
  ip=ip,
1175
750
  user_agent=user_agent,
@@ -1181,9 +756,9 @@ def login(
1181
756
  def create_credential_session(
1182
757
  user_uuid: UUID,
1183
758
  credential: Credential,
1184
- host: str | None,
1185
- ip: str | None,
1186
- user_agent: str | None,
759
+ host: str,
760
+ ip: str,
761
+ user_agent: str,
1187
762
  display_name: str | None = None,
1188
763
  reset_key: bytes | None = None,
1189
764
  ) -> str:
@@ -1197,35 +772,25 @@ def create_credential_session(
1197
772
 
1198
773
  Returns the generated session token.
1199
774
  """
1200
- from paskia.config import SESSION_LIFETIME
1201
775
 
1202
776
  now = datetime.now(timezone.utc)
1203
777
  expiry = now + SESSION_LIFETIME
1204
778
  session_key = _create_token()
1205
779
 
1206
- if user_uuid not in _db._data.users:
780
+ if user_uuid not in _db.users:
1207
781
  raise ValueError(f"User {user_uuid} not found")
1208
782
 
1209
783
  user_str = str(user_uuid)
1210
- with _db.transaction("Registered credential", user=user_str):
784
+ with _db.transaction("create_credential_session", user=user_str):
1211
785
  # Update display name if provided
1212
786
  if display_name:
1213
- _db._data.users[user_uuid].display_name = display_name
787
+ _db.users[user_uuid].display_name = display_name
1214
788
 
1215
789
  # Create credential
1216
- _db._data.credentials[credential.uuid] = _CredentialData(
1217
- credential_id=credential.credential_id,
1218
- user=user_uuid,
1219
- aaguid=credential.aaguid,
1220
- public_key=credential.public_key,
1221
- sign_count=credential.sign_count,
1222
- created_at=credential.created_at,
1223
- last_used=credential.last_used,
1224
- last_verified=credential.last_verified,
1225
- )
790
+ _db.credentials[credential.uuid] = credential
1226
791
 
1227
792
  # Create session
1228
- _db._data.sessions[session_key] = _SessionData(
793
+ _db.sessions[session_key] = Session(
1229
794
  user=user_uuid,
1230
795
  credential=credential.uuid,
1231
796
  host=host,
@@ -1236,6 +801,118 @@ def create_credential_session(
1236
801
 
1237
802
  # Delete reset token if provided
1238
803
  if reset_key:
1239
- if reset_key in _db._data.reset_tokens:
1240
- del _db._data.reset_tokens[reset_key]
804
+ if reset_key in _db.reset_tokens:
805
+ del _db.reset_tokens[reset_key]
1241
806
  return session_key
807
+
808
+
809
+ # -------------------------------------------------------------------------
810
+ # Bootstrap (single transaction for initial system setup)
811
+ # -------------------------------------------------------------------------
812
+
813
+
814
+ def bootstrap(
815
+ org_name: str = "Organization",
816
+ admin_name: str = "Admin",
817
+ reset_passphrase: str | None = None,
818
+ reset_expiry: datetime | None = None,
819
+ ) -> str:
820
+ """Bootstrap the entire system in a single transaction.
821
+
822
+ Creates:
823
+ - auth:admin permission (Master Admin)
824
+ - auth:org:admin permission (Org Admin)
825
+ - Organization with Administration role
826
+ - Admin user with Administration role
827
+ - Reset token for admin registration
828
+
829
+ This is the only way to create a new database file (besides migrate).
830
+ All data is created atomically - if any step fails, nothing is written.
831
+
832
+ Args:
833
+ org_name: Display name for the organization (default: "Organization")
834
+ admin_name: Display name for the admin user (default: "Admin")
835
+ reset_passphrase: Passphrase for the reset token (generated if not provided)
836
+ reset_expiry: Expiry datetime for the reset token (default: 14 days)
837
+
838
+ Returns:
839
+ The reset passphrase for admin registration.
840
+ """
841
+
842
+ # Check if system is already bootstrapped
843
+ for p in _db.permissions.values():
844
+ if p.scope == "auth:admin":
845
+ raise ValueError(
846
+ "System already bootstrapped (auth:admin permission exists)"
847
+ )
848
+
849
+ # Generate UUIDs upfront
850
+ perm_admin_uuid = uuid7.create()
851
+ perm_org_admin_uuid = uuid7.create()
852
+ org_uuid = uuid7.create()
853
+ role_uuid = uuid7.create()
854
+ user_uuid = uuid7.create()
855
+
856
+ # Generate reset token components
857
+ if reset_passphrase is None:
858
+ reset_passphrase = generate_passphrase()
859
+ if reset_expiry is None:
860
+ from paskia.authsession import reset_expires # noqa: PLC0415
861
+
862
+ reset_expiry = reset_expires()
863
+ reset_key = _reset_key(reset_passphrase)
864
+
865
+ now = datetime.now(timezone.utc)
866
+
867
+ with _db.transaction("bootstrap"):
868
+ # Create auth:admin permission
869
+ perm_admin = Permission(
870
+ scope="auth:admin",
871
+ display_name="Master Admin",
872
+ orgs={org_uuid: True}, # Grant to org
873
+ )
874
+ perm_admin.uuid = perm_admin_uuid
875
+ _db.permissions[perm_admin_uuid] = perm_admin
876
+
877
+ # Create auth:org:admin permission
878
+ perm_org_admin = Permission(
879
+ scope="auth:org:admin",
880
+ display_name="Org Admin",
881
+ orgs={org_uuid: True}, # Grant to org
882
+ )
883
+ perm_org_admin.uuid = perm_org_admin_uuid
884
+ _db.permissions[perm_org_admin_uuid] = perm_org_admin
885
+
886
+ # Create organization
887
+ new_org = Org(display_name=org_name)
888
+ new_org.uuid = org_uuid
889
+ _db.orgs[org_uuid] = new_org
890
+
891
+ # Create Administration role with both permissions
892
+ admin_role = Role(
893
+ org=org_uuid,
894
+ display_name="Administration",
895
+ permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
896
+ )
897
+ admin_role.uuid = role_uuid
898
+ _db.roles[role_uuid] = admin_role
899
+
900
+ # Create admin user
901
+ admin_user = User(
902
+ display_name=admin_name,
903
+ role=role_uuid,
904
+ created_at=now,
905
+ last_seen=None,
906
+ visits=0,
907
+ )
908
+ admin_user.uuid = user_uuid
909
+ _db.users[user_uuid] = admin_user
910
+
911
+ # Create reset token
912
+ _db.reset_tokens[reset_key] = ResetToken(
913
+ user=user_uuid,
914
+ expiry=reset_expiry,
915
+ token_type="admin bootstrap",
916
+ )
917
+
918
+ return reset_passphrase