From f9d23a196cb2c18e8abae0a5d9b45c8fa6d94ca3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 23 Jan 2026 18:27:12 +0000 Subject: [PATCH] Database refactor to separate modules. --- paskia/db/__init__.py | 180 ++++- paskia/db/background.py | 106 +++ paskia/db/json.py | 1517 ------------------------------------ paskia/db/jsonl.py | 125 +++ paskia/db/operations.py | 1014 ++++++++++++++++++++++++ paskia/db/structs.py | 168 ++++ paskia/fastapi/admin.py | 10 +- paskia/fastapi/api.py | 2 +- paskia/fastapi/remote.py | 3 +- paskia/fastapi/reset.py | 16 +- paskia/fastapi/user.py | 2 +- paskia/fastapi/ws.py | 2 +- paskia/globals.py | 8 +- paskia/migrate/__init__.py | 6 +- paskia/migrate/sql.py | 2 +- paskia/util/userinfo.py | 9 +- tests/conftest.py | 55 +- tests/test_admin.py | 65 +- tests/test_api.py | 5 +- 19 files changed, 1653 insertions(+), 1642 deletions(-) create mode 100644 paskia/db/background.py delete mode 100644 paskia/db/json.py create mode 100644 paskia/db/jsonl.py create mode 100644 paskia/db/operations.py create mode 100644 paskia/db/structs.py diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index dd706ae..f6d4dc8 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -1,21 +1,92 @@ """ 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). +Read: Access _db._data directly, use build_* to convert to public structs. +CTX: get_session_context(key) returns SessionContext with effective permissions. +Write: Functions validate and commit, or raise ValueError. Usage: from paskia import db - # Access the database instance (after init) - db.create_session(...) - user = db.get_user_by_uuid(uuid) + # Read (after init) + user_data = db._db._data.users[user_uuid] + user = db.build_user(user_uuid) + + # Context + ctx = db.get_session_context(session_key) + + # Write + db.create_user(user) """ -from paskia.db.json import ( - Credential, +from paskia.db.background import ( + start_background, + start_cleanup, + stop_background, + stop_cleanup, +) +from paskia.db.operations import ( DB, + _db, + add_permission_to_organization, + add_permission_to_role, + build_credential, + build_org, + build_permission, + build_reset_token, + build_role, + build_session, + build_user, + cleanup_expired, + create_credential, + create_credential_session, + create_organization, + create_permission, + create_reset_token, + create_role, + create_session, + create_user, + delete_credential, + delete_organization, + delete_permission, + delete_reset_token, + delete_role, + delete_session, + delete_sessions_for_user, + delete_user, + get_credential_by_id, + get_credentials_by_user_uuid, + get_organization, + get_organization_users, + get_permission, + get_permission_by_scope, + get_permission_organizations, + get_reset_token, + get_role, + get_roles_by_organization, + get_session, + get_session_context, + get_user_by_uuid, + get_user_organization, + init, + list_organizations, + list_permissions, + list_sessions_for_user, + login, + remove_permission_from_organization, + remove_permission_from_role, + rename_permission, + update_credential_sign_count, + update_organization_name, + update_permission, + update_role_name, + update_session, + update_user_display_name, + update_user_role, + update_user_role_in_organization, +) +from paskia.db.structs import ( + Credential, Org, Permission, ResetToken, @@ -23,40 +94,10 @@ from paskia.db.json import ( Session, SessionContext, User, - init, - start_background, - stop_background, - start_cleanup, - stop_cleanup, ) -import paskia.db.json as _json_module - - -class _DBProxy: - """Proxy that forwards attribute access to the global DB instance. - - This allows using `db.method()` directly instead of `db.get_db().method()`. - """ - - def __getattr__(self, name: str): - db = _json_module._db - if db is None: - raise RuntimeError("Database not initialized. Call init() first.") - return getattr(db, name) - - -# Module-level proxy for direct access -_proxy = _DBProxy() - - -def __getattr__(name: str): - """Module-level __getattr__ to forward DB method calls.""" - if name in __all__: - raise AttributeError(name) - return getattr(_proxy, name) - __all__ = [ + # Types "Credential", "DB", "Org", @@ -66,9 +107,70 @@ __all__ = [ "Session", "SessionContext", "User", + # Instance + "_db", "init", + # Background "start_background", "stop_background", "start_cleanup", "stop_cleanup", + # Builders + "build_credential", + "build_org", + "build_permission", + "build_reset_token", + "build_role", + "build_session", + "build_user", + # Read ops + "get_credential_by_id", + "get_credentials_by_user_uuid", + "get_organization", + "get_organization_users", + "get_permission", + "get_permission_by_scope", + "get_permission_organizations", + "get_reset_token", + "get_role", + "get_roles_by_organization", + "get_session", + "get_session_context", + "get_user_by_uuid", + "get_user_organization", + "list_organizations", + "list_permissions", + "list_sessions_for_user", + # Write ops + "add_permission_to_organization", + "add_permission_to_role", + "cleanup_expired", + "create_credential", + "create_credential_session", + "create_organization", + "create_permission", + "create_reset_token", + "create_role", + "create_session", + "create_user", + "delete_credential", + "delete_organization", + "delete_permission", + "delete_reset_token", + "delete_role", + "delete_session", + "delete_sessions_for_user", + "delete_user", + "login", + "remove_permission_from_organization", + "remove_permission_from_role", + "rename_permission", + "update_credential_sign_count", + "update_organization_name", + "update_permission", + "update_role_name", + "update_session", + "update_user_display_name", + "update_user_role", + "update_user_role_in_organization", ] diff --git a/paskia/db/background.py b/paskia/db/background.py new file mode 100644 index 0000000..381d67e --- /dev/null +++ b/paskia/db/background.py @@ -0,0 +1,106 @@ +""" +Background task for database maintenance. + +Periodically flushes pending changes to disk and cleans up expired items. +""" + +import asyncio +import logging +from datetime import datetime, timezone + +from paskia.db.jsonl import flush_changes + +# Flush changes to disk every N seconds +FLUSH_INTERVAL = 1 +# Cleanup expired items every N seconds (cheap when nothing to remove) +CLEANUP_INTERVAL = 1 + + +_logger = logging.getLogger(__name__) +_background_task: asyncio.Task | None = None + + +def cleanup() -> None: + """Remove expired sessions and reset tokens from the database.""" + from paskia.db.operations import _db + + if _db is None or _db._data is None: + return + + with _db.transaction("expiry"): + current_time = datetime.now(timezone.utc) + + # Clean expired sessions + to_delete_sessions = [ + k for k, s in _db._data.sessions.items() if s.expiry < current_time + ] + for k in to_delete_sessions: + del _db._data.sessions[k] + + # Clean expired reset tokens + to_delete_tokens = [ + k for k, t in _db._data.reset_tokens.items() if t.expiry < current_time + ] + for k in to_delete_tokens: + del _db._data.reset_tokens[k] + + +async def flush() -> None: + """Write all pending database changes to disk.""" + from paskia.db.operations import _db + + if _db is None: + return + await flush_changes(_db.db_path, _db._pending_changes) + + +async def _background_loop(): + """Background task that periodically flushes changes and cleans up.""" + # Run cleanup immediately on startup to clear old expired items + cleanup() + await flush() + + last_cleanup = datetime.now(timezone.utc) + + while True: + try: + await asyncio.sleep(FLUSH_INTERVAL) + # Flush pending changes to disk + await flush() + + # Run cleanup less frequently + now = datetime.now(timezone.utc) + if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL: + cleanup() + await flush() # Flush cleanup changes + last_cleanup = now + except asyncio.CancelledError: + # Final flush before exit + await 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 diff --git a/paskia/db/json.py b/paskia/db/json.py deleted file mode 100644 index 76304e7..0000000 --- a/paskia/db/json.py +++ /dev/null @@ -1,1517 +0,0 @@ -""" -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 collections import deque -from contextlib import contextmanager -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from uuid import UUID - -import aiofiles -import base64url -import jsondiff -import msgspec - -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) -# ------------------------------------------------------------------------- - - -class Permission(msgspec.Struct, omit_defaults=True): - """A permission that can be granted to roles.""" - - uuid: UUID # UUID primary key - scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write") - display_name: str - domain: str | None = None # If set, scopes permission to this domain - - -class Role(msgspec.Struct): - """A role within an organization that can be assigned to users.""" - - uuid: UUID - org_uuid: UUID - display_name: str - permissions: list[str] = [] # permission IDs this role grants - - -class Org(msgspec.Struct): - """An organization that contains users and roles.""" - - uuid: UUID - display_name: str - permissions: list[str] = [] # permission IDs this org can grant - roles: list[Role] = [] # roles belonging to this org - - -class User(msgspec.Struct): - """A user in the authentication system.""" - - uuid: UUID - display_name: str - role_uuid: UUID - created_at: datetime | None = None - last_seen: datetime | None = None - visits: int = 0 - - -class Credential(msgspec.Struct): - """A WebAuthn credential (passkey) belonging to a user.""" - - uuid: UUID - credential_id: bytes # Long binary ID from the authenticator - user_uuid: UUID - aaguid: UUID - public_key: bytes - sign_count: int - created_at: datetime - last_used: datetime | None = None - last_verified: datetime | None = None - - -class Session(msgspec.Struct): - """An active user session.""" - - key: bytes - user_uuid: UUID - credential_uuid: UUID - host: str | None - ip: str | None - user_agent: str | None - expiry: datetime - - def metadata(self) -> dict: - """Return session metadata for backwards compatibility.""" - return { - "ip": self.ip, - "user_agent": self.user_agent, - "expiry": self.expiry.isoformat(), - } - - -class ResetToken(msgspec.Struct): - """A token for password reset or device addition.""" - - key: bytes - user_uuid: UUID - expiry: datetime - token_type: str - - -class SessionContext(msgspec.Struct): - """Complete context for an authenticated session.""" - - session: Session - user: User - org: Org - role: Role - credential: Credential | None = None - permissions: list[Permission] | None = None - - -# ------------------------------------------------------------------------- -# Internal storage types (different structure for efficient storage) -# ------------------------------------------------------------------------- - - -class _PermissionData(msgspec.Struct, omit_defaults=True): - scope: str # Permission scope identifier - display_name: str - domain: str | None = None - orgs: dict[str, bool] = {} # org_uuid -> True (which orgs can grant this) - - -class _OrgData(msgspec.Struct): - display_name: str - created_at: datetime | None = None - - -class _RoleData(msgspec.Struct): - org: str - display_name: str - permissions: dict[str, bool] # permission_id -> True - - -class _UserData(msgspec.Struct): - display_name: str - role: str - created_at: datetime - last_seen: datetime | None - visits: int - - -class _CredentialData(msgspec.Struct): - credential_id: bytes # msgspec uses standard base64 - user: str - aaguid: str - public_key: bytes # msgspec uses standard base64 - sign_count: int - created_at: datetime - last_used: datetime | None - last_verified: datetime | None - - -class _SessionData(msgspec.Struct): - user: str - credential: str - host: str | None - ip: str | None - user_agent: str | None - expiry: datetime - - -class _ResetTokenData(msgspec.Struct): - user: str - expiry: datetime - token_type: str - - -class _DatabaseData(msgspec.Struct): - permissions: dict[str, _PermissionData] - orgs: dict[str, _OrgData] - roles: dict[str, _RoleData] - users: dict[str, _UserData] - credentials: dict[str, _CredentialData] - sessions: dict[str, _SessionData] - reset_tokens: dict[str, _ResetTokenData] - - -class _ChangeRecord(msgspec.Struct): - """A single change record in the JSONL file.""" - - ts: datetime - actor: str - diff: dict - - -# msgspec encoder/decoder with built-in conversions -# datetime -> ISO 8601 strings, bytes -> standard base64 -_json_encoder = msgspec.json.Encoder() -_json_decoder = msgspec.json.Decoder(_DatabaseData) - - -def _bytes_to_str(b: bytes | None) -> str | None: - """Convert bytes to base64url string.""" - if b is None: - return None - return base64url.enc(b) - - -def _str_to_bytes(s: str | None) -> bytes | None: - """Convert base64url string to bytes.""" - if s is None: - return None - return base64url.dec(s) - - -# Global database instance (set by init()) -_db: "DB | None" = None -_background_task: asyncio.Task | None = None - -_logger = logging.getLogger(__name__) - - -def get_db() -> "DB": - """Get the global database instance.""" - if _db is None: - raise RuntimeError("Database not initialized. Call init() first.") - 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() - await _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 - await _db.flush() - - # Run cleanup less frequently - now = datetime.now(timezone.utc) - if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL: - _db.cleanup() - await _db.flush() # Flush cleanup changes - last_cleanup = now - except asyncio.CancelledError: - # Final flush before exit - if _db is not None: - await _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 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) - _db.load() - await start_background() - - -class DB: - """JSON-based database implementation. - - 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: Not needed since the app is single-threaded. - - Data structure: - { - "permissions": { "": {"id": ..., "display_name": ...} }, - "orgs": { "": {..., "permissions": [...]} }, - "roles": { "": {..., "permissions": [...]} }, - "users": { "": {...} }, - "credentials": { "": {...} }, - "sessions": { "": {...} }, - "reset_tokens": { "": {...} }, - } - """ - - def __init__(self, db_path: str = DB_PATH_DEFAULT): - """Initialize with database file path.""" - self.db_path = Path(db_path) - self._data: _DatabaseData | None = None - self._previous_builtins: dict[str, Any] = {} # For diffing (JSON-compatible) - self._pending_changes: deque[_ChangeRecord] = deque() - self._current_actor: str = "system" # Default actor for changes - - def _empty_data(self) -> _DatabaseData: - """Return an empty database structure.""" - return _DatabaseData( - permissions={}, - orgs={}, - roles={}, - users={}, - credentials={}, - sessions={}, - reset_tokens={}, - ) - - 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}") - - # 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) - - 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) - current_builtins = msgspec.to_builtins(self._data) - - # Calculate diff between previous and current state (marshal=True for JSON-serializable keys) - diff = jsondiff.diff(self._previous_builtins, current_builtins, marshal=True) - - # Only queue if there are changes - if diff: - change_record = _ChangeRecord( - ts=datetime.now(timezone.utc), - actor=self._current_actor, - diff=diff, - ) - self._pending_changes.append(change_record) - # Update previous builtins for next diff - self._previous_builtins = current_builtins - - async def flush(self) -> None: - """Write all pending changes to disk.""" - if not self._pending_changes: - return - - # 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(): - async with aiofiles.open(self.db_path, encoding="utf-8") as f: - existing_content = await f.read() - - new_content = existing_content + "\n".join(lines) + "\n" - - # Write atomically via temp file - tmp_path = self.db_path.with_suffix(".tmp") - async with aiofiles.open(tmp_path, "w", encoding="utf-8") as f: - await f.write(new_content) - tmp_path.replace(self.db_path) - except OSError: - _logger.exception("Failed to flush database changes") - # Re-queue the changes on failure - 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.""" - old_actor = self._current_actor - self._current_actor = actor - try: - yield - self._queue_change() - finally: - self._current_actor = old_actor - - # ------------------------------------------------------------------------- - # Internal helpers (caller must hold lock) - # ------------------------------------------------------------------------- - - 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, - ) - - def _build_role(self, role_uuid: str, org_filter: bool = False) -> Role: - """Build a Role object from internal storage. Caller must hold lock. - - Args: - role_uuid: The role UUID string - org_filter: If True, filter permissions to only those the org can grant - """ - r = self._data.roles[role_uuid] - permissions = list(r.permissions) - - # Filter by org if requested - if org_filter: - org_uuid = r.org - if org_uuid in self._data.orgs: - org_allowed_scopes = { - p.scope - for pid, p in self._data.permissions.items() - if org_uuid in p.orgs - } - permissions = [p for p in permissions if p in org_allowed_scopes] - - return Role( - uuid=UUID(role_uuid), - org_uuid=UUID(r.org), - display_name=r.display_name, - permissions=permissions, - ) - - 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 permission scopes this org can grant - perm_scopes = [ - p.scope 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_scopes, - ) - if include_roles: - # When building roles for org display, filter by what org can grant - org.roles = [ - self._build_role(role_uuid, org_filter=True) - for role_uuid, r in self._data.roles.items() - if r.org == org_uuid - ] - return org - - 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, - expiry=s.expiry, - ) - - # ------------------------------------------------------------------------- - # User operations - # ------------------------------------------------------------------------- - - def get_user_by_uuid(self, user_uuid: UUID) -> User: - key = str(user_uuid) - if key not in self._data.users: - raise ValueError("User not found") - return self._build_user(key) - - 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, - role=str(user.role_uuid), - created_at=user.created_at or datetime.now(timezone.utc), - last_seen=user.last_seen, - visits=user.visits, - ) - - def update_user_display_name( - self, user_uuid: UUID, display_name: str, actor: str = "system" - ) -> None: - with self.session(actor): - key = str(user_uuid) - if key not in self._data.users: - raise ValueError("User not found") - self._data.users[key].display_name = display_name - - # ------------------------------------------------------------------------- - # Role operations - # ------------------------------------------------------------------------- - - 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), - display_name=role.display_name, - permissions={p: True for p in role.permissions} - if role.permissions - else {}, - ) - - 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") - self._data.roles[key].display_name = role.display_name - self._data.roles[key].permissions = ( - {p: True for p in role.permissions} if role.permissions else {} - ) - - def update_role_name( - self, role_uuid: UUID, display_name: str, actor: str = "system" - ) -> None: - """Update only the role display name (intent-based API).""" - with self.session(actor): - key = str(role_uuid) - if key not in self._data.roles: - raise ValueError("Role not found") - self._data.roles[key].display_name = display_name - - 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(): - if u.role == key: - raise ValueError("Cannot delete role with assigned users") - if key in self._data.roles: - del self._data.roles[key] - - def get_role(self, role_uuid: UUID) -> Role: - key = str(role_uuid) - if key not in self._data.roles: - raise ValueError("Role not found") - return self._build_role(key) - - def get_role_hidden_permissions(self, role_uuid: UUID) -> list[str]: - """Get permission scopes assigned to role but not grantable by its org. - - These are "hidden" permissions that should be preserved when updating - the role, so they can become effective again if the org regains access. - """ - key = str(role_uuid) - if key not in self._data.roles: - return [] - - role_data = self._data.roles[key] - org_uuid = role_data.org - - # Get org's grantable scopes - if org_uuid not in self._data.orgs: - return [] - - org_allowed_scopes = { - p.scope for pid, p in self._data.permissions.items() if org_uuid in p.orgs - } - - # Return scopes in role but not in org - return [ - scope for scope in role_data.permissions if scope not in org_allowed_scopes - ] - - # ------------------------------------------------------------------------- - # Credential operations - # ------------------------------------------------------------------------- - - 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 - user=str(credential.user_uuid), - aaguid=str(credential.aaguid), - public_key=credential.public_key, # Store bytes directly - sign_count=credential.sign_count, - created_at=credential.created_at, - last_used=credential.last_used, - last_verified=credential.last_verified, - ) - - def get_credential_by_id(self, credential_id: bytes) -> Credential: - for key, c in self._data.credentials.items(): - if c.credential_id == credential_id: - return self._build_credential(key) - raise ValueError("Credential not found") - - def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]: - user_key = str(user_uuid) - result: list[bytes] = [] - for c in self._data.credentials.values(): - if c.user == user_key: - cred_id = c.credential_id - if cred_id is not None: - result.append(cred_id) - return result - - 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 - c.created_at = credential.created_at - c.last_used = credential.last_used - c.last_verified = credential.last_verified - return - raise ValueError("Credential not found") - - 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 - c = self._data.credentials[key] - if c.user != str(user_uuid): - return - del self._data.credentials[key] - - # ------------------------------------------------------------------------- - # Session operations - # ------------------------------------------------------------------------- - - def create_session( - self, - user_uuid: UUID, - key: bytes, - credential_uuid: UUID, - host: str, - ip: str, - user_agent: str, - expiry: datetime, - actor: str = "system", - ) -> None: - with self.session(actor): - key_b64 = _bytes_to_str(key) - self._data.sessions[key_b64] = _SessionData( - user=str(user_uuid), - credential=str(credential_uuid), - host=host, - ip=ip, - user_agent=user_agent, - expiry=expiry, - ) - - def get_session(self, key: bytes) -> Session | None: - key_b64 = _bytes_to_str(key) - if key_b64 not in self._data.sessions: - return None - return self._build_session(key_b64) - - 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] - - def update_session( - self, - key: bytes, - *, - ip: str, - user_agent: str, - expiry: datetime, - actor: str = "system", - ) -> Session | None: - with self.session(actor): - key_b64 = _bytes_to_str(key) - if key_b64 not in self._data.sessions: - return None - s = self._data.sessions[key_b64] - s.ip = ip - s.user_agent = user_agent - s.expiry = expiry - return self._build_session(key_b64) - - 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 - - def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: - 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(self._build_session(key_b64)) - # Sort by expiry desc (most recent expiry first) - sessions.sort(key=lambda x: x.expiry, reverse=True) - return sessions - - 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 - ] - for k in to_delete: - del self._data.sessions[k] - - # ------------------------------------------------------------------------- - # Reset token operations - # ------------------------------------------------------------------------- - - def create_reset_token( - self, - user_uuid: UUID, - key: bytes, - expiry: datetime, - token_type: str, - actor: str = "system", - ) -> None: - with self.session(actor): - key_b64 = _bytes_to_str(key) - self._data.reset_tokens[key_b64] = _ResetTokenData( - user=str(user_uuid), - expiry=expiry, - token_type=token_type, - ) - - def get_reset_token(self, key: bytes) -> ResetToken | None: - key_b64 = _bytes_to_str(key) - if key_b64 not in self._data.reset_tokens: - return None - t = self._data.reset_tokens[key_b64] - return ResetToken( - key=_str_to_bytes(key_b64), # type: ignore[arg-type] - user_uuid=UUID(t.user), - expiry=t.expiry, # Already datetime - token_type=t.token_type, - ) - - 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] - - # ------------------------------------------------------------------------- - # Organization operations - # ------------------------------------------------------------------------- - - 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, - ) - - # Update permissions to allow this org to grant them (by scope) - for perm_scope in org.permissions: - # Find permission by scope and add org - for pid, p in self._data.permissions.items(): - if p.scope == perm_scope: - p.orgs[key] = True - break - - # Automatically allow the org to grant the org admin permission if it exists - org_admin_scope = "auth:org:admin" - for pid, p in self._data.permissions.items(): - if p.scope == org_admin_scope: - p.orgs[key] = True - if org_admin_scope not in org.permissions: - org.permissions.append(org_admin_scope) - break - - def get_organization(self, org_id: str) -> Org: - if org_id not in self._data.orgs: - raise ValueError("Organization not found") - return self._build_org(org_id, include_roles=True) - - def list_organizations(self) -> list[Org]: - return [ - self._build_org(org_uuid, include_roles=True) - for org_uuid in self._data.orgs - ] - - 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") - self._data.orgs[key].display_name = org.display_name - # Update which permissions this org can grant (by scope) - # First remove this org from all permissions - for p in self._data.permissions.values(): - if key in p.orgs: - del p.orgs[key] - # Then add this org to the specified permissions (by scope) - for perm_scope in org.permissions: - for pid, p in self._data.permissions.items(): - if p.scope == perm_scope: - p.orgs[key] = True - break - - def update_organization_name( - self, org_uuid: UUID, display_name: str, actor: str = "system" - ) -> None: - """Update only the organization display name (intent-based API).""" - with self.session(actor): - key = str(org_uuid) - if key not in self._data.orgs: - raise ValueError("Organization not found") - self._data.orgs[key].display_name = display_name - - 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] - # Cascade delete roles belonging to this org - to_delete = [k for k, r in self._data.roles.items() if r.org == key] - for k in to_delete: - del self._data.roles[k] - - def add_user_to_organization( - self, user_uuid: UUID, org_id: str, role: str, actor: str = "system" - ) -> None: - with self.session(actor): - user_key = str(user_uuid) - if user_key not in self._data.users: - raise ValueError("User not found") - if org_id not in self._data.orgs: - raise ValueError("Organization not found") - # Find role by display_name in org - role_uuid = None - for role_key, r in self._data.roles.items(): - if r.org == org_id and r.display_name == role: - role_uuid = role_key - break - if role_uuid is None: - raise ValueError("Role not found in organization") - self._data.users[user_key].role = role_uuid - - 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") - - def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: - user_key = str(user_uuid) - if user_key not in self._data.users: - raise ValueError("User not found") - role_uuid = self._data.users[user_key].role - if role_uuid not in self._data.roles: - raise ValueError("Role not found") - r = self._data.roles[role_uuid] - if r.org not in self._data.orgs: - raise ValueError("Organization not found") - return self._build_org(r.org), r.display_name - - def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: - # Get all roles for this org - org_role_uuids = { - role_uuid for role_uuid, r in self._data.roles.items() if r.org == org_id - } - 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 - ] - - def get_roles_by_organization(self, org_id: str) -> list[Role]: - return [ - self._build_role(role_uuid) - for role_uuid, r in self._data.roles.items() - if r.org == org_id - ] - - def get_user_role_in_organization(self, user_uuid: UUID, org_id: str) -> str | None: - user_key = str(user_uuid) - if user_key not in self._data.users: - return None - role_uuid = self._data.users[user_key].role - if role_uuid not in self._data.roles: - return None - r = self._data.roles[role_uuid] - if r.org != org_id: - return None - return r.display_name - - def update_user_role_in_organization( - self, user_uuid: UUID, new_role: str, actor: str = "system" - ) -> None: - with self.session(actor): - user_key = str(user_uuid) - if user_key not in self._data.users: - raise ValueError("User not found") - current_role_uuid = self._data.users[user_key].role - if current_role_uuid not in self._data.roles: - raise ValueError("Current role not found") - org_uuid = self._data.roles[current_role_uuid].org - # Find new role - new_role_uuid = None - for role_uuid_str, r in self._data.roles.items(): - if r.org == org_uuid and r.display_name == new_role: - new_role_uuid = role_uuid_str - break - if new_role_uuid is None: - raise ValueError("Role not found in user's organization") - self._data.users[user_key].role = new_role_uuid - - # ------------------------------------------------------------------------- - # Permission operations - # ------------------------------------------------------------------------- - - def create_permission(self, permission: Permission, actor: str = "system") -> None: - with self.session(actor): - key = str(permission.uuid) - self._data.permissions[key] = _PermissionData( - scope=permission.scope, - display_name=permission.display_name, - domain=permission.domain, - orgs={}, # Will be populated when orgs are allowed to grant this permission - ) - - def get_permission(self, permission_id: str) -> Permission: - """Get a permission by UUID string or scope. - - For backwards compatibility, this accepts either: - - A UUID string (the primary key) - - A scope string (searches for matching scope) - """ - # First try as UUID key - if permission_id in self._data.permissions: - p = self._data.permissions[permission_id] - return Permission( - uuid=UUID(permission_id), - scope=p.scope, - display_name=p.display_name, - domain=p.domain, - ) - # Fall back to scope search - for pid, p in self._data.permissions.items(): - if p.scope == permission_id: - return Permission( - uuid=UUID(pid), - scope=p.scope, - display_name=p.display_name, - domain=p.domain, - ) - raise ValueError("Permission not found") - - def get_permission_by_scope(self, scope: str) -> Permission | None: - """Get a permission by its scope string.""" - for pid, p in self._data.permissions.items(): - if p.scope == scope: - return Permission( - uuid=UUID(pid), - scope=p.scope, - display_name=p.display_name, - domain=p.domain, - ) - return None - - def list_permissions(self) -> list[Permission]: - return [ - Permission( - uuid=UUID(pid), - scope=p.scope, - display_name=p.display_name, - domain=p.domain, - ) - for pid, p in self._data.permissions.items() - ] - - def update_permission(self, permission: Permission, actor: str = "system") -> None: - with self.session(actor): - key = str(permission.uuid) - if key not in self._data.permissions: - raise ValueError("Permission not found") - self._data.permissions[key].scope = permission.scope - self._data.permissions[key].display_name = permission.display_name - self._data.permissions[key].domain = permission.domain - - def delete_permission(self, permission_id: str, actor: str = "system") -> None: - """Delete a permission by UUID string or scope.""" - with self.session(actor): - # Find the UUID key - key = self._resolve_permission_key(permission_id) - if key and key in self._data.permissions: - scope = self._data.permissions[key].scope - del self._data.permissions[key] - # Remove from roles (roles store scopes, not UUIDs) - for r in self._data.roles.values(): - if scope in r.permissions: - del r.permissions[scope] - - def _resolve_permission_key(self, permission_id: str) -> str | None: - """Resolve a permission_id (UUID or scope) to its UUID key.""" - if permission_id in self._data.permissions: - return permission_id - for pid, p in self._data.permissions.items(): - if p.scope == permission_id: - return pid - return None - - def _resolve_permission_scope(self, permission_id: str) -> str | None: - """Resolve a permission_id (UUID or scope) to its scope.""" - if permission_id in self._data.permissions: - return self._data.permissions[permission_id].scope - for pid, p in self._data.permissions.items(): - if p.scope == permission_id: - return p.scope - return None - - def rename_permission( - self, - old_scope: str, - new_scope: str, - display_name: str, - domain: str | None = None, - actor: str = "system", - ) -> None: - """Rename a permission's scope. The UUID remains the same.""" - with self.session(actor): - # Find the permission by scope - key = self._resolve_permission_key(old_scope) - if not key: - raise ValueError("Original permission not found") - - # Check if new scope already exists - for pid, p in self._data.permissions.items(): - if p.scope == new_scope and pid != key: - raise ValueError("New permission scope already exists") - - old_scope_value = self._data.permissions[key].scope - - # Update the permission - self._data.permissions[key].scope = new_scope - self._data.permissions[key].display_name = display_name - self._data.permissions[key].domain = domain - - # Update role references if scope changed - if old_scope_value != new_scope: - for r in self._data.roles.values(): - if old_scope_value in r.permissions: - del r.permissions[old_scope_value] - r.permissions[new_scope] = True - - def add_permission_to_organization( - self, org_id: str, permission_id: str, actor: str = "system" - ) -> None: - """Add a permission to an organization (allows org to grant it). - - permission_id can be a UUID string or a scope string. - """ - with self.session(actor): - if org_id not in self._data.orgs: - raise ValueError("Organization not found") - key = self._resolve_permission_key(permission_id) - if not key: - raise ValueError("Permission not found") - self._data.permissions[key].orgs[org_id] = True - - def remove_permission_from_organization( - self, org_id: str, permission_id: str, actor: str = "system" - ) -> None: - """Remove a permission from an organization. - - permission_id can be a UUID string or a scope string. - """ - with self.session(actor): - key = self._resolve_permission_key(permission_id) - if key and key in self._data.permissions: - orgs = self._data.permissions[key].orgs - if org_id in orgs: - del orgs[org_id] - - def get_organization_permissions(self, org_id: str) -> list[Permission]: - if org_id not in self._data.orgs: - raise ValueError("Organization not found") - permissions = [] - for pid, p in self._data.permissions.items(): - if org_id in p.orgs: - permissions.append( - Permission( - uuid=UUID(pid), - scope=p.scope, - display_name=p.display_name, - domain=p.domain, - ) - ) - return permissions - - def get_permission_organizations(self, permission_id: str) -> list[Org]: - """Get organizations that can grant a permission. - - permission_id can be a UUID string or a scope string. - """ - key = self._resolve_permission_key(permission_id) - if not key or key not in self._data.permissions: - return [] - org_ids = self._data.permissions[key].orgs - return [ - self._build_org(org_id) for org_id in org_ids if org_id in self._data.orgs - ] - - # ------------------------------------------------------------------------- - # Role-permission operations - # ------------------------------------------------------------------------- - - def add_permission_to_role( - self, role_uuid: UUID, permission_id: str, actor: str = "system" - ) -> None: - """Add a permission to a role. - - permission_id can be a UUID string or a scope string. - Stores the scope in the role's permissions dict. - """ - with self.session(actor): - key = str(role_uuid) - if key not in self._data.roles: - raise ValueError("Role not found") - scope = self._resolve_permission_scope(permission_id) - if not scope: - raise ValueError("Permission not found") - self._data.roles[key].permissions[scope] = True - - def remove_permission_from_role( - self, role_uuid: UUID, permission_id: str, actor: str = "system" - ) -> None: - """Remove a permission from a role. - - permission_id can be a UUID string or a scope string. - """ - with self.session(actor): - key = str(role_uuid) - if key in self._data.roles: - # Try to find the scope - scope = self._resolve_permission_scope(permission_id) - if scope and scope in self._data.roles[key].permissions: - del self._data.roles[key].permissions[scope] - # Also try the raw permission_id in case it's already a scope - elif permission_id in self._data.roles[key].permissions: - del self._data.roles[key].permissions[permission_id] - - def get_role_permissions( - self, role_uuid: UUID, filter_by_org: bool = True - ) -> list[Permission]: - """Get permissions granted by a role. - - Note: Roles store scopes, so we need to look up permissions by scope. - - Args: - role_uuid: The role UUID - filter_by_org: If True, only return permissions that the role's org - can grant. Set to False to see all assigned permissions - regardless of org restrictions. - """ - key = str(role_uuid) - if key not in self._data.roles: - return [] - role_data = self._data.roles[key] - scopes = list(role_data.permissions.keys()) - - # Get org permissions if filtering - org_allowed_scopes = None - if filter_by_org: - org_uuid = role_data.org - if org_uuid in self._data.orgs: - org_allowed_scopes = { - p.scope - for pid, p in self._data.permissions.items() - if org_uuid in p.orgs - } - - permissions = [] - for scope in scopes: - # Skip if org filtering is enabled and scope not allowed by org - if org_allowed_scopes is not None and scope not in org_allowed_scopes: - continue - - # Find permission with this scope - for pid, p in self._data.permissions.items(): - if p.scope == scope: - permissions.append( - Permission( - uuid=UUID(pid), - scope=p.scope, - display_name=p.display_name, - domain=p.domain, - ) - ) - break - return permissions - - def get_permission_roles(self, permission_id: str) -> list[Role]: - """Get roles that have a permission. - - permission_id can be a UUID string or a scope string. - """ - scope = self._resolve_permission_scope(permission_id) - if not scope: - return [] - return [ - self._build_role(role_uuid) - for role_uuid, r in self._data.roles.items() - if scope in r.permissions - ] - - # ------------------------------------------------------------------------- - # Combined operations - # ------------------------------------------------------------------------- - - 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: - c.sign_count = credential.sign_count - c.created_at = credential.created_at - c.last_used = credential.last_used - c.last_verified = credential.last_verified - break - - # Update user - user_key = str(user_uuid) - if user_key in self._data.users: - self._data.users[user_key].last_seen = credential.last_used - self._data.users[user_key].visits = ( - self._data.users[user_key].visits + 1 - ) - - def create_user_and_credential( - self, user: User, credential: Credential, actor: str = "system" - ) -> None: - with self.session(actor): - # Create user - user_key = str(user.uuid) - self._data.users[user_key] = _UserData( - display_name=user.display_name, - role=str(user.role_uuid), - created_at=user.created_at or datetime.now(timezone.utc), - last_seen=user.last_seen, - visits=user.visits, - ) - # Create credential - cred_key = str(credential.uuid) - self._data.credentials[cred_key] = _CredentialData( - credential_id=credential.credential_id, # Store bytes directly - user=str(credential.user_uuid), - aaguid=str(credential.aaguid), - public_key=credential.public_key, # Store bytes directly - sign_count=credential.sign_count, - created_at=credential.created_at, - last_used=credential.last_used, - last_verified=credential.last_verified, - ) - - def create_credential_session( - self, - user_uuid: UUID, - credential: Credential, - reset_key: bytes | None, - session_key: bytes, - *, - display_name: str | None = None, - host: str | None = None, - ip: str | None = None, - user_agent: str | None = None, - actor: str = "system", - ) -> None: - with self.session(actor): - user_key = str(user_uuid) - # Ensure credential has last_used / last_verified - if credential.last_used is None: - credential.last_used = credential.created_at - if credential.last_verified is None: - credential.last_verified = credential.last_used - - # Insert credential - cred_key = str(credential.uuid) - self._data.credentials[cred_key] = _CredentialData( - credential_id=credential.credential_id, # Store bytes directly - user=str(credential.user_uuid), - aaguid=str(credential.aaguid), - public_key=credential.public_key, # Store bytes directly - sign_count=credential.sign_count, - created_at=credential.created_at, - last_used=credential.last_used, - last_verified=credential.last_verified, - ) - - # Delete old reset token if provided - if reset_key: - reset_key_b64 = _bytes_to_str(reset_key) - if reset_key_b64 in self._data.reset_tokens: - del self._data.reset_tokens[reset_key_b64] - - # Optional rename - if display_name and user_key in self._data.users: - self._data.users[user_key].display_name = display_name - - # New session - compute expiry from credential.last_used - sess_key_b64 = _bytes_to_str(session_key) - self._data.sessions[sess_key_b64] = _SessionData( - user=user_key, - credential=cred_key, - host=host, - ip=ip, - user_agent=user_agent, - expiry=credential.last_used + SESSION_LIFETIME, - ) - - # Login side-effects - if user_key in self._data.users: - self._data.users[user_key].last_seen = credential.last_used - self._data.users[user_key].visits = ( - self._data.users[user_key].visits + 1 - ) - - def cleanup(self) -> None: - """Remove expired sessions and reset tokens.""" - with self.session("expiry"): - current_time = datetime.now(timezone.utc) - - # Clean expired sessions - to_delete_sessions = [] - for k, s in self._data.sessions.items(): - if s.expiry < current_time: - to_delete_sessions.append(k) - for k in to_delete_sessions: - del self._data.sessions[k] - - # Clean expired reset tokens - to_delete_tokens = [] - for k, t in self._data.reset_tokens.items(): - if t.expiry < current_time: - to_delete_tokens.append(k) - for k in to_delete_tokens: - del self._data.reset_tokens[k] - - def get_session_context( - self, session_key: bytes, host: str | None = None - ) -> SessionContext | None: - """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. - """ - sess_key_b64 = _bytes_to_str(session_key) - if sess_key_b64 not in self._data.sessions: - return None - - s = self._data.sessions[sess_key_b64] - - # Handle host binding - if host is not None: - if s.host is None: - s.host = host - self._queue_change() # Queue change for host binding - elif s.host != host: - return None - - # Validate user exists - user_key = s.user - if user_key not in self._data.users: - return None - - # Validate role exists - role_uuid = self._data.users[user_key].role - if role_uuid not in self._data.roles: - return None - - # Validate org exists - org_uuid = self._data.roles[role_uuid].org - if org_uuid not in self._data.orgs: - return None - - # 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 = ( - self._build_credential(cred_uuid) - if cred_uuid in self._data.credentials - else None - ) - - # Effective permissions: role permissions (scopes) that the org can grant - # role_obj.permissions contains scopes, org_obj.permissions contains scopes - from paskia.util.hostutil import normalize_host - - normalized_host = normalize_host(host) - # Strip port for domain matching (e.g., localhost:4401 -> localhost) - host_without_port = ( - normalized_host.rsplit(":", 1)[0] if normalized_host else None - ) - effective_permissions = [] - for scope in role_obj.permissions: - if scope not in org_obj.permissions: - continue - # Find the permission by scope - for pid, p in self._data.permissions.items(): - if p.scope == scope: - # Check domain restriction (compare without port) - if p.domain is not None and p.domain != host_without_port: - continue - effective_permissions.append( - Permission( - uuid=UUID(pid), - scope=p.scope, - display_name=p.display_name, - domain=p.domain, - ) - ) - break - - return SessionContext( - session=session_obj, - user=user_obj, - org=org_obj, - role=role_obj, - credential=credential_obj, - permissions=effective_permissions or None, - ) diff --git a/paskia/db/jsonl.py b/paskia/db/jsonl.py new file mode 100644 index 0000000..a009321 --- /dev/null +++ b/paskia/db/jsonl.py @@ -0,0 +1,125 @@ +""" +JSONL persistence layer for the database. + +Handles file I/O, JSON diffs, and persistence. Works with plain JSON/dict data. +Uses aiofiles for async I/O operations. +""" + +import logging +from collections import deque +from datetime import datetime, timezone +from pathlib import Path + +import aiofiles +import jsondiff +import msgspec + +_logger = logging.getLogger(__name__) + +# Default database path +DB_PATH_DEFAULT = "paskia.jsonl" + + +class _ChangeRecord(msgspec.Struct): + """A single change record in the JSONL file.""" + + ts: datetime + actor: str + diff: dict + + +# msgspec encoder for change records +_change_encoder = msgspec.json.Encoder() + + +async def load_jsonl(db_path: Path, empty_data: dict) -> dict: + """Load data from disk by applying change log. + + Replays all changes from JSONL file using plain dicts (to handle + schema evolution). + + Args: + db_path: Path to the JSONL database file + empty_data: Empty data structure to start with (as dict) + + Returns: + The final state after applying all changes + """ + data_dict = empty_data.copy() + if db_path.exists(): + try: + # Read entire file at once and split into lines + async with aiofiles.open(db_path, "rb") as f: + content = await f.read() + for line_num, line in enumerate(content.split(b"\n"), 1): + line = line.strip() + if not line: + continue + try: + change = msgspec.json.decode(line) + # 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}") + return data_dict + + +def compute_diff(previous: dict, current: dict) -> dict | None: + """Compute JSON diff between two states. + + Args: + previous: Previous state (JSON-compatible dict) + current: Current state (JSON-compatible dict) + + Returns: + The diff, or None if no changes + """ + diff = jsondiff.diff(previous, current, marshal=True) + return diff if diff else None + + +def create_change_record(actor: str, diff: dict) -> _ChangeRecord: + """Create a change record for persistence.""" + return _ChangeRecord( + ts=datetime.now(timezone.utc), + actor=actor, + diff=diff, + ) + + +async def flush_changes( + db_path: Path, + pending_changes: deque[_ChangeRecord], +) -> bool: + """Write all pending changes to disk. + + Args: + db_path: Path to the JSONL database file + pending_changes: Queue of pending change records (will be cleared on success) + + Returns: + True if flush succeeded, False otherwise + """ + if not pending_changes: + return True + + # Collect all pending changes + changes_to_write = list(pending_changes) + pending_changes.clear() + + try: + # Build lines to append (keep as bytes, join with \n) + lines = [_change_encoder.encode(change) for change in changes_to_write] + + # Append all lines in a single write (binary mode for Windows compatibility) + async with aiofiles.open(db_path, "ab") as f: + await f.write(b"\n".join(lines) + b"\n") + return True + except OSError: + _logger.exception("Failed to flush database changes") + # Re-queue the changes on failure + for change in reversed(changes_to_write): + pending_changes.appendleft(change) + return False diff --git a/paskia/db/operations.py b/paskia/db/operations.py new file mode 100644 index 0000000..977fad1 --- /dev/null +++ b/paskia/db/operations.py @@ -0,0 +1,1014 @@ +""" +Database for WebAuthn passkey authentication. + +Read operations: Access _db._data directly, use build_* helpers to get public structs. +Context lookup: get_session_context() returns full SessionContext with effective permissions. +Write operations: Functions that validate and commit, or raise ValueError. +""" + +import os +from collections import deque +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import UUID + +import base64url +import msgspec + +from paskia.db.jsonl import ( + DB_PATH_DEFAULT, + _ChangeRecord, + compute_diff, + create_change_record, + load_jsonl, +) +from paskia.db.structs import ( + Credential, + Org, + Permission, + ResetToken, + Role, + Session, + SessionContext, + User, + _CredentialData, + _DatabaseData, + _OrgData, + _PermissionData, + _ResetTokenData, + _RoleData, + _SessionData, + _UserData, +) + +# msgspec encoder/decoder +_json_encoder = msgspec.json.Encoder() +_json_decoder = msgspec.json.Decoder(_DatabaseData) + + +def _b64(b: bytes | None) -> str | None: + return base64url.enc(b) if b else None + + +def _unb64(s: str | None) -> bytes | None: + return base64url.dec(s) if s else None + + +class DB: + """In-memory database with JSONL persistence. + + Access data directly via _data for reads. + Use transaction() context manager for writes. + """ + + def __init__(self, db_path: str = DB_PATH_DEFAULT): + self.db_path = Path(db_path) + self._data = _DatabaseData( + permissions={}, + orgs={}, + roles={}, + users={}, + credentials={}, + sessions={}, + reset_tokens={}, + ) + self._previous_builtins: dict[str, Any] = {} + self._pending_changes: deque[_ChangeRecord] = deque() + self._current_actor: str = "system" + + async def load(self, db_path: str | None = None) -> None: + """Load data from JSONL change log.""" + if db_path is not None: + self.db_path = Path(db_path) + empty = msgspec.to_builtins(self._data) + data_dict = await load_jsonl(self.db_path, empty) + self._data = _json_decoder.decode(_json_encoder.encode(data_dict)) + self._previous_builtins = msgspec.to_builtins(self._data) + + def _queue_change(self) -> None: + current = msgspec.to_builtins(self._data) + diff = compute_diff(self._previous_builtins, current) + if diff: + self._pending_changes.append( + create_change_record(self._current_actor, diff) + ) + self._previous_builtins = current + + @contextmanager + def transaction(self, actor: str = "system"): + """Wrap writes in transaction. Queues change on successful exit.""" + old_actor = self._current_actor + self._current_actor = actor + try: + yield + self._queue_change() + finally: + self._current_actor = old_actor + + +# Global instance, always available (empty until init() loads data) +_db = DB() + + +async def init(*args, **kwargs): + """Load database and start background flush task.""" + from paskia.db.background import start_background + + db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) + if db_path.startswith("json:"): + db_path = db_path[5:] + await _db.load(db_path) + await start_background() + + +# ------------------------------------------------------------------------- +# Builders: Convert internal _*Data to public structs +# ------------------------------------------------------------------------- + + +def build_permission(uuid: str) -> Permission: + p = _db._data.permissions[uuid] + return Permission( + uuid=UUID(uuid), scope=p.scope, display_name=p.display_name, domain=p.domain + ) + + +def build_user(uuid: str) -> User: + u = _db._data.users[uuid] + return User( + uuid=UUID(uuid), + display_name=u.display_name, + role_uuid=UUID(u.role), + created_at=u.created_at, + last_seen=u.last_seen, + visits=u.visits, + ) + + +def build_role(uuid: str) -> Role: + r = _db._data.roles[uuid] + return Role( + uuid=UUID(uuid), + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions.keys()), + ) + + +def build_org(uuid: str, include_roles: bool = False) -> Org: + o = _db._data.orgs[uuid] + perm_scopes = [p.scope for p in _db._data.permissions.values() if uuid in p.orgs] + org = Org(uuid=UUID(uuid), display_name=o.display_name, permissions=perm_scopes) + if include_roles: + org.roles = [ + build_role(rid) for rid, r in _db._data.roles.items() if r.org == uuid + ] + return org + + +def build_credential(uuid: str) -> Credential: + c = _db._data.credentials[uuid] + return Credential( + uuid=UUID(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(key_b64: str) -> Session: + s = _db._data.sessions[key_b64] + return Session( + key=_unb64(key_b64), # type: ignore + user_uuid=UUID(s.user), + credential_uuid=UUID(s.credential), + host=s.host, + ip=s.ip, + user_agent=s.user_agent, + expiry=s.expiry, + ) + + +def build_reset_token(key_b64: str) -> ResetToken: + t = _db._data.reset_tokens[key_b64] + return ResetToken( + key=_unb64(key_b64), + user_uuid=UUID(t.user), + expiry=t.expiry, + token_type=t.token_type, + ) # type: ignore + + +# ------------------------------------------------------------------------- +# Read/lookup functions +# ------------------------------------------------------------------------- + + +def get_permission(permission_id: str | UUID) -> Permission | None: + """Get permission by UUID or scope. + + For backwards compatibility, this accepts either: + - A UUID string (the primary key) + - A scope string (searches for matching scope) + """ + permission_id = str(permission_id) + # First try as UUID key + if permission_id in _db._data.permissions: + return build_permission(permission_id) + # Fall back to scope search + for uuid, p in _db._data.permissions.items(): + if p.scope == permission_id: + return build_permission(uuid) + return None + + +def get_permission_by_scope(scope: str) -> Permission | None: + """Get permission by scope identifier.""" + for uuid, p in _db._data.permissions.items(): + if p.scope == scope: + return build_permission(uuid) + return None + + +def list_permissions() -> list[Permission]: + """List all permissions.""" + return [build_permission(uuid) for uuid in _db._data.permissions] + + +def get_permission_organizations(scope: str) -> list[Org]: + """Get organizations that can grant a permission scope.""" + for p in _db._data.permissions.values(): + if p.scope == scope: + return [build_org(org_uuid) for org_uuid in p.orgs] + return [] + + +def get_organization(uuid: str | UUID) -> Org | None: + """Get organization by UUID.""" + uuid = str(uuid) + return build_org(uuid, include_roles=True) if uuid in _db._data.orgs else None + + +def list_organizations() -> list[Org]: + """List all organizations.""" + return [build_org(uuid, include_roles=True) for uuid in _db._data.orgs] + + +def get_organization_users(org_uuid: str | UUID) -> list[tuple[User, str]]: + """Get all users in an organization with their role names.""" + org_uuid = str(org_uuid) + role_map = { + rid: r.display_name for rid, r in _db._data.roles.items() if r.org == org_uuid + } + return [ + (build_user(uid), role_map[u.role]) + for uid, u in _db._data.users.items() + if u.role in role_map + ] + + +def get_role(uuid: str | UUID) -> Role | None: + """Get role by UUID.""" + uuid = str(uuid) + return build_role(uuid) if uuid in _db._data.roles else None + + +def get_roles_by_organization(org_uuid: str | UUID) -> list[Role]: + """Get all roles in an organization.""" + org_uuid = str(org_uuid) + return [build_role(rid) for rid, r in _db._data.roles.items() if r.org == org_uuid] + + +def get_user_by_uuid(uuid: str | UUID) -> User | None: + """Get user by UUID.""" + uuid = str(uuid) + return build_user(uuid) if uuid in _db._data.users else None + + +def get_user_organization(user_uuid: str | UUID) -> tuple[Org, str]: + """Get the organization a user belongs to and their role name. + + Raises ValueError if user not found. + """ + user_uuid = str(user_uuid) + if user_uuid not in _db._data.users: + raise ValueError(f"User {user_uuid} not found") + role_uuid = _db._data.users[user_uuid].role + if role_uuid not in _db._data.roles: + raise ValueError(f"Role {role_uuid} not found") + role_data = _db._data.roles[role_uuid] + org_uuid = role_data.org + return build_org(org_uuid, include_roles=True), role_data.display_name + + +def get_credential_by_id(credential_id: bytes) -> Credential | None: + """Get credential by credential_id (the authenticator's ID).""" + for uuid, c in _db._data.credentials.items(): + if c.credential_id == credential_id: + return build_credential(uuid) + return None + + +def get_credentials_by_user_uuid(user_uuid: str | UUID) -> list[Credential]: + """Get all credentials for a user.""" + user_uuid = str(user_uuid) + return [ + build_credential(cid) + for cid, c in _db._data.credentials.items() + if c.user == user_uuid + ] + + +def get_session(key: bytes) -> Session | None: + """Get session by key.""" + key_b64 = _b64(key) + if key_b64 not in _db._data.sessions: + return None + s = _db._data.sessions[key_b64] + if s.expiry < datetime.now(timezone.utc): + return None + return build_session(key_b64) + + +def list_sessions_for_user(user_uuid: str | UUID) -> list[Session]: + """Get all active sessions for a user.""" + user_uuid = str(user_uuid) + now = datetime.now(timezone.utc) + return [ + build_session(k) + for k, s in _db._data.sessions.items() + if s.user == user_uuid and s.expiry >= now + ] + + +def get_reset_token(key: bytes) -> ResetToken | None: + """Get reset token by key.""" + key_b64 = _b64(key) + if key_b64 not in _db._data.reset_tokens: + return None + t = _db._data.reset_tokens[key_b64] + if t.expiry < datetime.now(timezone.utc): + return None + return build_reset_token(key_b64) + + +# ------------------------------------------------------------------------- +# Context lookup +# ------------------------------------------------------------------------- + + +def get_session_context( + session_key: bytes, host: str | None = None +) -> SessionContext | None: + """Get full session context with effective permissions. + + Args: + session_key: The session key bytes + host: Optional host for binding/validation and domain-scoped permissions + + Returns: + SessionContext if valid, None if session not found, expired, or host mismatch + """ + from paskia.util.hostutil import normalize_host + + key_b64 = _b64(session_key) + if key_b64 not in _db._data.sessions: + return None + + s = _db._data.sessions[key_b64] + if s.expiry < datetime.now(timezone.utc): + return None + + # Handle host binding + if host is not None: + if s.host is None: + # Bind session to this host + with _db.transaction("host_binding"): + s.host = host + elif s.host != host: + # Session bound to different host + return None + + # Validate user exists + if s.user not in _db._data.users: + return None + + # Validate role exists + role_uuid = _db._data.users[s.user].role + if role_uuid not in _db._data.roles: + return None + + # Validate org exists + org_uuid = _db._data.roles[role_uuid].org + if org_uuid not in _db._data.orgs: + return None + + session = build_session(key_b64) + user = build_user(s.user) + role = build_role(role_uuid) + org = build_org(org_uuid) + credential = ( + build_credential(s.credential) + if s.credential in _db._data.credentials + else None + ) + + # Effective permissions: role's permission scopes that the org can grant + # Also filter by domain if host is provided + org_scopes = set(org.permissions) + normalized_host = normalize_host(host) + host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None + + effective_perms = [] + for scope in role.permissions: + if scope not in org_scopes: + continue + # Find permission by scope + for pid, p in _db._data.permissions.items(): + if p.scope == scope: + # Check domain restriction + if p.domain is not None and p.domain != host_without_port: + continue + effective_perms.append(build_permission(pid)) + break + + return SessionContext( + session=session, + user=user, + org=org, + role=role, + credential=credential, + permissions=effective_perms or None, + ) + + +# ------------------------------------------------------------------------- +# Write operations (validate, modify, commit or raise ValueError) +# ------------------------------------------------------------------------- + + +def create_permission(perm: Permission, actor: str = "system") -> None: + """Create a new permission.""" + uuid = str(perm.uuid) + if uuid in _db._data.permissions: + raise ValueError(f"Permission {uuid} already exists") + with _db.transaction(actor): + _db._data.permissions[uuid] = _PermissionData( + scope=perm.scope, + display_name=perm.display_name, + domain=perm.domain, + orgs={}, + ) + + +def update_permission(perm: Permission, actor: str = "system") -> None: + """Update a permission's scope, display_name, and domain.""" + uuid = str(perm.uuid) + if uuid not in _db._data.permissions: + raise ValueError(f"Permission {uuid} not found") + with _db.transaction(actor): + _db._data.permissions[uuid].scope = perm.scope + _db._data.permissions[uuid].display_name = perm.display_name + _db._data.permissions[uuid].domain = perm.domain + + +def rename_permission( + old_scope: str, + new_scope: str, + display_name: str, + domain: str | None = None, + actor: str = "system", +) -> None: + """Rename a permission's scope. The UUID remains the same. + + Also updates all role references to use the new scope. + """ + # Find permission by old scope + key = None + for pid, p in _db._data.permissions.items(): + if p.scope == old_scope: + key = pid + break + if not key: + raise ValueError(f"Permission with scope '{old_scope}' not found") + + # Check if new scope already exists (on a different permission) + for pid, p in _db._data.permissions.items(): + if p.scope == new_scope and pid != key: + raise ValueError(f"Permission with scope '{new_scope}' already exists") + + with _db.transaction(actor): + # Update the permission + _db._data.permissions[key].scope = new_scope + _db._data.permissions[key].display_name = display_name + _db._data.permissions[key].domain = domain + + # Update role references if scope changed + if old_scope != new_scope: + for r in _db._data.roles.values(): + if old_scope in r.permissions: + del r.permissions[old_scope] + r.permissions[new_scope] = True + + +def delete_permission(uuid: str | UUID, actor: str = "system") -> None: + """Delete a permission.""" + uuid = str(uuid) + if uuid not in _db._data.permissions: + raise ValueError(f"Permission {uuid} not found") + with _db.transaction(actor): + del _db._data.permissions[uuid] + + +def create_organization(org: Org, actor: str = "system") -> None: + """Create a new organization.""" + uuid = str(org.uuid) + if uuid in _db._data.orgs: + raise ValueError(f"Organization {uuid} already exists") + with _db.transaction(actor): + _db._data.orgs[uuid] = _OrgData( + display_name=org.display_name, created_at=datetime.now(timezone.utc) + ) + # Grant listed permissions to this org + for scope in org.permissions: + for pid, p in _db._data.permissions.items(): + if p.scope == scope: + p.orgs[uuid] = True + + +def update_organization_name( + uuid: str | UUID, display_name: str, actor: str = "system" +) -> None: + """Update organization display name.""" + uuid = str(uuid) + if uuid not in _db._data.orgs: + raise ValueError(f"Organization {uuid} not found") + with _db.transaction(actor): + _db._data.orgs[uuid].display_name = display_name + + +def delete_organization(uuid: str | UUID, actor: str = "system") -> None: + """Delete organization and all its roles/users.""" + uuid = str(uuid) + if uuid not in _db._data.orgs: + raise ValueError(f"Organization {uuid} not found") + with _db.transaction(actor): + # Remove org from all permissions + for p in _db._data.permissions.values(): + p.orgs.pop(uuid, None) + # Delete roles in this org + role_uuids = [rid for rid, r in _db._data.roles.items() if r.org == uuid] + for rid in role_uuids: + del _db._data.roles[rid] + # Delete users with those roles + user_uuids = [uid for uid, u in _db._data.users.items() if u.role in role_uuids] + for uid in user_uuids: + del _db._data.users[uid] + del _db._data.orgs[uuid] + + +def add_permission_to_organization( + org_uuid: str | UUID, permission_scope: str, actor: str = "system" +) -> None: + """Grant a permission scope to an organization.""" + org_uuid = str(org_uuid) + if org_uuid not in _db._data.orgs: + raise ValueError(f"Organization {org_uuid} not found") + found = False + with _db.transaction(actor): + for p in _db._data.permissions.values(): + if p.scope == permission_scope: + p.orgs[org_uuid] = True + found = True + if not found: + raise ValueError(f"Permission scope {permission_scope} not found") + + +def remove_permission_from_organization( + org_uuid: str | UUID, permission_scope: str, actor: str = "system" +) -> None: + """Remove a permission scope from an organization.""" + org_uuid = str(org_uuid) + if org_uuid not in _db._data.orgs: + raise ValueError(f"Organization {org_uuid} not found") + with _db.transaction(actor): + for p in _db._data.permissions.values(): + if p.scope == permission_scope: + p.orgs.pop(org_uuid, None) + + +def create_role(role: Role, actor: str = "system") -> None: + """Create a new role.""" + uuid = str(role.uuid) + org_uuid = str(role.org_uuid) + if uuid in _db._data.roles: + raise ValueError(f"Role {uuid} already exists") + if org_uuid not in _db._data.orgs: + raise ValueError(f"Organization {org_uuid} not found") + with _db.transaction(actor): + _db._data.roles[uuid] = _RoleData( + org=org_uuid, + display_name=role.display_name, + permissions={scope: True for scope in role.permissions}, + ) + + +def update_role_name( + uuid: str | UUID, display_name: str, actor: str = "system" +) -> None: + """Update role display name.""" + uuid = str(uuid) + if uuid not in _db._data.roles: + raise ValueError(f"Role {uuid} not found") + with _db.transaction(actor): + _db._data.roles[uuid].display_name = display_name + + +def add_permission_to_role( + role_uuid: str | UUID, permission_scope: str, actor: str = "system" +) -> None: + """Add permission scope to role.""" + role_uuid = str(role_uuid) + if role_uuid not in _db._data.roles: + raise ValueError(f"Role {role_uuid} not found") + with _db.transaction(actor): + _db._data.roles[role_uuid].permissions[permission_scope] = True + + +def remove_permission_from_role( + role_uuid: str | UUID, permission_scope: str, actor: str = "system" +) -> None: + """Remove permission scope from role.""" + role_uuid = str(role_uuid) + if role_uuid not in _db._data.roles: + raise ValueError(f"Role {role_uuid} not found") + with _db.transaction(actor): + _db._data.roles[role_uuid].permissions.pop(permission_scope, None) + + +def delete_role(uuid: str | UUID, actor: str = "system") -> None: + """Delete a role.""" + uuid = str(uuid) + if uuid not in _db._data.roles: + raise ValueError(f"Role {uuid} not found") + # Check no users have this role + if any(u.role == uuid for u in _db._data.users.values()): + raise ValueError(f"Cannot delete role {uuid}: users still assigned") + with _db.transaction(actor): + del _db._data.roles[uuid] + + +def create_user(user: User, actor: str = "system") -> None: + """Create a new user.""" + uuid = str(user.uuid) + role_uuid = str(user.role_uuid) + if uuid in _db._data.users: + raise ValueError(f"User {uuid} already exists") + if role_uuid not in _db._data.roles: + raise ValueError(f"Role {role_uuid} not found") + with _db.transaction(actor): + _db._data.users[uuid] = _UserData( + display_name=user.display_name, + role=role_uuid, + created_at=user.created_at or datetime.now(timezone.utc), + last_seen=user.last_seen, + visits=user.visits, + ) + + +def update_user_display_name( + uuid: str | UUID, display_name: str, actor: str = "system" +) -> None: + """Update user display name.""" + uuid = str(uuid) + if uuid not in _db._data.users: + raise ValueError(f"User {uuid} not found") + with _db.transaction(actor): + _db._data.users[uuid].display_name = display_name + + +def update_user_role( + uuid: str | UUID, role_uuid: str | UUID, actor: str = "system" +) -> None: + """Update user's role.""" + uuid, role_uuid = str(uuid), str(role_uuid) + if uuid not in _db._data.users: + raise ValueError(f"User {uuid} not found") + if role_uuid not in _db._data.roles: + raise ValueError(f"Role {role_uuid} not found") + with _db.transaction(actor): + _db._data.users[uuid].role = role_uuid + + +def update_user_role_in_organization( + user_uuid: str | UUID, role_name: str, actor: str = "system" +) -> None: + """Update user's role by role name within their current organization.""" + user_uuid = str(user_uuid) + if user_uuid not in _db._data.users: + raise ValueError(f"User {user_uuid} not found") + current_role_uuid = _db._data.users[user_uuid].role + if current_role_uuid not in _db._data.roles: + raise ValueError("Current role not found") + org_uuid = _db._data.roles[current_role_uuid].org + # Find role by name in the same org + new_role_uuid = None + for rid, r in _db._data.roles.items(): + if r.org == org_uuid and r.display_name == role_name: + new_role_uuid = rid + break + if new_role_uuid is None: + raise ValueError(f"Role '{role_name}' not found in organization") + with _db.transaction(actor): + _db._data.users[user_uuid].role = new_role_uuid + + +def delete_user(uuid: str | UUID, actor: str = "system") -> None: + """Delete user and their credentials/sessions.""" + uuid = str(uuid) + if uuid not in _db._data.users: + raise ValueError(f"User {uuid} not found") + with _db.transaction(actor): + # Delete credentials + cred_uuids = [cid for cid, c in _db._data.credentials.items() if c.user == uuid] + for cid in cred_uuids: + del _db._data.credentials[cid] + # Delete sessions + sess_keys = [k for k, s in _db._data.sessions.items() if s.user == uuid] + for k in sess_keys: + del _db._data.sessions[k] + # Delete reset tokens + token_keys = [k for k, t in _db._data.reset_tokens.items() if t.user == uuid] + for k in token_keys: + del _db._data.reset_tokens[k] + del _db._data.users[uuid] + + +def create_credential(cred: Credential, actor: str = "system") -> None: + """Create a new credential.""" + uuid = str(cred.uuid) + user_uuid = str(cred.user_uuid) + if uuid in _db._data.credentials: + raise ValueError(f"Credential {uuid} already exists") + if user_uuid not in _db._data.users: + raise ValueError(f"User {user_uuid} not found") + with _db.transaction(actor): + _db._data.credentials[uuid] = _CredentialData( + credential_id=cred.credential_id, + user=user_uuid, + aaguid=str(cred.aaguid), + public_key=cred.public_key, + sign_count=cred.sign_count, + created_at=cred.created_at, + last_used=cred.last_used, + last_verified=cred.last_verified, + ) + + +def update_credential_sign_count( + uuid: str | UUID, + sign_count: int, + last_used: datetime | None = None, + actor: str = "system", +) -> None: + """Update credential sign count and last_used.""" + uuid = str(uuid) + if uuid not in _db._data.credentials: + raise ValueError(f"Credential {uuid} not found") + with _db.transaction(actor): + _db._data.credentials[uuid].sign_count = sign_count + if last_used: + _db._data.credentials[uuid].last_used = last_used + + +def delete_credential( + uuid: str | UUID, user_uuid: str | UUID | None = None, actor: str = "system" +) -> None: + """Delete a credential. + + If user_uuid is provided, validates that the credential belongs to that user. + """ + uuid = str(uuid) + if uuid not in _db._data.credentials: + raise ValueError(f"Credential {uuid} not found") + if user_uuid is not None: + cred_user = _db._data.credentials[uuid].user + if cred_user != str(user_uuid): + raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}") + with _db.transaction(actor): + del _db._data.credentials[uuid] + + +def create_session( + key: bytes, + user_uuid: UUID, + credential_uuid: UUID, + host: str | None, + ip: str | None, + user_agent: str | None, + expiry: datetime, + actor: str = "system", +) -> None: + """Create a new session.""" + key_b64 = _b64(key) + user_uuid_s = str(user_uuid) + cred_uuid_s = str(credential_uuid) + if key_b64 in _db._data.sessions: + raise ValueError("Session already exists") + if user_uuid_s not in _db._data.users: + raise ValueError(f"User {user_uuid} not found") + if cred_uuid_s not in _db._data.credentials: + raise ValueError(f"Credential {credential_uuid} not found") + with _db.transaction(actor): + _db._data.sessions[key_b64] = _SessionData( + user=user_uuid_s, + credential=cred_uuid_s, + host=host, + ip=ip, + user_agent=user_agent, + expiry=expiry, + ) + + +def update_session( + key: bytes, + ip: str | None = None, + user_agent: str | None = None, + expiry: datetime | None = None, + actor: str = "system", +) -> None: + """Update session metadata.""" + key_b64 = _b64(key) + if key_b64 not in _db._data.sessions: + raise ValueError("Session not found") + with _db.transaction(actor): + s = _db._data.sessions[key_b64] + if ip is not None: + s.ip = ip + if user_agent is not None: + s.user_agent = user_agent + if expiry is not None: + s.expiry = expiry + + +def delete_session(key: bytes, actor: str = "system") -> None: + """Delete a session.""" + key_b64 = _b64(key) + if key_b64 not in _db._data.sessions: + raise ValueError("Session not found") + with _db.transaction(actor): + del _db._data.sessions[key_b64] + + +def delete_sessions_for_user(user_uuid: str | UUID, actor: str = "system") -> None: + """Delete all sessions for a user.""" + user_uuid = str(user_uuid) + with _db.transaction(actor): + keys = [k for k, s in _db._data.sessions.items() if s.user == user_uuid] + for k in keys: + del _db._data.sessions[k] + + +def create_reset_token( + key: bytes, + user_uuid: UUID, + expiry: datetime, + token_type: str, + actor: str = "system", +) -> None: + """Create a reset token.""" + key_b64 = _b64(key) + user_uuid_s = str(user_uuid) + if key_b64 in _db._data.reset_tokens: + raise ValueError("Reset token already exists") + if user_uuid_s not in _db._data.users: + raise ValueError(f"User {user_uuid} not found") + with _db.transaction(actor): + _db._data.reset_tokens[key_b64] = _ResetTokenData( + user=user_uuid_s, expiry=expiry, token_type=token_type + ) + + +def delete_reset_token(key: bytes, actor: str = "system") -> None: + """Delete a reset token.""" + key_b64 = _b64(key) + if key_b64 not in _db._data.reset_tokens: + raise ValueError("Reset token not found") + with _db.transaction(actor): + del _db._data.reset_tokens[key_b64] + + +# ------------------------------------------------------------------------- +# Cleanup (called by background task) +# ------------------------------------------------------------------------- + + +def cleanup_expired(actor: str = "system") -> int: + """Remove expired sessions and reset tokens. Returns count removed.""" + now = datetime.now(timezone.utc) + count = 0 + with _db.transaction(actor): + expired_sessions = [k for k, s in _db._data.sessions.items() if s.expiry < now] + for k in expired_sessions: + del _db._data.sessions[k] + count += 1 + expired_tokens = [ + k for k, t in _db._data.reset_tokens.items() if t.expiry < now + ] + for k in expired_tokens: + del _db._data.reset_tokens[k] + count += 1 + return count + + +# ------------------------------------------------------------------------- +# Composite operations (used by app code) +# ------------------------------------------------------------------------- + + +def login(user_uuid: str | UUID, credential: Credential, actor: str = "system") -> None: + """Update user last_seen and credential sign_count/last_used on login.""" + user_uuid = str(user_uuid) + cred_uuid = str(credential.uuid) + now = datetime.now(timezone.utc) + if user_uuid not in _db._data.users: + raise ValueError(f"User {user_uuid} not found") + if cred_uuid not in _db._data.credentials: + raise ValueError(f"Credential {cred_uuid} not found") + with _db.transaction(actor): + _db._data.users[user_uuid].last_seen = now + _db._data.users[user_uuid].visits += 1 + _db._data.credentials[cred_uuid].sign_count = credential.sign_count + _db._data.credentials[cred_uuid].last_used = now + + +def create_credential_session( + user_uuid: UUID, + credential: Credential, + session_key: bytes, + host: str | None, + ip: str | None, + user_agent: str | None, + display_name: str | None = None, + reset_key: bytes | None = None, + actor: str = "system", +) -> None: + """Create a credential and session together, optionally consuming a reset token. + + Used during registration to atomically: + 1. Update user display_name if provided + 2. Create the credential + 3. Create the session + 4. Delete the reset token if provided + """ + from paskia.config import SESSION_LIFETIME + + user_uuid_s = str(user_uuid) + cred_uuid_s = str(credential.uuid) + key_b64 = _b64(session_key) + assert key_b64 is not None + now = datetime.now(timezone.utc) + expiry = now + SESSION_LIFETIME + + if user_uuid_s not in _db._data.users: + raise ValueError(f"User {user_uuid} not found") + + with _db.transaction(actor): + # Update display name if provided + if display_name: + _db._data.users[user_uuid_s].display_name = display_name + + # Create credential + _db._data.credentials[cred_uuid_s] = _CredentialData( + credential_id=credential.credential_id, + user=user_uuid_s, + aaguid=str(credential.aaguid), + public_key=credential.public_key, + sign_count=credential.sign_count, + created_at=credential.created_at, + last_used=credential.last_used, + last_verified=credential.last_verified, + ) + + # Create session + _db._data.sessions[key_b64] = _SessionData( + user=user_uuid_s, + credential=cred_uuid_s, + host=host, + ip=ip, + user_agent=user_agent, + expiry=expiry, + ) + + # Delete reset token if provided + if reset_key: + reset_b64 = _b64(reset_key) + if reset_b64 in _db._data.reset_tokens: + del _db._data.reset_tokens[reset_b64] diff --git a/paskia/db/structs.py b/paskia/db/structs.py new file mode 100644 index 0000000..c5226de --- /dev/null +++ b/paskia/db/structs.py @@ -0,0 +1,168 @@ +from datetime import datetime +from uuid import UUID + +import msgspec + + +class Permission(msgspec.Struct, omit_defaults=True): + """A permission that can be granted to roles.""" + + uuid: UUID # UUID primary key + scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write") + display_name: str + domain: str | None = None # If set, scopes permission to this domain + + +class Role(msgspec.Struct): + """A role within an organization that can be assigned to users.""" + + uuid: UUID + org_uuid: UUID + display_name: str + permissions: list[str] = [] # permission IDs this role grants + + +class Org(msgspec.Struct): + """An organization that contains users and roles.""" + + uuid: UUID + display_name: str + permissions: list[str] = [] # permission IDs this org can grant + roles: list[Role] = [] # roles belonging to this org + + +class User(msgspec.Struct): + """A user in the authentication system.""" + + uuid: UUID + display_name: str + role_uuid: UUID + created_at: datetime | None = None + last_seen: datetime | None = None + visits: int = 0 + + +class Credential(msgspec.Struct): + """A WebAuthn credential (passkey) belonging to a user.""" + + uuid: UUID + credential_id: bytes # Long binary ID from the authenticator + user_uuid: UUID + aaguid: UUID + public_key: bytes + sign_count: int + created_at: datetime + last_used: datetime | None = None + last_verified: datetime | None = None + + +class Session(msgspec.Struct): + """An active user session.""" + + key: bytes + user_uuid: UUID + credential_uuid: UUID + host: str | None + ip: str | None + user_agent: str | None + expiry: datetime + + def metadata(self) -> dict: + """Return session metadata for backwards compatibility.""" + return { + "ip": self.ip, + "user_agent": self.user_agent, + "expiry": self.expiry.isoformat(), + } + + +# ------------------------------------------------------------------------- +# Public data types (msgspec Structs) +# ------------------------------------------------------------------------- + + +class ResetToken(msgspec.Struct): + """A token for password reset or device addition.""" + + key: bytes + user_uuid: UUID + expiry: datetime + token_type: str + + +class SessionContext(msgspec.Struct): + """Complete context for an authenticated session.""" + + session: Session + user: User + org: Org + role: Role + credential: Credential | None = None + permissions: list[Permission] | None = None + + +# ------------------------------------------------------------------------- +# Internal storage types (different structure for efficient storage) +# ------------------------------------------------------------------------- + + +class _PermissionData(msgspec.Struct, omit_defaults=True): + scope: str # Permission scope identifier + display_name: str + domain: str | None = None + orgs: dict[str, bool] = {} # org_uuid -> True (which orgs can grant this) + + +class _OrgData(msgspec.Struct): + display_name: str + created_at: datetime | None = None + + +class _RoleData(msgspec.Struct): + org: str + display_name: str + permissions: dict[str, bool] # permission_id -> True + + +class _UserData(msgspec.Struct): + display_name: str + role: str + created_at: datetime + last_seen: datetime | None + visits: int + + +class _CredentialData(msgspec.Struct): + credential_id: bytes # msgspec uses standard base64 + user: str + aaguid: str + public_key: bytes # msgspec uses standard base64 + sign_count: int + created_at: datetime + last_used: datetime | None + last_verified: datetime | None + + +class _SessionData(msgspec.Struct): + user: str + credential: str + host: str | None + ip: str | None + user_agent: str | None + expiry: datetime + + +class _ResetTokenData(msgspec.Struct): + user: str + expiry: datetime + token_type: str + + +class _DatabaseData(msgspec.Struct): + permissions: dict[str, _PermissionData] + orgs: dict[str, _OrgData] + roles: dict[str, _RoleData] + users: dict[str, _UserData] + credentials: dict[str, _CredentialData] + sessions: dict[str, _SessionData] + reset_tokens: dict[str, _ResetTokenData] diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 3bb8f2d..42a62cc 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -356,7 +356,7 @@ async def admin_add_role_permission( db.get_permission(permission_id) org = db.get_organization(str(org_uuid)) if permission_id not in org.permissions: - raise ValueError(f"Permission not grantable by organization") + raise ValueError("Permission not grantable by organization") db.add_permission_to_role(role_uuid, permission_id) return {"status": "ok"} @@ -593,14 +593,10 @@ async def admin_get_user_detail( status_code=403, detail="Insufficient permissions", mode="forbidden" ) user = db.get_user_by_uuid(user_uuid) - cred_ids = db.get_credentials_by_user_uuid(user_uuid) + user_creds = db.get_credentials_by_user_uuid(user_uuid) creds: list[dict] = [] aaguids: set[str] = set() - for cid in cred_ids: - try: - c = db.get_credential_by_id(cid) - except ValueError: # pragma: no cover - race condition handling - continue + for c in user_creds: aaguid_str = str(c.aaguid) aaguids.add(aaguid_str) creds.append( diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index cd84b42..e22d753 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -13,6 +13,7 @@ from fastapi import ( from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer +from paskia import db from paskia.authsession import ( EXPIRES, get_reset, @@ -21,7 +22,6 @@ from paskia.authsession import ( ) from paskia.fastapi import authz, session, user from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME -from paskia import db from paskia.globals import passkey as global_passkey from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo from paskia.util.tokens import session_key diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index 0941956..b5581a2 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -15,11 +15,10 @@ from uuid import UUID import base64url from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from paskia import remoteauth +from paskia import db, remoteauth from paskia.authsession import create_session from paskia.fastapi.session import infodict from paskia.fastapi.wsutil import validate_origin, websocket_error_handler -from paskia import db from paskia.globals import passkey from paskia.util import passphrase, pow diff --git a/paskia/fastapi/reset.py b/paskia/fastapi/reset.py index 1e61b1b..b42dc59 100644 --- a/paskia/fastapi/reset.py +++ b/paskia/fastapi/reset.py @@ -27,9 +27,9 @@ async def _resolve_targets(query: str | None): targets: list[tuple] = [] try: q_uuid = UUID(query) - perm_orgs = await _db.get_permission_organizations("auth:admin") + perm_orgs = _db.get_permission_organizations("auth:admin") for o in perm_orgs: - users = await _db.get_organization_users(str(o.uuid)) + users = _db.get_organization_users(str(o.uuid)) for u, role_name in users: if u.uuid == q_uuid: return [(u, role_name)] @@ -38,9 +38,9 @@ async def _resolve_targets(query: str | None): pass # Substring search needle = query.lower() - perm_orgs = await _db.get_permission_organizations("auth:admin") + perm_orgs = _db.get_permission_organizations("auth:admin") for o in perm_orgs: - users = await _db.get_organization_users(str(o.uuid)) + users = _db.get_organization_users(str(o.uuid)) for u, role_name in users: if needle in (u.display_name or "").lower(): targets.append((u, role_name)) @@ -53,10 +53,10 @@ async def _resolve_targets(query: str | None): deduped.append((u, role_name)) return deduped # No query -> master admin - perm_orgs = await _db.get_permission_organizations("auth:admin") + perm_orgs = _db.get_permission_organizations("auth:admin") if not perm_orgs: return [] - users = await _db.get_organization_users(str(perm_orgs[0].uuid)) + users = _db.get_organization_users(str(perm_orgs[0].uuid)) admin_users = [pair for pair in users if pair[1] == "Administration"] return admin_users[:1] @@ -64,9 +64,9 @@ async def _resolve_targets(query: str | None): async def _create_reset(user, role_name: str): token = passphrase.generate() expiry = _authsession.reset_expires() - await _db.create_reset_token( - user_uuid=user.uuid, + _db.create_reset_token( key=_tokens.reset_key(token), + user_uuid=user.uuid, expiry=expiry, token_type="manual reset", ) diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 93cd347..ddb4172 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -10,6 +10,7 @@ from fastapi import ( ) from fastapi.responses import JSONResponse +from paskia import db from paskia.authsession import ( delete_credential, expires, @@ -17,7 +18,6 @@ from paskia.authsession import ( ) from paskia.fastapi import authz, session from paskia.fastapi.session import AUTH_COOKIE -from paskia import db from paskia.util import hostutil, passphrase, tokens from paskia.util.tokens import decode_session_key, session_key diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 947eaa4..e260911 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -2,11 +2,11 @@ from uuid import UUID from fastapi import FastAPI, WebSocket +from paskia import db from paskia.authsession import create_session, get_reset, get_session from paskia.fastapi import authz, remote from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wsutil import validate_origin, websocket_error_handler -from paskia import db from paskia.globals import passkey from paskia.util import passphrase from paskia.util.tokens import create_token, session_key diff --git a/paskia/globals.py b/paskia/globals.py index 907d998..34c3991 100644 --- a/paskia/globals.py +++ b/paskia/globals.py @@ -42,8 +42,7 @@ async def init( Set PASKIA_DB environment variable to specify the JSONL database file path. Default: paskia.jsonl """ - from . import remoteauth - from .db import json as json_db + from . import db, remoteauth # Initialize passkey instance with provided parameters passkey.instance = Passkey( @@ -52,9 +51,8 @@ async def init( origins=origins, ) - # Initialize database if not already done - if json_db._db is None: - await json_db.init() + # Initialize database + await db.init() # Initialize remote auth manager await remoteauth.init() diff --git a/paskia/migrate/__init__.py b/paskia/migrate/__init__.py index e73ee45..21bdb14 100644 --- a/paskia/migrate/__init__.py +++ b/paskia/migrate/__init__.py @@ -59,10 +59,8 @@ async def migrate_from_sql( import uuid7 from sqlalchemy import select - from paskia.db.json import ( - DB as JSONDB, - ) - from paskia.db.json import ( + from paskia.db.operations import DB as JSONDB + from paskia.db.structs import ( _CredentialData, _OrgData, _PermissionData, diff --git a/paskia/migrate/sql.py b/paskia/migrate/sql.py index 025dec2..9eb381f 100644 --- a/paskia/migrate/sql.py +++ b/paskia/migrate/sql.py @@ -4,7 +4,7 @@ Legacy SQL database implementation for migration purposes. This module provides the async SQLAlchemy database layer that was used before the JSONL format. It is kept here for migration purposes only. -DO NOT use this module for new code. Use paskia.db.json instead. +DO NOT use this module for new code. Use paskia.db instead. """ from contextlib import asynccontextmanager diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py index 1d72bca..03b2717 100644 --- a/paskia/util/userinfo.py +++ b/paskia/util/userinfo.py @@ -44,16 +44,11 @@ async def format_user_info( ctx = await permutil.session_context(auth, request_host) # Fetch and format credentials - credential_ids = db.get_credentials_by_user_uuid(user_uuid) + user_credentials = db.get_credentials_by_user_uuid(user_uuid) credentials: list[dict] = [] user_aaguids: set[str] = set() - for cred_id in credential_ids: - try: - c = db.get_credential_by_id(cred_id) - except ValueError: - continue - + for c in user_credentials: aaguid_str = str(c.aaguid) user_aaguids.add(aaguid_str) credentials.append( diff --git a/tests/conftest.py b/tests/conftest.py index c28d6c1..ad98319 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,14 +18,27 @@ from uuid import UUID import httpx import pytest - -from paskia.authsession import expires 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.authsession import expires +from paskia.db import ( + Credential, + Org, + Permission, + Role, + User, + add_permission_to_organization, + create_credential, + create_organization, + create_permission, + create_reset_token, + create_role, + create_session, + create_user, +) +from paskia.db.operations import DB from paskia.fastapi.session import AUTH_COOKIE_NAME from paskia.sansio import Passkey from paskia.util.tokens import create_token, session_key @@ -45,15 +58,15 @@ async def test_db() -> AsyncGenerator[DB, None]: Uses a temp file that gets cleaned up after each test. """ - import paskia.db.json as json_db + import paskia.db.operations as ops_db with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: db = DB(f.name) - db.load() # Synchronous now - json_db._db = db + await db.load() + ops_db._db = db yield db # Clean up - json_db._db = None + ops_db._db = None @pytest_asyncio.fixture(scope="function") @@ -77,7 +90,7 @@ async def test_org(test_db: DB, admin_permission: Permission) -> Org: display_name="Test Organization", permissions=["auth:admin"], # Org can grant this permission ) - test_db.create_organization(org) + create_organization(org) return org @@ -89,7 +102,7 @@ async def admin_permission(test_db: DB) -> Permission: perm = Permission( uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin" ) - test_db.create_permission(perm) + create_permission(perm) return perm @@ -101,9 +114,9 @@ async def org_admin_permission(test_db: DB, test_org: Org) -> Permission: perm = Permission( uuid=uuid7.create(), scope="auth:org:admin", display_name="Organization Admin" ) - test_db.create_permission(perm) + create_permission(perm) # Make it grantable by the org - test_db.add_permission_to_organization(str(test_org.uuid), "auth:org:admin") + add_permission_to_organization(str(test_org.uuid), "auth:org:admin") return perm @@ -121,7 +134,7 @@ async def test_role( display_name="Test Admin Role", permissions=["auth:admin", "auth:org:admin"], ) - test_db.create_role(role) + create_role(role) return role @@ -134,7 +147,7 @@ async def user_role(test_db: DB, test_org: Org) -> Role: display_name="User Role", permissions=[], ) - test_db.create_role(role) + create_role(role) return role @@ -148,7 +161,7 @@ async def test_user(test_db: DB, test_role: Role) -> User: created_at=datetime.now(timezone.utc), visits=0, ) - test_db.create_user(user) + create_user(user) return user @@ -162,7 +175,7 @@ async def regular_user(test_db: DB, user_role: Role) -> User: created_at=datetime.now(timezone.utc), visits=0, ) - test_db.create_user(user) + create_user(user) return user @@ -180,7 +193,7 @@ async def test_credential(test_db: DB, test_user: User) -> Credential: last_used=None, last_verified=None, ) - test_db.create_credential(credential) + create_credential(credential) return credential @@ -198,7 +211,7 @@ async def regular_credential(test_db: DB, regular_user: User) -> Credential: last_used=None, last_verified=None, ) - test_db.create_credential(credential) + create_credential(credential) return credential @@ -208,7 +221,7 @@ async def session_token( ) -> str: """Create a session for the admin user and return the token.""" token = create_token() - test_db.create_session( + create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, key=session_key(token), @@ -226,7 +239,7 @@ async def regular_session_token( ) -> str: """Create a session for a regular user and return the token.""" token = create_token() - test_db.create_session( + create_session( user_uuid=regular_user.uuid, credential_uuid=regular_credential.uuid, key=session_key(token), @@ -246,7 +259,7 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential) from paskia.util.tokens import reset_key token = generate() - test_db.create_reset_token( + 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 af90536..b3efb5a 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -20,8 +20,21 @@ import pytest_asyncio import uuid7 from paskia.authsession import expires -from paskia.db import Credential, Org, Permission, Role, User -from paskia.db.json import DB +from paskia.db import ( + Credential, + Org, + Permission, + Role, + User, + add_permission_to_organization, + create_credential, + create_organization, + create_permission, + create_role, + create_session, + create_user, +) +from paskia.db.operations import DB from paskia.util.tokens import create_token, encode_session_key, session_key from tests.conftest import auth_headers @@ -36,7 +49,7 @@ async def second_org(test_db: DB) -> Org: display_name="Second Organization", permissions=[], ) - test_db.create_organization(org) + create_organization(org) return org @@ -51,7 +64,7 @@ async def second_org_role( display_name="Second Org Admin Role", permissions=["auth:admin"], ) - test_db.create_role(role) + create_role(role) return role @@ -65,7 +78,7 @@ async def second_org_user(test_db: DB, second_org_role: Role) -> User: created_at=datetime.now(timezone.utc), visits=0, ) - test_db.create_user(user) + create_user(user) return user @@ -85,7 +98,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), ) - test_db.create_credential(credential) + create_credential(credential) return credential @@ -95,7 +108,7 @@ async def second_org_session_token( ) -> str: """Create a session for the second org admin user.""" token = create_token() - test_db.create_session( + create_session( user_uuid=second_org_user.uuid, credential_uuid=second_org_credential.uuid, key=session_key(token), @@ -116,7 +129,7 @@ async def org_admin_role(test_db: DB, test_org: Org, org_admin_permission) -> Ro display_name="Org Admin Role", permissions=["auth:org:admin"], ) - test_db.create_role(role) + create_role(role) return role @@ -131,7 +144,7 @@ async def org_admin_user(test_db: DB, org_admin_role: Role) -> User: visits=5, last_seen=datetime.now(timezone.utc), ) - test_db.create_user(user) + create_user(user) return user @@ -151,7 +164,7 @@ async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential: last_used=datetime.now(timezone.utc), last_verified=None, ) - test_db.create_credential(credential) + create_credential(credential) return credential @@ -161,7 +174,7 @@ async def org_admin_session_token( ) -> str: """Create a session for the org admin user.""" token = create_token() - test_db.create_session( + create_session( user_uuid=org_admin_user.uuid, credential_uuid=org_admin_credential.uuid, key=session_key(token), @@ -181,9 +194,9 @@ async def grantable_permission(test_db: DB, test_org: Org) -> Permission: perm = Permission( uuid=uuid7.create(), scope="test:grantable:perm", display_name="Grantable Perm" ) - test_db.create_permission(perm) + create_permission(perm) # Add to org's grantable permissions - test_db.add_permission_to_organization(str(test_org.uuid), perm.scope) + add_permission_to_organization(str(test_org.uuid), perm.scope) return perm @@ -414,7 +427,7 @@ class TestAdminOrganizations: display_name="Org To Delete", permissions=[], ) - test_db.create_organization(org_to_delete) + create_organization(org_to_delete) # Create some org-specific permissions to test cleanup org_perm = Permission( @@ -422,7 +435,7 @@ class TestAdminOrganizations: scope=f"test:org:{org_to_delete.uuid}:feature", display_name="Org Feature", ) - test_db.create_permission(org_perm) + create_permission(org_perm) response = await client.delete( f"/auth/api/admin/orgs/{org_to_delete.uuid}", @@ -601,7 +614,7 @@ class TestAdminRoles: scope="test:not:grantable", display_name="Not Grantable", ) - test_db.create_permission(perm) + create_permission(perm) response = await client.post( f"/auth/api/admin/orgs/{test_org.uuid}/roles", @@ -676,7 +689,7 @@ class TestAdminRoles: scope="test:not:grantable:update", display_name="Not Grantable", ) - test_db.create_permission(perm) + create_permission(perm) response = await client.post( f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/test:not:grantable:update", @@ -1097,7 +1110,7 @@ class TestAdminUsersInOrg: created_at=datetime.now(timezone.utc), visits=0, ) - test_db.create_user(user_no_cred) + create_user(user_no_cred) response = await client.post( f"/auth/api/admin/orgs/{test_org.uuid}/users/{user_no_cred.uuid}/create-link", @@ -1184,7 +1197,7 @@ class TestAdminSessions: # Create an additional session to delete extra_token = create_token() extra_key = session_key(extra_token) - test_db.create_session( + create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, key=extra_key, @@ -1380,7 +1393,7 @@ class TestAdminPermissions: perm = Permission( uuid=uuid7.create(), scope="test:updateable", display_name="Updateable" ) - test_db.create_permission(perm) + create_permission(perm) response = await client.patch( "/auth/api/admin/permission?permission_id=test:updateable&display_name=Updated%20Name", @@ -1401,7 +1414,7 @@ class TestAdminPermissions: perm = Permission( uuid=uuid7.create(), scope="test:perm", display_name="Test Perm" ) - test_db.create_permission(perm) + create_permission(perm) response = await client.patch( "/auth/api/admin/permission?permission_id=test:perm&display_name=", @@ -1422,7 +1435,7 @@ class TestAdminPermissions: perm = Permission( uuid=uuid7.create(), scope="test:renameable2", display_name="Renameable" ) - test_db.create_permission(perm) + create_permission(perm) response = await client.post( "/auth/api/admin/permission/rename", @@ -1469,7 +1482,7 @@ class TestAdminPermissions: perm = Permission( uuid=uuid7.create(), scope="test:rename:withname", display_name="Old Name" ) - test_db.create_permission(perm) + create_permission(perm) response = await client.post( "/auth/api/admin/permission/rename", @@ -1493,7 +1506,7 @@ class TestAdminPermissions: perm = Permission( uuid=uuid7.create(), scope="test:deleteable", display_name="Deleteable" ) - test_db.create_permission(perm) + create_permission(perm) response = await client.delete( "/auth/api/admin/permission?permission_id=test:deleteable", @@ -1529,7 +1542,7 @@ class TestAdminPermissions: perm2 = Permission( uuid=uuid7.create(), scope="auth:admin", display_name="Secondary Admin" ) - test_db.create_permission(perm2) + create_permission(perm2) # Now we can delete the original one response = await client.delete( @@ -1556,7 +1569,7 @@ class TestAdminPermissions: display_name="Other Domain Admin", domain="other.example.com", ) - test_db.create_permission(perm2) + create_permission(perm2) # Cannot delete the original one because the remaining one is not accessible response = await client.delete( diff --git a/tests/test_api.py b/tests/test_api.py index b5b35c7..ce577b2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -15,6 +15,7 @@ from datetime import datetime, timezone import httpx import pytest +from paskia.db import create_session, delete_session from tests.conftest import auth_headers @@ -526,7 +527,7 @@ class TestValidateSessionRefresh: # Create a session with an old expiry time to trigger refresh token = create_token() old_expiry = datetime.now(timezone.utc) + EXPIRES - timedelta(minutes=10) - test_db.create_session( + create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, key=session_key(token), @@ -537,7 +538,7 @@ class TestValidateSessionRefresh: ) # Delete the session right before validate tries to refresh - test_db.delete_session(session_key(token)) + delete_session(session_key(token)) response = await client.post( "/auth/api/validate",