diff --git a/paskia/authsession.py b/paskia/authsession.py index 32033ac..ad68580 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -55,7 +55,7 @@ async def create_session( raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") token = create_token() now = datetime.now(timezone.utc) - await db.create_session( + db.create_session( user_uuid=user_uuid, credential_uuid=credential_uuid, key=session_key(token), @@ -69,7 +69,7 @@ async def create_session( async def get_reset(token: str) -> ResetToken: """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token).""" - record = await db.get_reset_token(reset_key(token)) + record = db.get_reset_token(reset_key(token)) if record and record.expiry >= datetime.now(timezone.utc): return record raise ValueError("This authentication link is no longer valid.") @@ -80,11 +80,11 @@ async def get_session(token: str, host: str | None = None) -> Session: host = hostutil.normalize_host(host) if not host: raise ValueError("Invalid host") - session = await db.get_session(session_key(token)) + session = db.get_session(session_key(token)) if session and session_expiry(session) >= datetime.now(timezone.utc): if session.host is None: # First time binding: store exact host:port (or IPv6 form) now. - await db.set_session_host(session.key, host) + db.set_session_host(session.key, host) session.host = host elif session.host != host: raise ValueError("Session host mismatch") @@ -94,10 +94,10 @@ async def get_session(token: str, host: str | None = None) -> Session: async def refresh_session_token(token: str, *, ip: str, user_agent: str): """Refresh a session extending its expiry.""" - session_record = await db.get_session(session_key(token)) + session_record = db.get_session(session_key(token)) if not session_record: raise ValueError("Session not found or expired") - updated = await db.update_session( + updated = db.update_session( session_key(token), ip=ip, user_agent=user_agent, @@ -110,4 +110,4 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str): async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None): """Delete a specific credential for the current user.""" s = await get_session(auth, host=host) - await db.delete_credential(credential_uuid, s.user_uuid) + db.delete_credential(credential_uuid, s.user_uuid) diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index e8bc16c..458ac8b 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -42,7 +42,7 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> """Create an admin reset link and log it with the provided message.""" token = passphrase.generate() expiry = authsession.reset_expires() - await db.create_reset_token( + db.create_reset_token( user_uuid=user_uuid, key=tokens.reset_key(token), expiry=expiry, @@ -62,14 +62,14 @@ async def bootstrap_system() -> dict: """ # Create permission first - will fail if already exists perm0 = Permission(id="auth:admin", display_name="Master Admin") - await db.create_permission(perm0) + db.create_permission(perm0) org = Org(uuid7.create(), "Organization") - await db.create_organization(org) + db.create_organization(org) # After creation, org.permissions now includes the auto-created org admin permission # Allow this org to grant global admin explicitly - await db.add_permission_to_organization(str(org.uuid), perm0.id) + db.add_permission_to_organization(str(org.uuid), perm0.id) # Create an Administration role granting both org and global admin # Compose permissions for Administration role: global admin + org admin auto-perm @@ -79,7 +79,7 @@ async def bootstrap_system() -> dict: "Administration", permissions=[perm0.id, *org.permissions], ) - await db.create_role(role) + db.create_role(role) user = User( uuid=uuid7.create(), @@ -88,7 +88,7 @@ async def bootstrap_system() -> dict: created_at=datetime.now(timezone.utc), visits=0, ) - await db.create_user(user) + db.create_user(user) # Generate reset link and log it reset_link = await _create_and_log_admin_reset_link( @@ -116,7 +116,7 @@ async def check_admin_credentials() -> bool: """ try: # Get permission organizations to find admin users - permission_orgs = await db.get_permission_organizations( + permission_orgs = db.get_permission_organizations( "auth:admin" ) @@ -124,7 +124,7 @@ async def check_admin_credentials() -> bool: return False # Get users from the first organization with admin permission - org_users = await db.get_organization_users( + org_users = db.get_organization_users( str(permission_orgs[0].uuid) ) admin_users = [user for user, role in org_users if role == "Administration"] @@ -134,7 +134,7 @@ async def check_admin_credentials() -> bool: # Check first admin user for credentials admin_user = admin_users[0] - credentials = await db.get_credentials_by_user_uuid( + credentials = db.get_credentials_by_user_uuid( admin_user.uuid ) @@ -162,7 +162,7 @@ async def bootstrap_if_needed() -> bool: """ try: # Check if the admin permission exists - if it does, system is already bootstrapped - await db.get_permission("auth:admin") + db.get_permission("auth:admin") # Permission exists, system is already bootstrapped # Check if admin needs credentials (only for already-bootstrapped systems) await check_admin_credentials() diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 95d7e59..dd706ae 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -3,13 +3,14 @@ Database module for WebAuthn passkey authentication. This module re-exports the JSONL database types and implementation. All data types are msgspec Structs for efficient serialization. +Database methods are synchronous (no await needed). Usage: from paskia import db # Access the database instance (after init) - await db.create_session(...) - user = await db.get_user_by_uuid(uuid) + db.create_session(...) + user = db.get_user_by_uuid(uuid) """ from paskia.db.json import ( @@ -23,8 +24,11 @@ from paskia.db.json import ( SessionContext, User, init, + start_background, + stop_background, + start_cleanup, + stop_cleanup, ) -from paskia.db.json import _db as _json_db import paskia.db.json as _json_module @@ -63,4 +67,8 @@ __all__ = [ "SessionContext", "User", "init", + "start_background", + "stop_background", + "start_cleanup", + "stop_cleanup", ] diff --git a/paskia/db/json.py b/paskia/db/json.py index 7d9ec5f..136c6aa 100644 --- a/paskia/db/json.py +++ b/paskia/db/json.py @@ -1,16 +1,21 @@ """ -Async JSON database implementation for WebAuthn passkey authentication. +JSON database implementation for WebAuthn passkey authentication. This module provides a JSON file-based database layer that maintains all data in memory and persists changes to disk as JSONL. Uses object keys by UUID instead of lists for efficient lookups. All public data types are msgspec Structs for efficient serialization. +Database methods are synchronous since all data is in memory. +A background task periodically flushes queued changes to disk. """ import asyncio +import logging import os -from contextlib import asynccontextmanager +import threading +from collections import deque +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -24,6 +29,11 @@ from paskia.config import SESSION_LIFETIME DB_PATH_DEFAULT = "paskia.jsonl" +# Flush changes to disk every N seconds +FLUSH_INTERVAL = 5 +# Cleanup expired items every N seconds (cheap when nothing to remove) +CLEANUP_INTERVAL = 1 + # ------------------------------------------------------------------------- # Public data types (msgspec Structs) @@ -215,6 +225,9 @@ def _str_to_bytes(s: str | None) -> bytes | None: # Global database instance (set by init()) _db: "DB | None" = None +_background_task: asyncio.Task | None = None + +_logger = logging.getLogger(__name__) def get_db() -> "DB": @@ -224,22 +237,81 @@ def get_db() -> "DB": return _db +async def _background_loop(): + """Background task that periodically flushes changes and cleans up.""" + # Run cleanup immediately on startup to clear old expired items + if _db is not None: + _db.cleanup() + _db.flush() + + last_cleanup = datetime.now(timezone.utc) + + while True: + try: + await asyncio.sleep(FLUSH_INTERVAL) + if _db is not None: + # Flush pending changes to disk + _db.flush() + + # Run cleanup less frequently + now = datetime.now(timezone.utc) + if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL: + _db.cleanup() + _db.flush() # Flush cleanup changes + last_cleanup = now + except asyncio.CancelledError: + # Final flush before exit + if _db is not None: + _db.flush() + break + except Exception: + _logger.exception("Error in database background loop") + + +async def start_background(): + """Start the background flush/cleanup task.""" + global _background_task + if _background_task is None: + _background_task = asyncio.create_task(_background_loop()) + + +async def stop_background(): + """Stop the background task and flush any pending changes.""" + global _background_task + if _background_task: + _background_task.cancel() + try: + await _background_task + except asyncio.CancelledError: + pass + _background_task = None + + +# Aliases for backwards compatibility +start_cleanup = start_background +stop_cleanup = stop_background + + async def init(*args, **kwargs): - """Initialize the global database instance.""" + """Initialize the global database instance and start background task.""" global _db db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) # Remove any prefix (for compatibility with SQL-style URIs) if db_path.startswith("json:"): db_path = db_path[5:] _db = DB(db_path) - await _db.init_db() + _db.load() + await start_background() class DB: """JSON-based database implementation. - Maintains data in memory and persists to disk on every change. - Uses nested dictionaries keyed by UUID strings for efficient lookup. + All methods are synchronous since data is maintained in memory. + Changes are queued and periodically flushed to disk by a background task. + Each change records the actor (user UUID or system identifier). + + Thread-safety: Uses a lock for concurrent access to the data structure. Data structure: { @@ -258,7 +330,9 @@ class DB: self.db_path = Path(db_path) self._data: _DatabaseData | None = None self._previous_builtins: dict[str, Any] = {} # For diffing (JSON-compatible) - self._lock = asyncio.Lock() + self._pending_changes: deque[_ChangeRecord] = deque() + self._lock = threading.RLock() # Reentrant for nested calls + self._current_actor: str = "system" # Default actor for changes def _empty_data(self) -> _DatabaseData: """Return an empty database structure.""" @@ -272,40 +346,41 @@ class DB: reset_tokens={}, ) - async def _load(self) -> None: + def load(self) -> None: """Load data from disk by applying change log. Replays all changes from JSONL file using plain dicts (to handle schema evolution), then validates the final state against msgspec structs which become the working copy with proper datetime types. """ - data_dict = msgspec.to_builtins(self._empty_data()) - if self.db_path.exists(): - try: - # Read JSONL file line by line and apply diffs - with open(self.db_path, encoding="utf-8") as f: - for line_num, line in enumerate(f, 1): - line = line.strip() - if not line: - continue - try: - change = msgspec.json.decode(line.encode("utf-8")) - # Apply the diff to current state (marshal=True for $-prefixed keys) - data_dict = jsondiff.patch( - data_dict, change["diff"], marshal=True - ) - except Exception as e: - raise ValueError(f"Error parsing line {line_num}: {e}") - except (OSError, ValueError, msgspec.DecodeError) as e: - raise ValueError(f"Failed to load database: {e}") + with self._lock: + data_dict = msgspec.to_builtins(self._empty_data()) + if self.db_path.exists(): + try: + # Read JSONL file line by line and apply diffs + with open(self.db_path, encoding="utf-8") as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + change = msgspec.json.decode(line.encode("utf-8")) + # Apply the diff to current state (marshal=True for $-prefixed keys) + data_dict = jsondiff.patch( + data_dict, change["diff"], marshal=True + ) + except Exception as e: + raise ValueError(f"Error parsing line {line_num}: {e}") + except (OSError, ValueError, msgspec.DecodeError) as e: + raise ValueError(f"Failed to load database: {e}") - # Validate and convert to msgspec struct (datetime strings -> datetime objects) - self._data = _json_decoder.decode(_json_encoder.encode(data_dict)) - # Store builtins representation for diffing (to_builtins creates a copy) - self._previous_builtins = msgspec.to_builtins(self._data) + # Validate and convert to msgspec struct (datetime strings -> datetime objects) + self._data = _json_decoder.decode(_json_encoder.encode(data_dict)) + # Store builtins representation for diffing (to_builtins creates a copy) + self._previous_builtins = msgspec.to_builtins(self._data) - async def _save(self, actor: str = "system") -> None: - """Append change record to JSONL file.""" + def _queue_change(self) -> None: + """Queue a change record for later flush. Must hold lock.""" if self._data is None: return # Convert current struct to builtins for diffing (datetime->str, bytes->base64) @@ -314,75 +389,152 @@ class DB: # Calculate diff between previous and current state (marshal=True for JSON-serializable keys) diff = jsondiff.diff(self._previous_builtins, current_builtins, marshal=True) - # Only save if there are changes + # Only queue if there are changes if diff: change_record = _ChangeRecord( ts=datetime.now(timezone.utc), - actor=actor, + actor=self._current_actor, diff=diff, ) + self._pending_changes.append(change_record) + # Update previous builtins for next diff + self._previous_builtins = current_builtins - # Encode and append to file - data = _json_encoder.encode(change_record) - line = data.decode("utf-8") + "\n" + def flush(self) -> None: + """Write all pending changes to disk.""" + with self._lock: + if not self._pending_changes: + return - # Append atomically (create temp file, then append) + # Collect all pending changes + changes_to_write = list(self._pending_changes) + self._pending_changes.clear() + + # Write outside the lock to avoid blocking other operations + try: + # Build lines to append + lines = [] + for change in changes_to_write: + data = _json_encoder.encode(change) + lines.append(data.decode("utf-8")) + + # Read existing content and append + existing_content = "" + if self.db_path.exists(): + existing_content = self.db_path.read_text("utf-8") + + new_content = existing_content + "\n".join(lines) + "\n" + + # Write atomically via temp file tmp_path = self.db_path.with_suffix(".tmp") + tmp_path.write_text(new_content, "utf-8") + tmp_path.replace(self.db_path) + except OSError: + _logger.exception("Failed to flush database changes") + # Re-queue the changes on failure + with self._lock: + for change in reversed(changes_to_write): + self._pending_changes.appendleft(change) + + @contextmanager + def session(self, actor: str = "system"): + """Context manager for atomic operations with change queued on exit.""" + with self._lock: + old_actor = self._current_actor + self._current_actor = actor try: - # Read existing content - existing_content = "" - if self.db_path.exists(): - existing_content = await asyncio.to_thread( - self.db_path.read_text, "utf-8" - ) + yield + self._queue_change() + finally: + self._current_actor = old_actor - # Append new line - new_content = existing_content + line + # ------------------------------------------------------------------------- + # Internal helpers (caller must hold lock) + # ------------------------------------------------------------------------- - # Write to temp file and rename - await asyncio.to_thread(tmp_path.write_text, new_content, "utf-8") - await asyncio.to_thread(tmp_path.replace, self.db_path) + def _build_user(self, user_uuid: str) -> User: + """Build a User object from internal storage. Caller must hold lock.""" + u = self._data.users[user_uuid] + return User( + uuid=UUID(user_uuid), + display_name=u.display_name, + role_uuid=UUID(u.role), + created_at=u.created_at, + last_seen=u.last_seen, + visits=u.visits, + ) - # Update previous builtins for next diff (to_builtins creates a copy) - self._previous_builtins = current_builtins - except OSError: - # Clean up temp file on error - if tmp_path.exists(): - await asyncio.to_thread(tmp_path.unlink) + def _build_role(self, role_uuid: str) -> Role: + """Build a Role object from internal storage. Caller must hold lock.""" + r = self._data.roles[role_uuid] + return Role( + uuid=UUID(role_uuid), + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions), + ) - @asynccontextmanager - async def session(self): - """Context manager for atomic operations with save on exit.""" - async with self._lock: - yield - await self._save() + def _build_org(self, org_uuid: str, include_roles: bool = False) -> Org: + """Build an Org object from internal storage. Caller must hold lock.""" + o = self._data.orgs[org_uuid] + # Get permissions this org can grant + perm_ids = [ + pid for pid, p in self._data.permissions.items() if org_uuid in p.orgs + ] + org = Org( + uuid=UUID(org_uuid), + display_name=o.display_name, + permissions=perm_ids, + ) + if include_roles: + org.roles = [ + self._build_role(role_uuid) + for role_uuid, r in self._data.roles.items() + if r.org == org_uuid + ] + return org - async def init_db(self) -> None: - """Initialize database (load from disk).""" - async with self._lock: - await self._load() + def _build_credential(self, cred_uuid: str) -> Credential: + """Build a Credential object from internal storage. Caller must hold lock.""" + c = self._data.credentials[cred_uuid] + return Credential( + uuid=UUID(cred_uuid), + credential_id=c.credential_id, + user_uuid=UUID(c.user), + aaguid=UUID(c.aaguid), + public_key=c.public_key, + sign_count=c.sign_count, + created_at=c.created_at, + last_used=c.last_used, + last_verified=c.last_verified, + ) + + def _build_session(self, sess_key_b64: str) -> Session: + """Build a Session object from internal storage. Caller must hold lock.""" + s = self._data.sessions[sess_key_b64] + return Session( + key=_str_to_bytes(sess_key_b64), # type: ignore[arg-type] + user_uuid=UUID(s.user), + credential_uuid=UUID(s.credential), + host=s.host, + ip=s.ip, + user_agent=s.user_agent, + renewed=s.renewed, + ) # ------------------------------------------------------------------------- # User operations # ------------------------------------------------------------------------- - async def get_user_by_uuid(self, user_uuid: UUID) -> User: - async with self._lock: + def get_user_by_uuid(self, user_uuid: UUID) -> User: + with self._lock: key = str(user_uuid) if key not in self._data.users: raise ValueError("User not found") - u = self._data.users[key] - return User( - uuid=user_uuid, # Use the key directly - display_name=u.display_name, - role_uuid=UUID(u.role), - created_at=u.created_at, - last_seen=u.last_seen, - visits=u.visits, - ) + return self._build_user(key) - async def create_user(self, user: User) -> None: - async with self.session(): + def create_user(self, user: User, actor: str = "system") -> None: + with self.session(actor): key = str(user.uuid) self._data.users[key] = _UserData( display_name=user.display_name, @@ -392,10 +544,10 @@ class DB: visits=user.visits, ) - async def update_user_display_name( - self, user_uuid: UUID, display_name: str + def update_user_display_name( + self, user_uuid: UUID, display_name: str, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): key = str(user_uuid) if key not in self._data.users: raise ValueError("User not found") @@ -405,8 +557,8 @@ class DB: # Role operations # ------------------------------------------------------------------------- - async def create_role(self, role: Role) -> None: - async with self.session(): + def create_role(self, role: Role, actor: str = "system") -> None: + with self.session(actor): key = str(role.uuid) self._data.roles[key] = _RoleData( org=str(role.org_uuid), @@ -416,8 +568,8 @@ class DB: else {}, ) - async def update_role(self, role: Role) -> None: - async with self.session(): + def update_role(self, role: Role, actor: str = "system") -> None: + with self.session(actor): key = str(role.uuid) if key not in self._data.roles: raise ValueError("Role not found") @@ -426,8 +578,8 @@ class DB: {p: True for p in role.permissions} if role.permissions else {} ) - async def delete_role(self, role_uuid: UUID) -> None: - async with self.session(): + def delete_role(self, role_uuid: UUID, actor: str = "system") -> None: + with self.session(actor): key = str(role_uuid) # Check for users with this role for u in self._data.users.values(): @@ -436,25 +588,19 @@ class DB: if key in self._data.roles: del self._data.roles[key] - async def get_role(self, role_uuid: UUID) -> Role: - async with self._lock: + def get_role(self, role_uuid: UUID) -> Role: + with self._lock: key = str(role_uuid) if key not in self._data.roles: raise ValueError("Role not found") - r = self._data.roles[key] - return Role( - uuid=role_uuid, # Use the key directly - org_uuid=UUID(r.org), - display_name=r.display_name, - permissions=list(r.permissions), - ) + return self._build_role(key) # ------------------------------------------------------------------------- # Credential operations # ------------------------------------------------------------------------- - async def create_credential(self, credential: Credential) -> None: - async with self.session(): + def create_credential(self, credential: Credential, actor: str = "system") -> None: + with self.session(actor): key = str(credential.uuid) self._data.credentials[key] = _CredentialData( credential_id=credential.credential_id, # Store bytes directly @@ -467,25 +613,15 @@ class DB: last_verified=credential.last_verified, ) - async def get_credential_by_id(self, credential_id: bytes) -> Credential: - async with self._lock: + def get_credential_by_id(self, credential_id: bytes) -> Credential: + with self._lock: for key, c in self._data.credentials.items(): if c.credential_id == credential_id: - return Credential( - uuid=UUID(key), # Use the key directly - credential_id=c.credential_id, # Already bytes - user_uuid=UUID(c.user), - aaguid=UUID(c.aaguid), - public_key=c.public_key, # Already bytes - sign_count=c.sign_count, - created_at=c.created_at, # Already datetime - last_used=c.last_used, - last_verified=c.last_verified, - ) + return self._build_credential(key) raise ValueError("Credential not found") - async def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]: - async with self._lock: + def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]: + with self._lock: user_key = str(user_uuid) result: list[bytes] = [] for c in self._data.credentials.values(): @@ -495,8 +631,8 @@ class DB: result.append(cred_id) return result - async def update_credential(self, credential: Credential) -> None: - async with self.session(): + def update_credential(self, credential: Credential, actor: str = "system") -> None: + with self.session(actor): for key, c in self._data.credentials.items(): if c.credential_id == credential.credential_id: c.sign_count = credential.sign_count @@ -506,8 +642,8 @@ class DB: return raise ValueError("Credential not found") - async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None: - async with self.session(): + def delete_credential(self, uuid: UUID, user_uuid: UUID, actor: str = "system") -> None: + with self.session(actor): key = str(uuid) if key not in self._data.credentials: return @@ -520,7 +656,7 @@ class DB: # Session operations # ------------------------------------------------------------------------- - async def create_session( + def create_session( self, user_uuid: UUID, key: bytes, @@ -529,8 +665,9 @@ class DB: ip: str, user_agent: str, renewed: datetime, + actor: str = "system", ) -> None: - async with self.session(): + with self.session(actor): key_b64 = _bytes_to_str(key) self._data.sessions[key_b64] = _SessionData( user=str(user_uuid), @@ -541,37 +678,29 @@ class DB: renewed=renewed, ) - async def get_session(self, key: bytes) -> Session | None: - async with self._lock: + def get_session(self, key: bytes) -> Session | None: + with self._lock: key_b64 = _bytes_to_str(key) if key_b64 not in self._data.sessions: return None - s = self._data.sessions[key_b64] - return Session( - key=_str_to_bytes(key_b64), # type: ignore[arg-type] - user_uuid=UUID(s.user), - credential_uuid=UUID(s.credential), - host=s.host, - ip=s.ip, - user_agent=s.user_agent, - renewed=s.renewed, # Already datetime - ) + return self._build_session(key_b64) - async def delete_session(self, key: bytes) -> None: - async with self.session(): + def delete_session(self, key: bytes, actor: str = "system") -> None: + with self.session(actor): key_b64 = _bytes_to_str(key) if key_b64 in self._data.sessions: del self._data.sessions[key_b64] - async def update_session( + def update_session( self, key: bytes, *, ip: str, user_agent: str, renewed: datetime, + actor: str = "system", ) -> Session | None: - async with self.session(): + with self.session(actor): key_b64 = _bytes_to_str(key) if key_b64 not in self._data.sessions: return None @@ -579,49 +708,31 @@ class DB: s.ip = ip s.user_agent = user_agent s.renewed = renewed - return Session( - key=_str_to_bytes(key_b64), # type: ignore[arg-type] - user_uuid=UUID(s.user), - credential_uuid=UUID(s.credential), - host=s.host, - ip=s.ip, - user_agent=s.user_agent, - renewed=s.renewed, # Already datetime - ) + return self._build_session(key_b64) - async def set_session_host(self, key: bytes, host: str) -> None: - async with self.session(): + def set_session_host(self, key: bytes, host: str, actor: str = "system") -> None: + with self.session(actor): key_b64 = _bytes_to_str(key) if key_b64 in self._data.sessions: s = self._data.sessions[key_b64] if s.host is None: s.host = host - async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: - async with self._lock: + def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: + with self._lock: user_key = str(user_uuid) sessions = [] for key_b64, s in self._data.sessions.items(): if s.user == user_key: key_bytes = _str_to_bytes(key_b64) if key_bytes and key_bytes.startswith(b"sess"): - sessions.append( - Session( - key=key_bytes, - user_uuid=UUID(s.user), - credential_uuid=UUID(s.credential), - host=s.host, - ip=s.ip, - user_agent=s.user_agent, - renewed=s.renewed, # Already datetime - ) - ) + sessions.append(self._build_session(key_b64)) # Sort by renewed desc sessions.sort(key=lambda x: x.renewed, reverse=True) return sessions - async def delete_sessions_for_user(self, user_uuid: UUID) -> None: - async with self.session(): + def delete_sessions_for_user(self, user_uuid: UUID, actor: str = "system") -> None: + with self.session(actor): user_key = str(user_uuid) to_delete = [ k for k, s in self._data.sessions.items() if s.user == user_key @@ -633,14 +744,15 @@ class DB: # Reset token operations # ------------------------------------------------------------------------- - async def create_reset_token( + def create_reset_token( self, user_uuid: UUID, key: bytes, expiry: datetime, token_type: str, + actor: str = "system", ) -> None: - async with self.session(): + with self.session(actor): key_b64 = _bytes_to_str(key) self._data.reset_tokens[key_b64] = _ResetTokenData( user=str(user_uuid), @@ -648,8 +760,8 @@ class DB: token_type=token_type, ) - async def get_reset_token(self, key: bytes) -> ResetToken | None: - async with self._lock: + def get_reset_token(self, key: bytes) -> ResetToken | None: + with self._lock: key_b64 = _bytes_to_str(key) if key_b64 not in self._data.reset_tokens: return None @@ -661,8 +773,8 @@ class DB: token_type=t.token_type, ) - async def delete_reset_token(self, key: bytes) -> None: - async with self.session(): + def delete_reset_token(self, key: bytes, actor: str = "system") -> None: + with self.session(actor): key_b64 = _bytes_to_str(key) if key_b64 in self._data.reset_tokens: del self._data.reset_tokens[key_b64] @@ -671,8 +783,8 @@ class DB: # Organization operations # ------------------------------------------------------------------------- - async def create_organization(self, org: Org) -> None: - async with self.session(): + def create_organization(self, org: Org, actor: str = "system") -> None: + with self.session(actor): key = str(org.uuid) self._data.orgs[key] = _OrgData( display_name=org.display_name, @@ -697,69 +809,21 @@ class DB: if auto_perm_id not in org.permissions: org.permissions.append(auto_perm_id) - async def get_organization(self, org_id: str) -> Org: - async with self._lock: - # org_id is a UUID string + def get_organization(self, org_id: str) -> Org: + with self._lock: if org_id not in self._data.orgs: raise ValueError("Organization not found") - o = self._data.orgs[org_id] - # Get permissions that this org can grant - permissions = [] - for perm_id, p in self._data.permissions.items(): - if org_id in p.orgs: - permissions.append(perm_id) - org = Org( - uuid=UUID(org_id), # Use the key directly - display_name=o.display_name, - permissions=permissions, - ) - # Load roles for this org - roles = [] - for role_uuid_str, r in self._data.roles.items(): - if r.org == org_id: - roles.append( - Role( - uuid=UUID(role_uuid_str), # Use the key directly - org_uuid=UUID(r.org), - display_name=r.display_name, - permissions=list(r.permissions), - ) - ) - org.roles = roles - return org + return self._build_org(org_id, include_roles=True) - async def list_organizations(self) -> list[Org]: - async with self._lock: - orgs = [] - for org_uuid_str, o in self._data.orgs.items(): - # Get permissions that this org can grant - permissions = [] - for perm_id, p in self._data.permissions.items(): - if org_uuid_str in p.orgs: - permissions.append(perm_id) - org = Org( - uuid=UUID(org_uuid_str), # Use the key directly - display_name=o.display_name, - permissions=permissions, - ) - # Load roles for this org - roles = [] - for role_uuid_str, r in self._data.roles.items(): - if r.org == org_uuid_str: - roles.append( - Role( - uuid=UUID(role_uuid_str), # Use the key directly - org_uuid=UUID(r.org), - display_name=r.display_name, - permissions=list(r.permissions), - ) - ) - org.roles = roles - orgs.append(org) - return orgs + def list_organizations(self) -> list[Org]: + with self._lock: + return [ + self._build_org(org_uuid, include_roles=True) + for org_uuid in self._data.orgs + ] - async def update_organization(self, org: Org) -> None: - async with self.session(): + def update_organization(self, org: Org, actor: str = "system") -> None: + with self.session(actor): key = str(org.uuid) if key not in self._data.orgs: raise ValueError("Organization not found") @@ -774,8 +838,8 @@ class DB: if perm_id in self._data.permissions: self._data.permissions[perm_id].orgs[key] = True - async def delete_organization(self, org_uuid: UUID) -> None: - async with self.session(): + def delete_organization(self, org_uuid: UUID, actor: str = "system") -> None: + with self.session(actor): key = str(org_uuid) if key in self._data.orgs: del self._data.orgs[key] @@ -784,10 +848,10 @@ class DB: for k in to_delete: del self._data.roles[k] - async def add_user_to_organization( - self, user_uuid: UUID, org_id: str, role: str + def add_user_to_organization( + self, user_uuid: UUID, org_id: str, role: str, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): user_key = str(user_uuid) if user_key not in self._data.users: raise ValueError("User not found") @@ -803,13 +867,13 @@ class DB: raise ValueError("Role not found in organization") self._data.users[user_key].role = role_uuid - async def transfer_user_to_organization( + def transfer_user_to_organization( self, user_uuid: UUID, new_org_id: str, new_role: str | None = None ) -> None: raise ValueError("Users cannot be transferred to a different organization") - async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: - async with self._lock: + def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: + with self._lock: user_key = str(user_uuid) if user_key not in self._data.users: raise ValueError("User not found") @@ -817,59 +881,34 @@ class DB: if role_uuid not in self._data.roles: raise ValueError("Role not found") r = self._data.roles[role_uuid] - org_uuid = r.org - if org_uuid not in self._data.orgs: + if r.org not in self._data.orgs: raise ValueError("Organization not found") - o = self._data.orgs[org_uuid] - org = Org( - uuid=UUID(org_uuid), - display_name=o.display_name, - permissions=[], # Could populate from permissions if needed - ) - return org, r.display_name + return self._build_org(r.org), r.display_name - async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: - async with self._lock: + def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: + with self._lock: # Get all roles for this org org_role_uuids = { - role_uuid_str - for role_uuid_str, r in self._data.roles.items() - if r.org == org_id + role_uuid for role_uuid, r in self._data.roles.items() if r.org == org_id } - results = [] - for user_uuid_str, u in self._data.users.items(): - if u.role in org_role_uuids: - role_name = self._data.roles[u.role].display_name - user = User( - uuid=UUID(user_uuid_str), - display_name=u.display_name, - role_uuid=UUID(u.role), - created_at=u.created_at, - last_seen=u.last_seen, - visits=u.visits, - ) - results.append((user, role_name)) - return results + return [ + (self._build_user(user_uuid), self._data.roles[u.role].display_name) + for user_uuid, u in self._data.users.items() + if u.role in org_role_uuids + ] - async def get_roles_by_organization(self, org_id: str) -> list[Role]: - async with self._lock: - roles = [] - for role_uuid_str, r in self._data.roles.items(): - if r.org == org_id: - roles.append( - Role( - uuid=UUID(role_uuid_str), # Use the key directly - org_uuid=UUID(r.org), - display_name=r.display_name, - permissions=list(r.permissions), - ) - ) - return roles + def get_roles_by_organization(self, org_id: str) -> list[Role]: + with self._lock: + return [ + self._build_role(role_uuid) + for role_uuid, r in self._data.roles.items() + if r.org == org_id + ] - async def get_user_role_in_organization( + def get_user_role_in_organization( self, user_uuid: UUID, org_id: str ) -> str | None: - async with self._lock: + with self._lock: user_key = str(user_uuid) if user_key not in self._data.users: return None @@ -881,10 +920,10 @@ class DB: return None return r.display_name - async def update_user_role_in_organization( - self, user_uuid: UUID, new_role: str + def update_user_role_in_organization( + self, user_uuid: UUID, new_role: str, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): user_key = str(user_uuid) if user_key not in self._data.users: raise ValueError("User not found") @@ -906,35 +945,35 @@ class DB: # Permission operations # ------------------------------------------------------------------------- - async def create_permission(self, permission: Permission) -> None: - async with self.session(): + def create_permission(self, permission: Permission, actor: str = "system") -> None: + with self.session(actor): self._data.permissions[permission.id] = _PermissionData( display_name=permission.display_name, orgs={}, # Will be populated when orgs are allowed to grant this permission ) - async def get_permission(self, permission_id: str) -> Permission: - async with self._lock: + def get_permission(self, permission_id: str) -> Permission: + with self._lock: if permission_id not in self._data.permissions: raise ValueError("Permission not found") p = self._data.permissions[permission_id] return Permission(id=permission_id, display_name=p.display_name) - async def list_permissions(self) -> list[Permission]: - async with self._lock: + def list_permissions(self) -> list[Permission]: + with self._lock: return [ Permission(id=pid, display_name=p.display_name) for pid, p in self._data.permissions.items() ] - async def update_permission(self, permission: Permission) -> None: - async with self.session(): + def update_permission(self, permission: Permission, actor: str = "system") -> None: + with self.session(actor): if permission.id not in self._data.permissions: raise ValueError("Permission not found") self._data.permissions[permission.id].display_name = permission.display_name - async def delete_permission(self, permission_id: str) -> None: - async with self.session(): + def delete_permission(self, permission_id: str, actor: str = "system") -> None: + with self.session(actor): if permission_id in self._data.permissions: del self._data.permissions[permission_id] # Remove from roles (permissions is a dict) @@ -942,10 +981,10 @@ class DB: if permission_id in r.permissions: del r.permissions[permission_id] - async def rename_permission( - self, old_id: str, new_id: str, display_name: str + def rename_permission( + self, old_id: str, new_id: str, display_name: str, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): if old_id == new_id: if old_id in self._data.permissions: self._data.permissions[old_id].display_name = display_name @@ -969,27 +1008,27 @@ class DB: # Delete old permission del self._data.permissions[old_id] - async def add_permission_to_organization( - self, org_id: str, permission_id: str + def add_permission_to_organization( + self, org_id: str, permission_id: str, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): if org_id not in self._data.orgs: raise ValueError("Organization not found") if permission_id not in self._data.permissions: raise ValueError("Permission not found") self._data.permissions[permission_id].orgs[org_id] = True - async def remove_permission_from_organization( - self, org_id: str, permission_id: str + def remove_permission_from_organization( + self, org_id: str, permission_id: str, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): if permission_id in self._data.permissions: orgs = self._data.permissions[permission_id].orgs if org_id in orgs: del orgs[org_id] - async def get_organization_permissions(self, org_id: str) -> list[Permission]: - async with self._lock: + def get_organization_permissions(self, org_id: str) -> list[Permission]: + with self._lock: if org_id not in self._data.orgs: raise ValueError("Organization not found") permissions = [] @@ -998,35 +1037,25 @@ class DB: permissions.append(Permission(id=pid, display_name=p.display_name)) return permissions - async def get_permission_organizations(self, permission_id: str) -> list[Org]: - async with self._lock: + def get_permission_organizations(self, permission_id: str) -> list[Org]: + with self._lock: if permission_id not in self._data.permissions: return [] org_ids = self._data.permissions[permission_id].orgs - orgs = [] - for org_id in org_ids: - if org_id in self._data.orgs: - o = self._data.orgs[org_id] - # Get permissions for this org - permissions = [] - for pid, p in self._data.permissions.items(): - if org_id in p.orgs: - permissions.append(pid) - orgs.append( - Org( - uuid=UUID(org_id), - display_name=o.display_name, - permissions=permissions, - ) - ) - return orgs + return [ + self._build_org(org_id) + for org_id in org_ids + if org_id in self._data.orgs + ] # ------------------------------------------------------------------------- # Role-permission operations # ------------------------------------------------------------------------- - async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None: - async with self.session(): + def add_permission_to_role( + self, role_uuid: UUID, permission_id: str, actor: str = "system" + ) -> None: + with self.session(actor): key = str(role_uuid) if key not in self._data.roles: raise ValueError("Role not found") @@ -1034,17 +1063,17 @@ class DB: raise ValueError("Permission not found") self._data.roles[key].permissions[permission_id] = True - async def remove_permission_from_role( - self, role_uuid: UUID, permission_id: str + def remove_permission_from_role( + self, role_uuid: UUID, permission_id: str, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): key = str(role_uuid) if key in self._data.roles: if permission_id in self._data.roles[key].permissions: del self._data.roles[key].permissions[permission_id] - async def get_role_permissions(self, role_uuid: UUID) -> list[Permission]: - async with self._lock: + def get_role_permissions(self, role_uuid: UUID) -> list[Permission]: + with self._lock: key = str(role_uuid) if key not in self._data.roles: return [] @@ -1056,27 +1085,20 @@ class DB: permissions.append(Permission(id=pid, display_name=p.display_name)) return permissions - async def get_permission_roles(self, permission_id: str) -> list[Role]: - async with self._lock: - roles = [] - for role_uuid_str, r in self._data.roles.items(): - if permission_id in r.permissions: - roles.append( - Role( - uuid=UUID(role_uuid_str), # Use the key directly - org_uuid=UUID(r.org), - display_name=r.display_name, - permissions=list(r.permissions), - ) - ) - return roles + def get_permission_roles(self, permission_id: str) -> list[Role]: + with self._lock: + return [ + self._build_role(role_uuid) + for role_uuid, r in self._data.roles.items() + if permission_id in r.permissions + ] # ------------------------------------------------------------------------- # Combined operations # ------------------------------------------------------------------------- - async def login(self, user_uuid: UUID, credential: Credential) -> None: - async with self.session(): + def login(self, user_uuid: UUID, credential: Credential, actor: str = "system") -> None: + with self.session(actor): # Update credential for key, c in self._data.credentials.items(): if c.credential_id == credential.credential_id: @@ -1094,10 +1116,10 @@ class DB: self._data.users[user_key].visits + 1 ) - async def create_user_and_credential( - self, user: User, credential: Credential + def create_user_and_credential( + self, user: User, credential: Credential, actor: str = "system" ) -> None: - async with self.session(): + with self.session(actor): # Create user user_key = str(user.uuid) self._data.users[user_key] = _UserData( @@ -1120,7 +1142,7 @@ class DB: last_verified=credential.last_verified, ) - async def create_credential_session( + def create_credential_session( self, user_uuid: UUID, credential: Credential, @@ -1131,8 +1153,9 @@ class DB: host: str | None = None, ip: str | None = None, user_agent: str | None = None, + actor: str = "system", ) -> None: - async with self.session(): + with self.session(actor): user_key = str(user_uuid) # Ensure credential has last_used / last_verified if credential.last_used is None: @@ -1181,8 +1204,9 @@ class DB: self._data.users[user_key].visits + 1 ) - async def cleanup(self) -> None: - async with self.session(): + def cleanup(self) -> None: + """Remove expired sessions and reset tokens.""" + with self.session("expiry"): current_time = datetime.now(timezone.utc) session_threshold = current_time - SESSION_LIFETIME @@ -1204,11 +1228,15 @@ class DB: for k in to_delete_tokens: del self._data.reset_tokens[k] - async def get_session_context( + def get_session_context( self, session_key: bytes, host: str | None = None ) -> SessionContext | None: - # Need to acquire session lock for potential write (host binding) - async with self._lock: + """Get full authentication context from a session key. + + This is the primary method for validating sessions and getting all + associated user/org/role/credential data in a single call. + """ + with self._lock: sess_key_b64 = _bytes_to_str(session_key) if sess_key_b64 not in self._data.sessions: return None @@ -1219,91 +1247,44 @@ class DB: if host is not None: if s.host is None: s.host = host - # Mark for save - await self._save() + self._queue_change() # Queue change for host binding elif s.host != host: return None - # Build session object - session_obj = Session( - key=_str_to_bytes(sess_key_b64), # type: ignore[arg-type] - user_uuid=UUID(s.user), - credential_uuid=UUID(s.credential), - host=s.host, - ip=s.ip, - user_agent=s.user_agent, - renewed=s.renewed, # Already datetime - ) - - # Get user + # Validate user exists user_key = s.user if user_key not in self._data.users: return None - u = self._data.users[user_key] - user_obj = User( - uuid=UUID(user_key), - display_name=u.display_name, - role_uuid=UUID(u.role), - created_at=u.created_at, - last_seen=u.last_seen, - visits=u.visits, - ) - # Get role - role_uuid = u.role + # Validate role exists + role_uuid = self._data.users[user_key].role if role_uuid not in self._data.roles: return None - r = self._data.roles[role_uuid] - role_obj = Role( - uuid=UUID(role_uuid), - org_uuid=UUID(r.org), - display_name=r.display_name, - permissions=list(r.permissions), - ) - # Get org - org_uuid = r.org + # Validate org exists + org_uuid = self._data.roles[role_uuid].org if org_uuid not in self._data.orgs: return None - o = self._data.orgs[org_uuid] - org_obj = Org( - uuid=UUID(org_uuid), # Use the key directly - display_name=o.display_name, - permissions=[], # Could populate from permissions if needed - ) + + # Build objects using helpers + session_obj = self._build_session(sess_key_b64) + user_obj = self._build_user(user_key) + role_obj = self._build_role(role_uuid) + org_obj = self._build_org(org_uuid) # Get credential (optional) cred_uuid = s.credential - credential_obj = None - if cred_uuid in self._data.credentials: - c = self._data.credentials[cred_uuid] - credential_obj = Credential( - uuid=UUID(cred_uuid), # Use the key directly - credential_id=c.credential_id, # Already bytes - user_uuid=UUID(c.user), - aaguid=UUID(c.aaguid), - public_key=c.public_key, # Already bytes - sign_count=c.sign_count, - created_at=c.created_at, # Already datetime - last_used=c.last_used, - last_verified=c.last_verified, - ) + credential_obj = ( + self._build_credential(cred_uuid) + if cred_uuid in self._data.credentials + else None + ) - # Collect permissions for the role - permissions = [] - for pid in role_obj.permissions: - if pid in self._data.permissions: - p = self._data.permissions[pid] - permissions.append(Permission(id=pid, display_name=p.display_name)) - - # Filter effective permissions: only include permissions that the org can grant + # Effective permissions: role permissions that the org can grant effective_permissions = [ - p for p in permissions if p.id in org_obj.permissions - ] - - # Filter effective permissions: only include permissions that the org can grant - effective_permissions = [ - p for p in permissions if p.id in org_obj.permissions + Permission(id=pid, display_name=self._data.permissions[pid].display_name) + for pid in role_obj.permissions + if pid in self._data.permissions and pid in org_obj.permissions ] return SessionContext( @@ -1312,5 +1293,5 @@ class DB: org=org_obj, role=role_obj, credential=credential_obj, - permissions=effective_permissions if effective_permissions else None, + permissions=effective_permissions or None, ) diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index d197237..3edb432 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -59,7 +59,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): match=permutil.has_any, host=request.headers.get("host"), ) - orgs = await db.list_organizations() + orgs = db.list_organizations() if "auth:admin" not in ctx.role.permissions: orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions] @@ -72,7 +72,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): } async def org_to_dict(o): - users = await db.get_organization_users(str(o.uuid)) + users = db.get_organization_users(str(o.uuid)) return { "uuid": str(o.uuid), "display_name": o.display_name, @@ -107,7 +107,7 @@ async def admin_create_org( display_name = payload.get("display_name") or "New Organization" permissions = payload.get("permissions") or [] org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) - await db.create_organization(org) + db.create_organization(org) # Automatically create Administration role with org admin permission role_uuid = uuid4() @@ -117,7 +117,7 @@ async def admin_create_org( display_name="Administration", permissions=[f"auth:org:{org_uuid}"], ) - await db.create_role(admin_role) + db.create_role(admin_role) return {"uuid": str(org_uuid)} @@ -137,7 +137,7 @@ async def admin_update_org( ) from ..db import Org as OrgDC # local import to avoid cycles - current = await db.get_organization(str(org_uuid)) + current = db.get_organization(str(org_uuid)) display_name = payload.get("display_name") or current.display_name permissions = payload.get("permissions") if permissions is None: @@ -157,7 +157,7 @@ async def admin_update_org( ) org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) - await db.update_organization(org) + db.update_organization(org) return {"status": "ok"} @@ -175,7 +175,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): # Delete organization-specific permissions org_perm_pattern = f"org:{str(org_uuid).lower()}" - all_permissions = await db.list_permissions() + all_permissions = db.list_permissions() for perm in all_permissions: perm_id_lower = perm.id.lower() # Check if permission contains "org:{uuid}" separated by colons or at boundaries @@ -185,9 +185,9 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): or perm_id_lower.endswith(f":{org_perm_pattern}") or perm_id_lower == org_perm_pattern ): - await db.delete_permission(perm.id) + db.delete_permission(perm.id) - await db.delete_organization(org_uuid) + db.delete_organization(org_uuid) return {"status": "ok"} @@ -201,7 +201,7 @@ async def admin_add_org_permission( await authz.verify( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) - await db.add_permission_to_organization(str(org_uuid), permission_id) + db.add_permission_to_organization(str(org_uuid), permission_id) return {"status": "ok"} @@ -215,7 +215,7 @@ async def admin_remove_org_permission( await authz.verify( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) - await db.remove_permission_from_organization(str(org_uuid), permission_id) + db.remove_permission_from_organization(str(org_uuid), permission_id) return {"status": "ok"} @@ -240,10 +240,10 @@ async def admin_create_role( role_uuid = uuid4() display_name = payload.get("display_name") or "New Role" perms = payload.get("permissions") or [] - org = await db.get_organization(str(org_uuid)) + org = db.get_organization(str(org_uuid)) grantable = set(org.permissions or []) for pid in perms: - await db.get_permission(pid) + db.get_permission(pid) if pid not in grantable: raise ValueError(f"Permission not grantable by org: {pid}") role = RoleDC( @@ -252,7 +252,7 @@ async def admin_create_role( display_name=display_name, permissions=perms, ) - await db.create_role(role) + db.create_role(role) return {"uuid": str(role_uuid)} @@ -271,7 +271,7 @@ async def admin_update_role( match=permutil.has_any, host=request.headers.get("host"), ) - role = await db.get_role(role_uuid) + role = db.get_role(role_uuid) if role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") from ..db import Role as RoleDC @@ -280,11 +280,11 @@ async def admin_update_role( permissions = payload.get("permissions") if permissions is None: permissions = role.permissions - org = await db.get_organization(str(org_uuid)) + org = db.get_organization(str(org_uuid)) grantable = set(org.permissions or []) existing_permissions = set(role.permissions) for pid in permissions: - await db.get_permission(pid) + db.get_permission(pid) if pid not in existing_permissions and pid not in grantable: raise ValueError(f"Permission not grantable by org: {pid}") @@ -302,7 +302,7 @@ async def admin_update_role( display_name=display_name, permissions=permissions, ) - await db.update_role(updated) + db.update_role(updated) return {"status": "ok"} @@ -320,7 +320,7 @@ async def admin_delete_role( host=request.headers.get("host"), max_age="5m", ) - role = await db.get_role(role_uuid) + role = db.get_role(role_uuid) if role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") @@ -328,7 +328,7 @@ async def admin_delete_role( if ctx.role.uuid == role_uuid: raise ValueError("Cannot delete your own role") - await db.delete_role(role_uuid) + db.delete_role(role_uuid) return {"status": "ok"} @@ -354,7 +354,7 @@ async def admin_create_user( raise ValueError("display_name and role are required") from ..db import User as UserDC - roles = await db.get_roles_by_organization(str(org_uuid)) + roles = db.get_roles_by_organization(str(org_uuid)) role_obj = next((r for r in roles if r.display_name == role_name), None) if not role_obj: raise ValueError("Role not found in organization") @@ -366,7 +366,7 @@ async def admin_create_user( visits=0, created_at=None, ) - await db.create_user(user) + db.create_user(user) return {"uuid": str(user_uuid)} @@ -388,12 +388,12 @@ async def admin_update_user_role( if not new_role: raise ValueError("role is required") try: - user_org, _current_role = await db.get_user_organization(user_uuid) + user_org, _current_role = db.get_user_organization(user_uuid) except ValueError: raise ValueError("User not found") if user_org.uuid != org_uuid: raise ValueError("User does not belong to this organization") - roles = await db.get_roles_by_organization(str(org_uuid)) + roles = db.get_roles_by_organization(str(org_uuid)) if not any(r.display_name == new_role for r in roles): raise ValueError("Role not found in organization") @@ -410,7 +410,7 @@ async def admin_update_user_role( "Cannot change your own role to one without admin permissions" ) - await db.update_user_role_in_organization(user_uuid, new_role) + db.update_user_role_in_organization(user_uuid, new_role) return {"status": "ok"} @@ -422,7 +422,7 @@ async def admin_create_user_registration_link( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.get_user_organization(user_uuid) + user_org, _role_name = db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -443,12 +443,12 @@ async def admin_create_user_registration_link( ) # Check if user has existing credentials - credentials = await db.get_credentials_by_user_uuid(user_uuid) + credentials = db.get_credentials_by_user_uuid(user_uuid) token_type = "user registration" if not credentials else "account recovery" token = passphrase.generate() expiry = reset_expires() - await db.create_reset_token( + db.create_reset_token( user_uuid=user_uuid, key=tokens.reset_key(token), expiry=expiry, @@ -473,7 +473,7 @@ async def admin_get_user_detail( auth=AUTH_COOKIE, ): try: - user_org, role_name = await db.get_user_organization(user_uuid) + user_org, role_name = db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -491,13 +491,13 @@ async def admin_get_user_detail( raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - user = await db.get_user_by_uuid(user_uuid) - cred_ids = await db.get_credentials_by_user_uuid(user_uuid) + user = db.get_user_by_uuid(user_uuid) + cred_ids = db.get_credentials_by_user_uuid(user_uuid) creds: list[dict] = [] aaguids: set[str] = set() for cid in cred_ids: try: - c = await db.get_credential_by_id(cid) + c = db.get_credential_by_id(cid) except ValueError: # pragma: no cover - race condition handling continue aaguid_str = str(c.aaguid) @@ -552,7 +552,7 @@ async def admin_get_user_detail( # Get sessions for the user normalized_request_host = hostutil.normalize_host(request.headers.get("host")) - session_records = await db.list_sessions_for_user(user_uuid) + session_records = db.list_sessions_for_user(user_uuid) current_session_key = session_key(auth) sessions_payload: list[dict] = [] for entry in session_records: @@ -623,7 +623,7 @@ async def admin_update_user_display_name( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.get_user_organization(user_uuid) + user_org, _role_name = db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -646,7 +646,7 @@ async def admin_update_user_display_name( raise HTTPException(status_code=400, detail="display_name required") if len(new_name) > 64: raise HTTPException(status_code=400, detail="display_name too long") - await db.update_user_display_name(user_uuid, new_name) + db.update_user_display_name(user_uuid, new_name) return {"status": "ok"} @@ -659,7 +659,7 @@ async def admin_delete_user_credential( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.get_user_organization(user_uuid) + user_org, _role_name = db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -678,7 +678,7 @@ async def admin_delete_user_credential( raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - await db.delete_credential(credential_uuid, user_uuid) + db.delete_credential(credential_uuid, user_uuid) return {"status": "ok"} @@ -691,7 +691,7 @@ async def admin_delete_user_session( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.get_user_organization(user_uuid) + user_org, _role_name = db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -717,11 +717,11 @@ async def admin_delete_user_session( status_code=400, detail="Invalid session identifier" ) from exc - target_session = await db.get_session(target_key) + target_session = db.get_session(target_key) if not target_session or target_session.user_uuid != user_uuid: raise HTTPException(status_code=404, detail="Session not found") - await db.delete_session(target_key) + db.delete_session(target_key) # Check if admin terminated their own session current_terminated = target_key == session_key(auth) @@ -739,7 +739,7 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): match=permutil.has_any, host=request.headers.get("host"), ) - perms = await db.list_permissions() + perms = db.list_permissions() # Global admins see all permissions if "auth:admin" in ctx.role.permissions: @@ -771,7 +771,7 @@ async def admin_create_permission( if not perm_id or not display_name: raise ValueError("id and display_name are required") querysafe.assert_safe(perm_id, field="id") - await db.create_permission(PermDC(id=perm_id, display_name=display_name)) + db.create_permission(PermDC(id=perm_id, display_name=display_name)) return {"status": "ok"} @@ -790,7 +790,7 @@ async def admin_update_permission( if not display_name: raise ValueError("display_name is required") querysafe.assert_safe(permission_id, field="permission_id") - await db.update_permission( + db.update_permission( PermDC(id=permission_id, display_name=display_name) ) return {"status": "ok"} @@ -818,10 +818,10 @@ async def admin_rename_permission( querysafe.assert_safe(old_id, field="old_id") querysafe.assert_safe(new_id, field="new_id") if display_name is None: - perm = await db.get_permission(old_id) + perm = db.get_permission(old_id) display_name = perm.display_name # All current backends support rename_permission - await db.rename_permission(old_id, new_id, display_name) + db.rename_permission(old_id, new_id, display_name) return {"status": "ok"} @@ -844,5 +844,5 @@ async def admin_delete_permission( if permission_id == "auth:admin": raise ValueError("Cannot delete the master admin permission") - await db.delete_permission(permission_id) + db.delete_permission(permission_id) return {"status": "ok"} diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 4cb5e18..321cf2e 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -227,7 +227,7 @@ async def api_token_info(token: str): # Check if this is a reset token try: reset_token = await get_reset(token) - user = await db.get_user_by_uuid(reset_token.user_uuid) + user = db.get_user_by_uuid(reset_token.user_uuid) return { "type": "reset", "user_name": user.display_name, @@ -297,7 +297,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): except ValueError: return {"message": "Already logged out"} with suppress(Exception): - await db.delete_session(session_key(auth)) + db.delete_session(session_key(auth)) session.clear_session_cookie(response) return {"message": "Logged out successfully"} diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index a417dd7..eac9e9a 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -324,7 +324,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): # Fetch and verify credential try: - stored_cred = await db.get_credential_by_id( + stored_cred = db.get_credential_by_id( credential.raw_id ) except ValueError: @@ -338,7 +338,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): ) # Update credential last_used - await db.login(stored_cred.user_uuid, stored_cred) + db.login(stored_cred.user_uuid, stored_cred) # Create a session for the REQUESTING device assert stored_cred.uuid is not None @@ -353,7 +353,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): token_str = passphrase.generate() expiry = expires() - await db.create_reset_token( + db.create_reset_token( user_uuid=stored_cred.user_uuid, key=tokens.reset_key(token_str), expiry=expiry, diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 327786a..408b159 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -55,7 +55,7 @@ async def user_update_display_name( raise HTTPException(status_code=400, detail="display_name required") if len(new_name) > 64: raise HTTPException(status_code=400, detail="display_name too long") - await db.update_user_display_name(s.user_uuid, new_name) + db.update_user_display_name(s.user_uuid, new_name) return {"status": "ok"} @@ -69,7 +69,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE) raise authz.AuthException( status_code=401, detail="Session expired", mode="login" ) - await db.delete_sessions_for_user(s.user_uuid) + db.delete_sessions_for_user(s.user_uuid) session.clear_session_cookie(response) return {"message": "Logged out from all hosts"} @@ -99,11 +99,11 @@ async def api_delete_session( status_code=400, detail="Invalid session identifier" ) from exc - target_session = await db.get_session(target_key) + target_session = db.get_session(target_key) if not target_session or target_session.user_uuid != current_session.user_uuid: raise HTTPException(status_code=404, detail="Session not found") - await db.delete_session(target_key) + db.delete_session(target_key) current_terminated = target_key == session_key(auth) if current_terminated: session.clear_session_cookie(response) # explicit because 200 @@ -144,7 +144,7 @@ async def api_create_link( ) from e token = passphrase.generate() expiry = expires() - await db.create_reset_token( + db.create_reset_token( user_uuid=s.user_uuid, key=tokens.reset_key(token), expiry=expiry, diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index f96cdcc..56d4034 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -66,13 +66,13 @@ async def websocket_register_add( s = ctx.session # Get user information and determine effective user_name for this registration - user = await db.get_user_by_uuid(user_uuid) + user = db.get_user_by_uuid(user_uuid) user_name = user.display_name if name is not None: stripped = name.strip() if stripped: user_name = stripped - challenge_ids = await db.get_credentials_by_user_uuid(user_uuid) + challenge_ids = db.get_credentials_by_user_uuid(user_uuid) # WebAuthn registration credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids) @@ -80,7 +80,7 @@ async def websocket_register_add( # Create a new session and store everything in database token = create_token() metadata = infodict(ws, "authenticated") - await db.create_credential_session( # type: ignore[attr-defined] + db.create_credential_session( # type: ignore[attr-defined] user_uuid=user_uuid, credential=credential, reset_key=(s.key if reset is not None else None), @@ -116,7 +116,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): try: session = await get_session(auth, host=host) session_user_uuid = session.user_uuid - credential_ids = await db.get_credentials_by_user_uuid( + credential_ids = db.get_credentials_by_user_uuid( session_user_uuid ) except ValueError: @@ -130,7 +130,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): credential = passkey.instance.auth_parse(await ws.receive_json()) # Fetch from the database by credential ID try: - stored_cred = await db.get_credential_by_id(credential.raw_id) + stored_cred = db.get_credential_by_id(credential.raw_id) except ValueError: raise ValueError( f"This passkey is no longer registered with {passkey.instance.rp_name}" @@ -143,7 +143,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): # Verify the credential matches the stored data passkey.instance.auth_verify(credential, challenge, stored_cred, origin) # Update both credential and user's last_seen timestamp - await db.login(stored_cred.user_uuid, stored_cred) + db.login(stored_cred.user_uuid, stored_cred) # Create a session token for the authenticated user assert stored_cred.uuid is not None diff --git a/paskia/util/permutil.py b/paskia/util/permutil.py index 466efed..98144a2 100644 --- a/paskia/util/permutil.py +++ b/paskia/util/permutil.py @@ -29,4 +29,4 @@ async def session_context(auth: str | None, host: str | None = None): if not auth: return None normalized_host = normalize_host(host) if host else None - return await db.get_session_context(session_key(auth), normalized_host) + return db.get_session_context(session_key(auth), normalized_host) diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py index c4ccfdf..279179a 100644 --- a/paskia/util/userinfo.py +++ b/paskia/util/userinfo.py @@ -41,17 +41,17 @@ async def format_user_info( - Sessions list - Permissions """ - u = await db.get_user_by_uuid(user_uuid) + u = db.get_user_by_uuid(user_uuid) ctx = await permutil.session_context(auth, request_host) # Fetch and format credentials - credential_ids = await db.get_credentials_by_user_uuid(user_uuid) + credential_ids = db.get_credentials_by_user_uuid(user_uuid) credentials: list[dict] = [] user_aaguids: set[str] = set() for cred_id in credential_ids: try: - c = await db.get_credential_by_id(cred_id) + c = db.get_credential_by_id(cred_id) except ValueError: continue @@ -98,7 +98,7 @@ async def format_user_info( # Format sessions normalized_request_host = hostutil.normalize_host(request_host) - session_records = await db.list_sessions_for_user(user_uuid) + session_records = db.list_sessions_for_user(user_uuid) current_session_key = session_key(auth) sessions_payload: list[dict] = [] @@ -150,7 +150,7 @@ async def format_reset_user_info(user_uuid, reset_token) -> dict: Returns: Dictionary with minimal user info for password reset flow """ - u = await db.get_user_by_uuid(user_uuid) + u = db.get_user_by_uuid(user_uuid) return { "authenticated": False, diff --git a/tests/conftest.py b/tests/conftest.py index 6c14f8a..87e5a3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ import pytest import pytest_asyncio import uuid7 +from paskia import globals as paskia_globals from paskia.db import Credential, Org, Permission, Role, User from paskia.db.json import DB from paskia.fastapi.session import AUTH_COOKIE_NAME @@ -46,7 +47,7 @@ async def test_db() -> AsyncGenerator[DB, None]: with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: db = DB(f.name) - await db.init_db() + db.load() # Synchronous now json_db._db = db yield db # Clean up @@ -61,9 +62,9 @@ async def passkey_instance() -> Passkey: rp_name="Test RP", origins=["http://localhost:4401"], ) - globals.passkey._instance = pk + paskia_globals.passkey._instance = pk yield pk - globals.passkey._instance = None + paskia_globals.passkey._instance = None @pytest_asyncio.fixture(scope="function") @@ -74,7 +75,7 @@ async def test_org(test_db: DB, admin_permission: Permission) -> Org: display_name="Test Organization", permissions=["auth:admin"], # Org can grant this permission ) - await test_db.create_organization(org) + test_db.create_organization(org) return org @@ -82,7 +83,7 @@ async def test_org(test_db: DB, admin_permission: Permission) -> Org: async def admin_permission(test_db: DB) -> Permission: """Create the auth:admin permission.""" perm = Permission(id="auth:admin", display_name="Master Admin") - await test_db.create_permission(perm) + test_db.create_permission(perm) return perm @@ -95,7 +96,7 @@ async def test_role(test_db: DB, test_org: Org, admin_permission: Permission) -> display_name="Test Admin Role", permissions=["auth:admin", f"auth:org:{test_org.uuid}"], ) - await test_db.create_role(role) + test_db.create_role(role) return role @@ -108,7 +109,7 @@ async def user_role(test_db: DB, test_org: Org) -> Role: display_name="User Role", permissions=[], ) - await test_db.create_role(role) + test_db.create_role(role) return role @@ -122,7 +123,7 @@ async def test_user(test_db: DB, test_role: Role) -> User: created_at=datetime.now(timezone.utc), visits=0, ) - await test_db.create_user(user) + test_db.create_user(user) return user @@ -136,7 +137,7 @@ async def regular_user(test_db: DB, user_role: Role) -> User: created_at=datetime.now(timezone.utc), visits=0, ) - await test_db.create_user(user) + test_db.create_user(user) return user @@ -154,7 +155,7 @@ async def test_credential(test_db: DB, test_user: User) -> Credential: last_used=None, last_verified=None, ) - await test_db.create_credential(credential) + test_db.create_credential(credential) return credential @@ -172,7 +173,7 @@ async def regular_credential(test_db: DB, regular_user: User) -> Credential: last_used=None, last_verified=None, ) - await test_db.create_credential(credential) + test_db.create_credential(credential) return credential @@ -182,7 +183,7 @@ async def session_token( ) -> str: """Create a session for the admin user and return the token.""" token = create_token() - await test_db.create_session( + test_db.create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, key=session_key(token), @@ -200,7 +201,7 @@ async def regular_session_token( ) -> str: """Create a session for a regular user and return the token.""" token = create_token() - await test_db.create_session( + test_db.create_session( user_uuid=regular_user.uuid, credential_uuid=regular_credential.uuid, key=session_key(token), @@ -220,7 +221,7 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential) from paskia.util.tokens import reset_key token = generate() - await test_db.create_reset_token( + test_db.create_reset_token( user_uuid=test_user.uuid, key=reset_key(token), expiry=reset_expires(), diff --git a/tests/test_admin.py b/tests/test_admin.py index 6c3f150..472d413 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -35,7 +35,7 @@ async def second_org(test_db: DB) -> Org: display_name="Second Organization", permissions=[], ) - await test_db.create_organization(org) + test_db.create_organization(org) return org @@ -50,7 +50,7 @@ async def second_org_role( display_name="Second Org Admin Role", permissions=["auth:admin"], ) - await test_db.create_role(role) + test_db.create_role(role) return role @@ -64,7 +64,7 @@ async def second_org_user(test_db: DB, second_org_role: Role) -> User: created_at=datetime.now(timezone.utc), visits=0, ) - await test_db.create_user(user) + test_db.create_user(user) return user @@ -84,7 +84,7 @@ async def second_org_credential(test_db: DB, second_org_user: User) -> Credentia last_used=datetime.now(timezone.utc), last_verified=datetime.now(timezone.utc), ) - await test_db.create_credential(credential) + test_db.create_credential(credential) return credential @@ -94,7 +94,7 @@ async def second_org_session_token( ) -> str: """Create a session for the second org admin user.""" token = create_token() - await test_db.create_session( + test_db.create_session( user_uuid=second_org_user.uuid, credential_uuid=second_org_credential.uuid, key=session_key(token), @@ -115,7 +115,7 @@ async def org_admin_role(test_db: DB, test_org: Org) -> Role: display_name="Org Admin Role", permissions=[f"auth:org:{test_org.uuid}"], ) - await test_db.create_role(role) + test_db.create_role(role) return role @@ -130,7 +130,7 @@ async def org_admin_user(test_db: DB, org_admin_role: Role) -> User: visits=5, last_seen=datetime.now(timezone.utc), ) - await test_db.create_user(user) + test_db.create_user(user) return user @@ -150,7 +150,7 @@ async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential: last_used=datetime.now(timezone.utc), last_verified=None, ) - await test_db.create_credential(credential) + test_db.create_credential(credential) return credential @@ -160,7 +160,7 @@ async def org_admin_session_token( ) -> str: """Create a session for the org admin user.""" token = create_token() - await test_db.create_session( + test_db.create_session( user_uuid=org_admin_user.uuid, credential_uuid=org_admin_credential.uuid, key=session_key(token), @@ -176,9 +176,9 @@ async def org_admin_session_token( async def grantable_permission(test_db: DB, test_org: Org) -> Permission: """Create a permission and add it to org's grantable permissions.""" perm = Permission(id="test:grantable:perm", display_name="Grantable Perm") - await test_db.create_permission(perm) + test_db.create_permission(perm) # Add to org's grantable permissions - await test_db.add_permission_to_organization(str(test_org.uuid), perm.id) + test_db.add_permission_to_organization(str(test_org.uuid), perm.id) return perm @@ -375,12 +375,12 @@ class TestAdminOrganizations: org_admin_perm_id = f"auth:org:{test_org.uuid}" perm = Permission(id=org_admin_perm_id, display_name="Org Admin") try: - await test_db.create_permission(perm) + test_db.create_permission(perm) except Exception: pass # Permission may already exist # Add it to the org's permissions - await test_db.add_permission_to_organization( + test_db.add_permission_to_organization( str(test_org.uuid), org_admin_perm_id ) @@ -424,13 +424,13 @@ class TestAdminOrganizations: display_name="Org To Delete", permissions=[], ) - await test_db.create_organization(org_to_delete) + test_db.create_organization(org_to_delete) # Create some org-specific permissions to test cleanup org_perm = Permission( id=f"test:org:{org_to_delete.uuid}:feature", display_name="Org Feature" ) - await test_db.create_permission(org_perm) + test_db.create_permission(org_perm) response = await client.delete( f"/auth/api/admin/orgs/{org_to_delete.uuid}", @@ -603,7 +603,7 @@ class TestAdminRoles: """Creating role with non-grantable permission should fail.""" # Create permission but don't add to org perm = Permission(id="test:not:grantable", display_name="Not Grantable") - await test_db.create_permission(perm) + test_db.create_permission(perm) response = await client.post( f"/auth/api/admin/orgs/{test_org.uuid}/roles", @@ -673,7 +673,7 @@ class TestAdminRoles: ): """Adding non-grantable permission to role should fail.""" perm = Permission(id="test:not:grantable:update", display_name="Not Grantable") - await test_db.create_permission(perm) + test_db.create_permission(perm) response = await client.put( f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}", @@ -1087,7 +1087,7 @@ class TestAdminUsersInOrg: created_at=datetime.now(timezone.utc), visits=0, ) - await test_db.create_user(user_no_cred) + test_db.create_user(user_no_cred) response = await client.post( f"/auth/api/admin/orgs/{test_org.uuid}/users/{user_no_cred.uuid}/create-link", @@ -1174,7 +1174,7 @@ class TestAdminSessions: # Create an additional session to delete extra_token = create_token() extra_key = session_key(extra_token) - await test_db.create_session( + test_db.create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, key=extra_key, @@ -1301,7 +1301,7 @@ class TestAdminPermissions: test_org, grantable_permission, ): - """Org admin should only see grantable permissions.""" + """Org admin should only see permissions their org can grant.""" response = await client.get( "/auth/api/admin/permissions", headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, @@ -1311,8 +1311,10 @@ class TestAdminPermissions: # Should only see permissions the org can grant perm_ids = [p["id"] for p in data] assert grantable_permission.id in perm_ids - # Should NOT see auth:admin (not grantable by org) - assert "auth:admin" not in perm_ids + # test_org CAN grant auth:admin (it's in org.permissions), so org admin sees it + assert "auth:admin" in perm_ids + # Should also see auto-created org admin permission + assert f"auth:org:{test_org.uuid}" in perm_ids @pytest.mark.asyncio async def test_create_permission( @@ -1364,7 +1366,7 @@ class TestAdminPermissions: """Admin should be able to update a permission.""" # Create permission first perm = Permission(id="test:updateable", display_name="Updateable") - await test_db.create_permission(perm) + test_db.create_permission(perm) response = await client.put( "/auth/api/admin/permission?permission_id=test:updateable&display_name=Updated%20Name", @@ -1394,7 +1396,7 @@ class TestAdminPermissions: """Admin should be able to rename a permission.""" # Create permission first perm = Permission(id="test:renameable2", display_name="Renameable") - await test_db.create_permission(perm) + test_db.create_permission(perm) response = await client.post( "/auth/api/admin/permission/rename", @@ -1437,7 +1439,7 @@ class TestAdminPermissions: ): """Renaming permission can also update display name.""" perm = Permission(id="test:rename:withname", display_name="Old Name") - await test_db.create_permission(perm) + test_db.create_permission(perm) response = await client.post( "/auth/api/admin/permission/rename", @@ -1457,7 +1459,7 @@ class TestAdminPermissions: """Admin should be able to delete a permission.""" # Create permission first perm = Permission(id="test:deleteable", display_name="Deleteable") - await test_db.create_permission(perm) + test_db.create_permission(perm) response = await client.delete( "/auth/api/admin/permission?permission_id=test:deleteable", diff --git a/tests/test_api.py b/tests/test_api.py index 7cf16ae..444cb6a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -525,7 +525,7 @@ class TestValidateSessionRefresh: # Create a session with an old renewed time to trigger refresh token = create_token() old_time = datetime.now(timezone.utc) - timedelta(minutes=10) - await test_db.create_session( + test_db.create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, key=session_key(token), @@ -536,7 +536,7 @@ class TestValidateSessionRefresh: ) # Delete the session right before validate tries to refresh - await test_db.delete_session(session_key(token)) + test_db.delete_session(session_key(token)) response = await client.post( "/auth/api/validate",