paskia 0.7.1__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +12 -49
  3. paskia/bootstrap.py +30 -25
  4. paskia/db/__init__.py +163 -401
  5. paskia/db/background.py +128 -0
  6. paskia/db/jsonl.py +132 -0
  7. paskia/db/operations.py +1241 -0
  8. paskia/db/structs.py +148 -0
  9. paskia/fastapi/admin.py +456 -215
  10. paskia/fastapi/api.py +16 -15
  11. paskia/fastapi/authz.py +7 -2
  12. paskia/fastapi/mainapp.py +2 -1
  13. paskia/fastapi/remote.py +20 -20
  14. paskia/fastapi/reset.py +9 -10
  15. paskia/fastapi/user.py +10 -18
  16. paskia/fastapi/ws.py +22 -19
  17. paskia/frontend-build/auth/admin/index.html +3 -3
  18. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +8 -0
  19. paskia/frontend-build/auth/assets/admin-BeNu48FR.css +1 -0
  20. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +1 -0
  21. paskia/frontend-build/auth/assets/{auth-BU_O38k2.css → auth-BKX7shEe.css} +1 -1
  22. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +1 -0
  23. paskia/frontend-build/auth/index.html +3 -3
  24. paskia/globals.py +7 -10
  25. paskia/migrate/__init__.py +274 -0
  26. paskia/migrate/sql.py +381 -0
  27. paskia/util/permutil.py +16 -5
  28. paskia/util/sessionutil.py +3 -2
  29. paskia/util/userinfo.py +12 -26
  30. paskia-0.8.0.dist-info/METADATA +94 -0
  31. {paskia-0.7.1.dist-info → paskia-0.8.0.dist-info}/RECORD +33 -29
  32. {paskia-0.7.1.dist-info → paskia-0.8.0.dist-info}/entry_points.txt +1 -0
  33. paskia/db/sql.py +0 -1424
  34. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +0 -8
  35. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +0 -1
  36. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +0 -1
  37. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +0 -1
  38. paskia/util/tokens.py +0 -44
  39. paskia-0.7.1.dist-info/METADATA +0 -22
  40. {paskia-0.7.1.dist-info → paskia-0.8.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1241 @@
1
+ """
2
+ Database for WebAuthn passkey authentication.
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.
6
+ Write operations: Functions that validate and commit, or raise ValueError.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import os
13
+ 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
20
+ from uuid import UUID
21
+
22
+ import msgspec
23
+
24
+ from paskia.db.jsonl import (
25
+ DB_PATH_DEFAULT,
26
+ _ChangeRecord,
27
+ compute_diff,
28
+ create_change_record,
29
+ load_jsonl,
30
+ )
31
+ from paskia.db.structs import (
32
+ Credential,
33
+ Org,
34
+ Permission,
35
+ ResetToken,
36
+ Role,
37
+ Session,
38
+ SessionContext,
39
+ User,
40
+ _CredentialData,
41
+ _DatabaseData,
42
+ _OrgData,
43
+ _PermissionData,
44
+ _ResetTokenData,
45
+ _RoleData,
46
+ _SessionData,
47
+ _UserData,
48
+ )
49
+ from paskia.util.passphrase import is_well_formed as _is_passphrase
50
+
51
+ _logger = logging.getLogger(__name__)
52
+
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)
157
+ _db = DB()
158
+
159
+
160
+ async def init(*args, **kwargs):
161
+ """Load database and start background flush task."""
162
+ from paskia.db.background import start_background
163
+
164
+ db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
165
+ if db_path.startswith("json:"):
166
+ 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
+ )
254
+
255
+
256
+ # -------------------------------------------------------------------------
257
+ # Read/lookup functions
258
+ # -------------------------------------------------------------------------
259
+
260
+
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]:
355
+ """Get the organization a user belongs to and their role name.
356
+
357
+ Raises ValueError if user not found.
358
+ """
359
+ if isinstance(user_uuid, str):
360
+ user_uuid = UUID(user_uuid)
361
+ if user_uuid not in _db._data.users:
362
+ 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
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
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
+ ]
388
+
389
+
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
+
399
+
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
+ ]
410
+
411
+
412
+ def _reset_key(passphrase: str) -> bytes:
413
+ """Hash a passphrase to bytes for reset token storage."""
414
+ if not _is_passphrase(passphrase):
415
+ raise ValueError(
416
+ "Trying to reset with a session token in place of a passphrase"
417
+ if len(passphrase) == 16
418
+ else "Invalid passphrase format"
419
+ )
420
+ return hashlib.sha512(passphrase.encode()).digest()[:9]
421
+
422
+
423
+ 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.
443
+
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
450
+ """
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
+ )
521
+
522
+
523
+ # -------------------------------------------------------------------------
524
+ # Write operations (validate, modify, commit or raise ValueError)
525
+ # -------------------------------------------------------------------------
526
+
527
+
528
+ def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
529
+ """Create a new permission."""
530
+ if perm.uuid in _db._data.permissions:
531
+ 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
549
+
550
+
551
+ def rename_permission(
552
+ old_scope: str,
553
+ new_scope: str,
554
+ display_name: str,
555
+ domain: str | None = None,
556
+ *,
557
+ ctx: SessionContext | None = None,
558
+ ) -> None:
559
+ """Rename a permission's scope. The UUID remains the same.
560
+
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).
563
+ """
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
578
+
579
+
580
+ def delete_permission(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
581
+ """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:
585
+ raise ValueError(f"Permission {uuid} not found")
586
+ with _db.transaction("Deleted permission", ctx):
587
+ # Remove this permission from all roles
588
+ for role in _db._data.roles.values():
589
+ role.permissions.pop(uuid, None)
590
+ del _db._data.permissions[uuid]
591
+
592
+
593
+ def create_organization(org: Org, *, ctx: SessionContext | None = None) -> None:
594
+ """Create a new organization with an Administration role.
595
+
596
+ Automatically creates an 'Administration' role with auth:org:admin permission.
597
+ """
598
+ if org.uuid in _db._data.orgs:
599
+ 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
611
+ # Create Administration role with org admin permission
612
+ import uuid7
613
+
614
+ admin_role_uuid = uuid7.create()
615
+ # Find the auth:org:admin permission UUID
616
+ org_admin_perm_uuid = None
617
+ for pid, p in _db._data.permissions.items():
618
+ if p.scope == "auth:org:admin":
619
+ org_admin_perm_uuid = pid
620
+ break
621
+ 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,
624
+ display_name="Administration",
625
+ permissions=role_permissions,
626
+ )
627
+
628
+
629
+ def update_organization_name(
630
+ uuid: str | UUID,
631
+ display_name: str,
632
+ *,
633
+ ctx: SessionContext | None = None,
634
+ ) -> None:
635
+ """Update organization display name."""
636
+ if isinstance(uuid, str):
637
+ uuid = UUID(uuid)
638
+ if uuid not in _db._data.orgs:
639
+ raise ValueError(f"Organization {uuid} not found")
640
+ with _db.transaction("Renamed organization", ctx):
641
+ _db._data.orgs[uuid].display_name = display_name
642
+
643
+
644
+ def delete_organization(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
645
+ """Delete organization and all its roles/users."""
646
+ if isinstance(uuid, str):
647
+ uuid = UUID(uuid)
648
+ if uuid not in _db._data.orgs:
649
+ raise ValueError(f"Organization {uuid} not found")
650
+ with _db.transaction("Deleted organization", ctx):
651
+ # Remove org from all permissions
652
+ for p in _db._data.permissions.values():
653
+ 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,
668
+ *,
669
+ ctx: SessionContext | None = None,
670
+ ) -> None:
671
+ """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:
675
+ raise ValueError(f"Organization {org_uuid} not found")
676
+
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:
693
+ raise ValueError(f"Permission {permission_uuid} not found")
694
+
695
+ with _db.transaction("Granted org permission", ctx):
696
+ _db._data.permissions[permission_uuid].orgs[org_uuid] = True
697
+
698
+
699
+ def remove_permission_from_organization(
700
+ org_uuid: str | UUID,
701
+ permission_id: str | UUID,
702
+ *,
703
+ ctx: SessionContext | None = None,
704
+ ) -> None:
705
+ """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:
709
+ raise ValueError(f"Organization {org_uuid} not found")
710
+
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:
727
+ return # Permission not found, silently return
728
+
729
+ with _db.transaction("Revoked org permission", ctx):
730
+ _db._data.permissions[permission_uuid].orgs.pop(org_uuid, None)
731
+
732
+
733
+ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
734
+ """Create a new role."""
735
+ if role.uuid in _db._data.roles:
736
+ 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
+ )
745
+
746
+
747
+ def update_role_name(
748
+ uuid: str | UUID,
749
+ display_name: str,
750
+ *,
751
+ ctx: SessionContext | None = None,
752
+ ) -> None:
753
+ """Update role display name."""
754
+ if isinstance(uuid, str):
755
+ uuid = UUID(uuid)
756
+ if uuid not in _db._data.roles:
757
+ raise ValueError(f"Role {uuid} not found")
758
+ with _db.transaction("Renamed role", ctx):
759
+ _db._data.roles[uuid].display_name = display_name
760
+
761
+
762
+ def add_permission_to_role(
763
+ role_uuid: str | UUID,
764
+ permission_uuid: str | UUID,
765
+ *,
766
+ ctx: SessionContext | None = None,
767
+ ) -> None:
768
+ """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:
774
+ raise ValueError(f"Role {role_uuid} not found")
775
+ if permission_uuid not in _db._data.permissions:
776
+ 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
779
+
780
+
781
+ def remove_permission_from_role(
782
+ role_uuid: str | UUID,
783
+ permission_uuid: str | UUID,
784
+ *,
785
+ ctx: SessionContext | None = None,
786
+ ) -> None:
787
+ """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:
793
+ 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)
796
+
797
+
798
+ def delete_role(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
799
+ """Delete a role."""
800
+ if isinstance(uuid, str):
801
+ uuid = UUID(uuid)
802
+ if uuid not in _db._data.roles:
803
+ raise ValueError(f"Role {uuid} not found")
804
+ # Check no users have this role
805
+ if any(u.role == uuid for u in _db._data.users.values()):
806
+ raise ValueError(f"Cannot delete role {uuid}: users still assigned")
807
+ with _db.transaction("Deleted role", ctx):
808
+ del _db._data.roles[uuid]
809
+
810
+
811
+ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
812
+ """Create a new user."""
813
+ if new_user.uuid in _db._data.users:
814
+ 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
+ )
825
+
826
+
827
+ def update_user_display_name(
828
+ uuid: str | UUID,
829
+ display_name: str,
830
+ *,
831
+ ctx: SessionContext | None = None,
832
+ ) -> None:
833
+ """Update user display name.
834
+
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.
837
+ """
838
+ if isinstance(uuid, str):
839
+ uuid = UUID(uuid)
840
+ if uuid not in _db._data.users:
841
+ 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
846
+
847
+
848
+ def update_user_role(
849
+ uuid: str | UUID,
850
+ role_uuid: str | UUID,
851
+ *,
852
+ ctx: SessionContext | None = None,
853
+ ) -> None:
854
+ """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:
860
+ raise ValueError(f"User {uuid} not found")
861
+ if role_uuid not in _db._data.roles:
862
+ raise ValueError(f"Role {role_uuid} not found")
863
+ with _db.transaction("Changed user role", ctx):
864
+ _db._data.users[uuid].role = role_uuid
865
+
866
+
867
+ def update_user_role_in_organization(
868
+ user_uuid: str | UUID,
869
+ role_name: str,
870
+ *,
871
+ ctx: SessionContext | None = None,
872
+ ) -> None:
873
+ """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:
877
+ 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
882
+ # Find role by name in the same org
883
+ 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
887
+ break
888
+ if new_role_uuid is None:
889
+ 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
892
+
893
+
894
+ def delete_user(uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
895
+ """Delete user and their credentials/sessions."""
896
+ if isinstance(uuid, str):
897
+ uuid = UUID(uuid)
898
+ if uuid not in _db._data.users:
899
+ raise ValueError(f"User {uuid} not found")
900
+ with _db.transaction("Deleted user", ctx):
901
+ # 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]
905
+ # 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]
909
+ # 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]
914
+
915
+
916
+ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
917
+ """Create a new credential."""
918
+ if cred.uuid in _db._data.credentials:
919
+ 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
+ )
933
+
934
+
935
+ def update_credential_sign_count(
936
+ uuid: str | UUID,
937
+ sign_count: int,
938
+ last_used: datetime | None = None,
939
+ *,
940
+ ctx: SessionContext | None = None,
941
+ ) -> None:
942
+ """Update credential sign count and last_used."""
943
+ if isinstance(uuid, str):
944
+ uuid = UUID(uuid)
945
+ if uuid not in _db._data.credentials:
946
+ raise ValueError(f"Credential {uuid} not found")
947
+ with _db.transaction("Updated credential", ctx):
948
+ _db._data.credentials[uuid].sign_count = sign_count
949
+ if last_used:
950
+ _db._data.credentials[uuid].last_used = last_used
951
+
952
+
953
+ def delete_credential(
954
+ uuid: str | UUID,
955
+ user_uuid: str | UUID | None = None,
956
+ *,
957
+ ctx: SessionContext | None = None,
958
+ ) -> None:
959
+ """Delete a credential.
960
+
961
+ If user_uuid is provided, validates that the credential belongs to that user.
962
+ """
963
+ if isinstance(uuid, str):
964
+ uuid = UUID(uuid)
965
+ if uuid not in _db._data.credentials:
966
+ raise ValueError(f"Credential {uuid} not found")
967
+ 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:
972
+ 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]
975
+
976
+
977
+ def create_session(
978
+ key: str,
979
+ user_uuid: UUID,
980
+ credential_uuid: UUID,
981
+ host: str | None,
982
+ ip: str | None,
983
+ user_agent: str | None,
984
+ expiry: datetime,
985
+ *,
986
+ 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:
992
+ raise ValueError(f"User {user_uuid} not found")
993
+ if credential_uuid not in _db._data.credentials:
994
+ 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
+ )
1004
+
1005
+
1006
+ def update_session(
1007
+ key: str,
1008
+ host: str | None = None,
1009
+ ip: str | None = None,
1010
+ user_agent: str | None = None,
1011
+ expiry: datetime | None = None,
1012
+ *,
1013
+ ctx: SessionContext | None = None,
1014
+ ) -> None:
1015
+ """Update session metadata."""
1016
+ if key not in _db._data.sessions:
1017
+ raise ValueError("Session not found")
1018
+ with _db.transaction("Updated session", ctx):
1019
+ s = _db._data.sessions[key]
1020
+ if host is not None:
1021
+ s.host = host
1022
+ if ip is not None:
1023
+ s.ip = ip
1024
+ if user_agent is not None:
1025
+ s.user_agent = user_agent
1026
+ if expiry is not None:
1027
+ s.expiry = expiry
1028
+
1029
+
1030
+ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
1031
+ """Set the host for a session (first-time binding)."""
1032
+ update_session(key, host=host, ctx=ctx)
1033
+
1034
+
1035
+ def delete_session(key: str, *, ctx: SessionContext | None = None) -> None:
1036
+ """Delete a session.
1037
+
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.
1040
+ """
1041
+ if key not in _db._data.sessions:
1042
+ 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]
1047
+
1048
+
1049
+ def delete_sessions_for_user(
1050
+ user_uuid: str | UUID, *, ctx: SessionContext | None = None
1051
+ ) -> None:
1052
+ """Delete all sessions for a user.
1053
+
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.
1056
+ """
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]
1065
+
1066
+
1067
+ def create_reset_token(
1068
+ passphrase: str,
1069
+ user_uuid: UUID,
1070
+ expiry: datetime,
1071
+ token_type: str,
1072
+ *,
1073
+ ctx: SessionContext | None = None,
1074
+ ) -> None:
1075
+ """Create a reset token from a passphrase.
1076
+
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.
1079
+ """
1080
+ key = _reset_key(passphrase)
1081
+ if key in _db._data.reset_tokens:
1082
+ raise ValueError("Reset token already exists")
1083
+ if user_uuid not in _db._data.users:
1084
+ 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
1090
+ )
1091
+
1092
+
1093
+ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
1094
+ """Delete a reset token."""
1095
+ if key not in _db._data.reset_tokens:
1096
+ raise ValueError("Reset token not found")
1097
+ with _db.transaction("Deleted reset token", ctx):
1098
+ del _db._data.reset_tokens[key]
1099
+
1100
+
1101
+ # -------------------------------------------------------------------------
1102
+ # Cleanup (called by background task)
1103
+ # -------------------------------------------------------------------------
1104
+
1105
+
1106
+ def cleanup_expired() -> int:
1107
+ """Remove expired sessions and reset tokens. Returns count removed."""
1108
+ now = datetime.now(timezone.utc)
1109
+ 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]
1112
+ for k in expired_sessions:
1113
+ del _db._data.sessions[k]
1114
+ count += 1
1115
+ expired_tokens = [
1116
+ k for k, t in _db._data.reset_tokens.items() if t.expiry < now
1117
+ ]
1118
+ for k in expired_tokens:
1119
+ del _db._data.reset_tokens[k]
1120
+ count += 1
1121
+ return count
1122
+
1123
+
1124
+ # -------------------------------------------------------------------------
1125
+ # Composite operations (used by app code)
1126
+ # -------------------------------------------------------------------------
1127
+
1128
+
1129
+ def _create_token() -> str:
1130
+ """Generate a 16-character URL-safe session token."""
1131
+ return secrets.token_urlsafe(12)
1132
+
1133
+
1134
+ def login(
1135
+ user_uuid: str | UUID,
1136
+ credential: Credential,
1137
+ host: str | None,
1138
+ ip: str | None,
1139
+ user_agent: str | None,
1140
+ expiry: datetime,
1141
+ ) -> str:
1142
+ """Update user/credential on login and create session in a single transaction.
1143
+
1144
+ Updates:
1145
+ - user.last_seen, user.visits
1146
+ - credential.sign_count, credential.last_used
1147
+ Creates:
1148
+ - new session
1149
+
1150
+ Returns the generated session token.
1151
+ """
1152
+ if isinstance(user_uuid, str):
1153
+ user_uuid = UUID(user_uuid)
1154
+ now = datetime.now(timezone.utc)
1155
+ if user_uuid not in _db._data.users:
1156
+ 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")
1159
+
1160
+ session_key = _create_token()
1161
+ user_str = str(user_uuid)
1162
+ with _db.transaction("User logged in", user=user_str):
1163
+ # Update user
1164
+ _db._data.users[user_uuid].last_seen = now
1165
+ _db._data.users[user_uuid].visits += 1
1166
+ # Update credential
1167
+ _db._data.credentials[credential.uuid].sign_count = credential.sign_count
1168
+ _db._data.credentials[credential.uuid].last_used = now
1169
+ # 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
1179
+
1180
+
1181
+ def create_credential_session(
1182
+ user_uuid: UUID,
1183
+ credential: Credential,
1184
+ host: str | None,
1185
+ ip: str | None,
1186
+ user_agent: str | None,
1187
+ display_name: str | None = None,
1188
+ reset_key: bytes | None = None,
1189
+ ) -> str:
1190
+ """Create a credential and session together, optionally consuming a reset token.
1191
+
1192
+ Used during registration to atomically:
1193
+ 1. Update user display_name if provided
1194
+ 2. Create the credential
1195
+ 3. Create the session
1196
+ 4. Delete the reset token if provided
1197
+
1198
+ Returns the generated session token.
1199
+ """
1200
+ from paskia.config import SESSION_LIFETIME
1201
+
1202
+ now = datetime.now(timezone.utc)
1203
+ expiry = now + SESSION_LIFETIME
1204
+ session_key = _create_token()
1205
+
1206
+ if user_uuid not in _db._data.users:
1207
+ raise ValueError(f"User {user_uuid} not found")
1208
+
1209
+ user_str = str(user_uuid)
1210
+ with _db.transaction("Registered credential", user=user_str):
1211
+ # Update display name if provided
1212
+ if display_name:
1213
+ _db._data.users[user_uuid].display_name = display_name
1214
+
1215
+ # 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
+ )
1226
+
1227
+ # 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
+ )
1236
+
1237
+ # Delete reset token if provided
1238
+ if reset_key:
1239
+ if reset_key in _db._data.reset_tokens:
1240
+ del _db._data.reset_tokens[reset_key]
1241
+ return session_key