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