From 7f3763b46dfe55c1e4f74e8db4260a9c97e4f6d2 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 23 Jan 2026 00:54:37 +0000 Subject: [PATCH] Replace SQL database with JSONL based solution that keeps history. --- .gitignore | 1 + paskia/authsession.py | 17 +- paskia/bootstrap.py | 22 +- paskia/db/__init__.py | 439 ++--------- paskia/db/json.py | 1316 +++++++++++++++++++++++++++++++++ paskia/db/sql.py | 1424 ------------------------------------ paskia/fastapi/admin.py | 98 ++- paskia/fastapi/api.py | 6 +- paskia/fastapi/mainapp.py | 3 +- paskia/fastapi/remote.py | 9 +- paskia/fastapi/reset.py | 16 +- paskia/fastapi/user.py | 12 +- paskia/fastapi/ws.py | 15 +- paskia/globals.py | 17 +- paskia/migrate/__init__.py | 216 ++++++ paskia/migrate/sql.py | 355 +++++++++ paskia/util/permutil.py | 4 +- paskia/util/userinfo.py | 12 +- pyproject.toml | 9 +- tests/conftest.py | 26 +- tests/test_admin.py | 2 +- 21 files changed, 2070 insertions(+), 1949 deletions(-) create mode 100644 paskia/db/json.py delete mode 100644 paskia/db/sql.py create mode 100644 paskia/migrate/__init__.py create mode 100644 paskia/migrate/sql.py diff --git a/.gitignore b/.gitignore index 94cdeef..a2a17cf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ *.lock package-lock.json paskia.sqlite +paskia.jsonl /paskia/frontend-build /paskia/_version.py coverage-html/ diff --git a/paskia/authsession.py b/paskia/authsession.py index f67a4ae..32033ac 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -11,9 +11,10 @@ independent of any web framework: from datetime import datetime, timezone from uuid import UUID +from paskia import db from paskia.config import SESSION_LIFETIME from paskia.db import ResetToken, Session -from paskia.globals import db, passkey +from paskia.globals import passkey from paskia.util import hostutil from paskia.util.tokens import create_token, reset_key, session_key @@ -54,7 +55,7 @@ async def create_session( raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") token = create_token() now = datetime.now(timezone.utc) - await db.instance.create_session( + await db.create_session( user_uuid=user_uuid, credential_uuid=credential_uuid, key=session_key(token), @@ -68,7 +69,7 @@ async def create_session( async def get_reset(token: str) -> ResetToken: """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token).""" - record = await db.instance.get_reset_token(reset_key(token)) + record = await db.get_reset_token(reset_key(token)) if record and record.expiry >= datetime.now(timezone.utc): return record raise ValueError("This authentication link is no longer valid.") @@ -79,11 +80,11 @@ async def get_session(token: str, host: str | None = None) -> Session: host = hostutil.normalize_host(host) if not host: raise ValueError("Invalid host") - session = await db.instance.get_session(session_key(token)) + session = await db.get_session(session_key(token)) if session and session_expiry(session) >= datetime.now(timezone.utc): if session.host is None: # First time binding: store exact host:port (or IPv6 form) now. - await db.instance.set_session_host(session.key, host) + await db.set_session_host(session.key, host) session.host = host elif session.host != host: raise ValueError("Session host mismatch") @@ -93,10 +94,10 @@ async def get_session(token: str, host: str | None = None) -> Session: async def refresh_session_token(token: str, *, ip: str, user_agent: str): """Refresh a session extending its expiry.""" - session_record = await db.instance.get_session(session_key(token)) + session_record = await db.get_session(session_key(token)) if not session_record: raise ValueError("Session not found or expired") - updated = await db.instance.update_session( + updated = await db.update_session( session_key(token), ip=ip, user_agent=user_agent, @@ -109,4 +110,4 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str): async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None): """Delete a specific credential for the current user.""" s = await get_session(auth, host=host) - await db.instance.delete_credential(credential_uuid, s.user_uuid) + await db.delete_credential(credential_uuid, s.user_uuid) diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index 9043e9a..e8bc16c 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone import uuid7 -from paskia import authsession, globals +from paskia import authsession, db from paskia.db import Org, Permission, Role, User from paskia.util import hostutil, passphrase, tokens @@ -42,7 +42,7 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> """Create an admin reset link and log it with the provided message.""" token = passphrase.generate() expiry = authsession.reset_expires() - await globals.db.instance.create_reset_token( + await db.create_reset_token( user_uuid=user_uuid, key=tokens.reset_key(token), expiry=expiry, @@ -62,14 +62,14 @@ async def bootstrap_system() -> dict: """ # Create permission first - will fail if already exists perm0 = Permission(id="auth:admin", display_name="Master Admin") - await globals.db.instance.create_permission(perm0) + await db.create_permission(perm0) org = Org(uuid7.create(), "Organization") - await globals.db.instance.create_organization(org) + await db.create_organization(org) # After creation, org.permissions now includes the auto-created org admin permission # Allow this org to grant global admin explicitly - await globals.db.instance.add_permission_to_organization(str(org.uuid), perm0.id) + await db.add_permission_to_organization(str(org.uuid), perm0.id) # Create an Administration role granting both org and global admin # Compose permissions for Administration role: global admin + org admin auto-perm @@ -79,7 +79,7 @@ async def bootstrap_system() -> dict: "Administration", permissions=[perm0.id, *org.permissions], ) - await globals.db.instance.create_role(role) + await db.create_role(role) user = User( uuid=uuid7.create(), @@ -88,7 +88,7 @@ async def bootstrap_system() -> dict: created_at=datetime.now(timezone.utc), visits=0, ) - await globals.db.instance.create_user(user) + await db.create_user(user) # Generate reset link and log it reset_link = await _create_and_log_admin_reset_link( @@ -116,7 +116,7 @@ async def check_admin_credentials() -> bool: """ try: # Get permission organizations to find admin users - permission_orgs = await globals.db.instance.get_permission_organizations( + permission_orgs = await db.get_permission_organizations( "auth:admin" ) @@ -124,7 +124,7 @@ async def check_admin_credentials() -> bool: return False # Get users from the first organization with admin permission - org_users = await globals.db.instance.get_organization_users( + org_users = await db.get_organization_users( str(permission_orgs[0].uuid) ) admin_users = [user for user, role in org_users if role == "Administration"] @@ -134,7 +134,7 @@ async def check_admin_credentials() -> bool: # Check first admin user for credentials admin_user = admin_users[0] - credentials = await globals.db.instance.get_credentials_by_user_uuid( + credentials = await db.get_credentials_by_user_uuid( admin_user.uuid ) @@ -162,7 +162,7 @@ async def bootstrap_if_needed() -> bool: """ try: # Check if the admin permission exists - if it does, system is already bootstrapped - await globals.db.instance.get_permission("auth:admin") + await db.get_permission("auth:admin") # Permission exists, system is already bootstrapped # Check if admin needs credentials (only for already-bootstrapped systems) await check_admin_credentials() diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 8555168..95d7e59 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -1,415 +1,66 @@ """ Database module for WebAuthn passkey authentication. -This module provides dataclasses and database abstractions for managing -users, credentials, and sessions in a WebAuthn authentication system. +This module re-exports the JSONL database types and implementation. +All data types are msgspec Structs for efficient serialization. + +Usage: + from paskia import db + + # Access the database instance (after init) + await db.create_session(...) + user = await db.get_user_by_uuid(uuid) """ -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from datetime import datetime -from uuid import UUID +from paskia.db.json import ( + Credential, + DB, + Org, + Permission, + ResetToken, + Role, + Session, + SessionContext, + User, + init, +) +from paskia.db.json import _db as _json_db +import paskia.db.json as _json_module -@dataclass -class Permission: - id: str # String primary key (max 128 chars) - display_name: str +class _DBProxy: + """Proxy that forwards attribute access to the global DB instance. - -@dataclass -class Role: - uuid: UUID - org_uuid: UUID - display_name: str - # List of permission IDs this role grants to its members - permissions: list[str] = field(default_factory=list) # permission IDs - - -@dataclass -class Org: - uuid: UUID - display_name: str - # All permission IDs that the Org is allowed to grant to its roles - permissions: list[str] = field(default_factory=list) # permission IDs - # Roles belonging to this org - roles: list[Role] = field(default_factory=list) - - -@dataclass -class User: - uuid: UUID - display_name: str - role_uuid: UUID - created_at: datetime | None = None - last_seen: datetime | None = None - visits: int = 0 - - -@dataclass -class Credential: - uuid: UUID - credential_id: bytes # Long binary ID passed 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 - - -@dataclass -class Session: - key: bytes - user_uuid: UUID - credential_uuid: UUID - host: str - ip: str - user_agent: str - renewed: datetime - - def metadata(self) -> dict: - """Return session metadata for backwards compatibility.""" - return { - "ip": self.ip, - "user_agent": self.user_agent, - "renewed": self.renewed.isoformat(), - } - - -@dataclass -class ResetToken: - key: bytes - user_uuid: UUID - expiry: datetime - token_type: str - - -@dataclass -class SessionContext: - session: Session - user: User - org: Org - role: Role - credential: Credential | None = None - permissions: list[Permission] | None = None - - -class DatabaseInterface(ABC): - """Abstract base class defining the database interface. - - This class defines the public API that database implementations should provide. - Implementations may use decorators like @with_session that modify method signatures - at runtime, so this interface focuses on the logical operations rather than - exact parameter matching. + This allows using `db.method()` directly instead of `db.get_db().method()`. """ - @abstractmethod - async def init_db(self) -> None: - """Initialize database tables.""" - pass + 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) - # User operations - @abstractmethod - async def get_user_by_uuid(self, user_uuid: UUID) -> User: - """Get user record by WebAuthn user UUID.""" - @abstractmethod - async def create_user(self, user: User) -> None: - """Create a new user.""" +# Module-level proxy for direct access +_proxy = _DBProxy() - @abstractmethod - async def update_user_display_name( - self, user_uuid: UUID, display_name: str - ) -> None: - """Update a user's display name.""" - # Role operations - @abstractmethod - async def create_role(self, role: Role) -> None: - """Create new role.""" - - @abstractmethod - async def update_role(self, role: Role) -> None: - """Update a role's display name and synchronize its permissions.""" - - @abstractmethod - async def delete_role(self, role_uuid: UUID) -> None: - """Delete a role by UUID. Implementations may prevent deletion if users exist.""" - - # Credential operations - @abstractmethod - async def create_credential(self, credential: Credential) -> None: - """Store a credential for a user.""" - - @abstractmethod - async def get_credential_by_id(self, credential_id: bytes) -> Credential: - """Get credential by credential ID.""" - - @abstractmethod - async def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]: - """Get all credential IDs for a user.""" - - @abstractmethod - async def update_credential(self, credential: Credential) -> None: - """Update the sign count, created_at, last_used, and last_verified for a credential.""" - - @abstractmethod - async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None: - """Delete a specific credential for a user.""" - - # Session operations - @abstractmethod - async def create_session( - self, - user_uuid: UUID, - key: bytes, - credential_uuid: UUID, - host: str, - ip: str, - user_agent: str, - renewed: datetime, - ) -> None: - """Create a new session.""" - - @abstractmethod - async def get_session(self, key: bytes) -> Session | None: - """Get session by key.""" - - @abstractmethod - async def delete_session(self, key: bytes) -> None: - """Delete session by key.""" - - @abstractmethod - async def update_session( - self, - key: bytes, - *, - ip: str, - user_agent: str, - renewed: datetime, - ) -> Session | None: - """Update session metadata and touch renewed timestamp.""" - - @abstractmethod - async def set_session_host(self, key: bytes, host: str) -> None: - """Bind a session to a specific host if not already set.""" - - @abstractmethod - async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: - """Return all sessions for a user (including other hosts).""" - - @abstractmethod - async def cleanup(self) -> None: - """Called periodically to clean up expired records.""" - - @abstractmethod - async def delete_sessions_for_user(self, user_uuid: UUID) -> None: - """Delete all sessions belonging to the provided user.""" - - # Reset token operations - @abstractmethod - async def create_reset_token( - self, - user_uuid: UUID, - key: bytes, - expiry: datetime, - token_type: str, - ) -> None: - """Create a reset token for a user.""" - - @abstractmethod - async def get_reset_token(self, key: bytes) -> ResetToken | None: - """Retrieve a reset token by key.""" - - @abstractmethod - async def delete_reset_token(self, key: bytes) -> None: - """Delete a reset token by key.""" - - # Organization operations - @abstractmethod - async def create_organization(self, org: Org) -> None: - """Add a new organization.""" - - @abstractmethod - async def get_organization(self, org_id: str) -> Org: - """Get organization by ID, including its permission IDs and roles (with their permission IDs).""" - - @abstractmethod - async def list_organizations(self) -> list[Org]: - """List all organizations with their roles and permission IDs.""" - - @abstractmethod - async def update_organization(self, org: Org) -> None: - """Update organization options.""" - - @abstractmethod - async def delete_organization(self, org_uuid: UUID) -> None: - """Delete organization by ID.""" - - @abstractmethod - async def add_user_to_organization( - self, user_uuid: UUID, org_id: str, role: str - ) -> None: - """Set a user's organization and role.""" - - @abstractmethod - async def transfer_user_to_organization( - self, user_uuid: UUID, new_org_id: str, new_role: str | None = None - ) -> None: - """Transfer a user to another organization with an optional role.""" - - @abstractmethod - async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: - """Get the organization and role for a user.""" - - @abstractmethod - async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: - """Get all users in an organization with their roles.""" - - @abstractmethod - async def get_roles_by_organization(self, org_id: str) -> list[Role]: - """List roles belonging to an organization.""" - - @abstractmethod - async def get_user_role_in_organization( - self, user_uuid: UUID, org_id: str - ) -> str | None: - """Get a user's role in a specific organization.""" - - @abstractmethod - async def update_user_role_in_organization( - self, user_uuid: UUID, new_role: str - ) -> None: - """Update a user's role in their organization.""" - - # Permission operations - @abstractmethod - async def create_permission(self, permission: Permission) -> None: - """Create a new permission.""" - - @abstractmethod - async def get_permission(self, permission_id: str) -> Permission: - """Get permission by ID.""" - - @abstractmethod - async def list_permissions(self) -> list[Permission]: - """List all permissions.""" - - @abstractmethod - async def update_permission(self, permission: Permission) -> None: - """Update permission details.""" - - @abstractmethod - async def delete_permission(self, permission_id: str) -> None: - """Delete permission by ID.""" - - @abstractmethod - async def rename_permission( - self, old_id: str, new_id: str, display_name: str - ) -> None: - """Rename a permission's ID (and display name) updating all references. - - This must update: - - permissions.id (primary key) - - org_permissions.permission_id - - role_permissions.permission_id - """ - - @abstractmethod - async def add_permission_to_organization( - self, org_id: str, permission_id: str - ) -> None: - """Add a permission to an organization.""" - - @abstractmethod - async def remove_permission_from_organization( - self, org_id: str, permission_id: str - ) -> None: - """Remove a permission from an organization.""" - - @abstractmethod - async def get_organization_permissions(self, org_id: str) -> list[Permission]: - """Get all permissions assigned to an organization.""" - - @abstractmethod - async def get_permission_organizations(self, permission_id: str) -> list[Org]: - """Get all organizations that have a specific permission.""" - - # Role-permission operations - @abstractmethod - async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None: - """Add a permission to a role.""" - - @abstractmethod - async def remove_permission_from_role( - self, role_uuid: UUID, permission_id: str - ) -> None: - """Remove a permission from a role.""" - - @abstractmethod - async def get_role_permissions(self, role_uuid: UUID) -> list[Permission]: - """List all permissions granted to a role.""" - - @abstractmethod - async def get_permission_roles(self, permission_id: str) -> list[Role]: - """List all roles that grant a permission.""" - - @abstractmethod - async def get_role(self, role_uuid: UUID) -> Role: - """Get a role by UUID, including its permission IDs.""" - - # Combined operations - @abstractmethod - async def login(self, user_uuid: UUID, credential: Credential) -> None: - """Update user and credential timestamps after successful login.""" - - @abstractmethod - async def create_user_and_credential( - self, user: User, credential: Credential - ) -> None: - """Create a new user and their first credential in a transaction.""" - - @abstractmethod - async def get_session_context( - self, session_key: bytes, host: str | None = None - ) -> SessionContext | None: - """Get complete session context including user, organization, role, and permissions.""" - - # Combined atomic operations - @abstractmethod - async 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, - ) -> None: - """Atomically add a credential and create a session. - - Steps (single transaction): - 1. Insert credential - 2. Optionally delete old reset token if provided - 3. Optionally update user's display name - 4. Insert new session referencing the credential - 5. Update user's last_seen and increment visits (treat as a login) - """ +def __getattr__(name: str): + """Module-level __getattr__ to forward DB method calls.""" + if name in __all__: + raise AttributeError(name) + return getattr(_proxy, name) __all__ = [ - "User", "Credential", - "Session", - "ResetToken", - "SessionContext", + "DB", "Org", - "Role", "Permission", - "DatabaseInterface", + "ResetToken", + "Role", + "Session", + "SessionContext", + "User", + "init", ] diff --git a/paskia/db/json.py b/paskia/db/json.py new file mode 100644 index 0000000..7d9ec5f --- /dev/null +++ b/paskia/db/json.py @@ -0,0 +1,1316 @@ +""" +Async 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. +""" + +import asyncio +import os +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import UUID + +import base64url +import jsondiff +import msgspec + +from paskia.config import SESSION_LIFETIME + +DB_PATH_DEFAULT = "paskia.jsonl" + + +# ------------------------------------------------------------------------- +# Public data types (msgspec Structs) +# ------------------------------------------------------------------------- + + +class Permission(msgspec.Struct): + """A permission that can be granted to roles.""" + + id: str # String primary key (max 128 chars) + display_name: str + + +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 + renewed: datetime + + def metadata(self) -> dict: + """Return session metadata for backwards compatibility.""" + return { + "ip": self.ip, + "user_agent": self.user_agent, + "renewed": self.renewed.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): + display_name: str + 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 + renewed: 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 + + +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 init(*args, **kwargs): + """Initialize the global database instance.""" + global _db + db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) + # Remove any prefix (for compatibility with SQL-style URIs) + if db_path.startswith("json:"): + db_path = db_path[5:] + _db = DB(db_path) + await _db.init_db() + + +class DB: + """JSON-based database implementation. + + Maintains data in memory and persists to disk on every change. + Uses nested dictionaries keyed by UUID strings for efficient lookup. + + 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._lock = asyncio.Lock() + + def _empty_data(self) -> _DatabaseData: + """Return an empty database structure.""" + return _DatabaseData( + permissions={}, + orgs={}, + roles={}, + users={}, + credentials={}, + sessions={}, + reset_tokens={}, + ) + + async 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) + + async def _save(self, actor: str = "system") -> None: + """Append change record to JSONL file.""" + 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 save if there are changes + if diff: + change_record = _ChangeRecord( + ts=datetime.now(timezone.utc), + actor=actor, + diff=diff, + ) + + # Encode and append to file + data = _json_encoder.encode(change_record) + line = data.decode("utf-8") + "\n" + + # Append atomically (create temp file, then append) + tmp_path = self.db_path.with_suffix(".tmp") + try: + # Read existing content + existing_content = "" + if self.db_path.exists(): + existing_content = await asyncio.to_thread( + self.db_path.read_text, "utf-8" + ) + + # Append new line + new_content = existing_content + line + + # Write to temp file and rename + await asyncio.to_thread(tmp_path.write_text, new_content, "utf-8") + await asyncio.to_thread(tmp_path.replace, self.db_path) + + # Update previous builtins for next diff (to_builtins creates a copy) + self._previous_builtins = current_builtins + except OSError: + # Clean up temp file on error + if tmp_path.exists(): + await asyncio.to_thread(tmp_path.unlink) + + @asynccontextmanager + async def session(self): + """Context manager for atomic operations with save on exit.""" + async with self._lock: + yield + await self._save() + + async def init_db(self) -> None: + """Initialize database (load from disk).""" + async with self._lock: + await self._load() + + # ------------------------------------------------------------------------- + # User operations + # ------------------------------------------------------------------------- + + async def get_user_by_uuid(self, user_uuid: UUID) -> User: + async with self._lock: + key = str(user_uuid) + if key not in self._data.users: + raise ValueError("User not found") + u = self._data.users[key] + return User( + uuid=user_uuid, # Use the key directly + display_name=u.display_name, + role_uuid=UUID(u.role), + created_at=u.created_at, + last_seen=u.last_seen, + visits=u.visits, + ) + + async def create_user(self, user: User) -> None: + async with self.session(): + 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, + ) + + async def update_user_display_name( + self, user_uuid: UUID, display_name: str + ) -> None: + async with self.session(): + 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 + # ------------------------------------------------------------------------- + + async def create_role(self, role: Role) -> None: + async with self.session(): + 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 {}, + ) + + async def update_role(self, role: Role) -> None: + async with self.session(): + 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 {} + ) + + async def delete_role(self, role_uuid: UUID) -> None: + async with self.session(): + 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] + + async def get_role(self, role_uuid: UUID) -> Role: + async with self._lock: + key = str(role_uuid) + if key not in self._data.roles: + raise ValueError("Role not found") + r = self._data.roles[key] + return Role( + uuid=role_uuid, # Use the key directly + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions), + ) + + # ------------------------------------------------------------------------- + # Credential operations + # ------------------------------------------------------------------------- + + async def create_credential(self, credential: Credential) -> None: + async with self.session(): + 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, + ) + + async def get_credential_by_id(self, credential_id: bytes) -> Credential: + async with self._lock: + for key, c in self._data.credentials.items(): + if c.credential_id == credential_id: + return Credential( + uuid=UUID(key), # Use the key directly + credential_id=c.credential_id, # Already bytes + user_uuid=UUID(c.user), + aaguid=UUID(c.aaguid), + public_key=c.public_key, # Already bytes + sign_count=c.sign_count, + created_at=c.created_at, # Already datetime + last_used=c.last_used, + last_verified=c.last_verified, + ) + raise ValueError("Credential not found") + + async def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]: + async with self._lock: + 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 + + async def update_credential(self, credential: Credential) -> None: + async with self.session(): + 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") + + async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None: + async with self.session(): + 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 + # ------------------------------------------------------------------------- + + async def create_session( + self, + user_uuid: UUID, + key: bytes, + credential_uuid: UUID, + host: str, + ip: str, + user_agent: str, + renewed: datetime, + ) -> None: + async with self.session(): + 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, + renewed=renewed, + ) + + async def get_session(self, key: bytes) -> Session | None: + async with self._lock: + key_b64 = _bytes_to_str(key) + if key_b64 not in self._data.sessions: + return None + s = self._data.sessions[key_b64] + return Session( + key=_str_to_bytes(key_b64), # type: ignore[arg-type] + user_uuid=UUID(s.user), + credential_uuid=UUID(s.credential), + host=s.host, + ip=s.ip, + user_agent=s.user_agent, + renewed=s.renewed, # Already datetime + ) + + async def delete_session(self, key: bytes) -> None: + async with self.session(): + key_b64 = _bytes_to_str(key) + if key_b64 in self._data.sessions: + del self._data.sessions[key_b64] + + async def update_session( + self, + key: bytes, + *, + ip: str, + user_agent: str, + renewed: datetime, + ) -> Session | None: + async with self.session(): + 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.renewed = renewed + return Session( + key=_str_to_bytes(key_b64), # type: ignore[arg-type] + user_uuid=UUID(s.user), + credential_uuid=UUID(s.credential), + host=s.host, + ip=s.ip, + user_agent=s.user_agent, + renewed=s.renewed, # Already datetime + ) + + async def set_session_host(self, key: bytes, host: str) -> None: + async with self.session(): + key_b64 = _bytes_to_str(key) + if key_b64 in self._data.sessions: + s = self._data.sessions[key_b64] + if s.host is None: + s.host = host + + async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: + async with self._lock: + user_key = str(user_uuid) + sessions = [] + for key_b64, s in self._data.sessions.items(): + if s.user == user_key: + key_bytes = _str_to_bytes(key_b64) + if key_bytes and key_bytes.startswith(b"sess"): + sessions.append( + Session( + key=key_bytes, + user_uuid=UUID(s.user), + credential_uuid=UUID(s.credential), + host=s.host, + ip=s.ip, + user_agent=s.user_agent, + renewed=s.renewed, # Already datetime + ) + ) + # Sort by renewed desc + sessions.sort(key=lambda x: x.renewed, reverse=True) + return sessions + + async def delete_sessions_for_user(self, user_uuid: UUID) -> None: + async with self.session(): + 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 + # ------------------------------------------------------------------------- + + async def create_reset_token( + self, + user_uuid: UUID, + key: bytes, + expiry: datetime, + token_type: str, + ) -> None: + async with self.session(): + key_b64 = _bytes_to_str(key) + self._data.reset_tokens[key_b64] = _ResetTokenData( + user=str(user_uuid), + expiry=expiry, + token_type=token_type, + ) + + async def get_reset_token(self, key: bytes) -> ResetToken | None: + async with self._lock: + 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, + ) + + async def delete_reset_token(self, key: bytes) -> None: + async with self.session(): + key_b64 = _bytes_to_str(key) + if key_b64 in self._data.reset_tokens: + del self._data.reset_tokens[key_b64] + + # ------------------------------------------------------------------------- + # Organization operations + # ------------------------------------------------------------------------- + + async def create_organization(self, org: Org) -> None: + async with self.session(): + key = str(org.uuid) + self._data.orgs[key] = _OrgData( + display_name=org.display_name, + ) + + # Update permissions to allow this org to grant them + for perm_id in org.permissions: + if perm_id in self._data.permissions: + self._data.permissions[perm_id].orgs[key] = True + + # Automatically create an organization admin permission if not present + auto_perm_id = f"auth:org:{org.uuid}" + if auto_perm_id not in self._data.permissions: + self._data.permissions[auto_perm_id] = _PermissionData( + display_name=f"{org.display_name} Admin", + orgs={key: True}, # This org can grant its own admin permission + ) + else: + # Ensure this org can grant its own admin permission + self._data.permissions[auto_perm_id].orgs[key] = True + # Reflect the automatically added permission in the dataclass instance + if auto_perm_id not in org.permissions: + org.permissions.append(auto_perm_id) + + async def get_organization(self, org_id: str) -> Org: + async with self._lock: + # org_id is a UUID string + if org_id not in self._data.orgs: + raise ValueError("Organization not found") + o = self._data.orgs[org_id] + # Get permissions that this org can grant + permissions = [] + for perm_id, p in self._data.permissions.items(): + if org_id in p.orgs: + permissions.append(perm_id) + org = Org( + uuid=UUID(org_id), # Use the key directly + display_name=o.display_name, + permissions=permissions, + ) + # Load roles for this org + roles = [] + for role_uuid_str, r in self._data.roles.items(): + if r.org == org_id: + roles.append( + Role( + uuid=UUID(role_uuid_str), # Use the key directly + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions), + ) + ) + org.roles = roles + return org + + async def list_organizations(self) -> list[Org]: + async with self._lock: + orgs = [] + for org_uuid_str, o in self._data.orgs.items(): + # Get permissions that this org can grant + permissions = [] + for perm_id, p in self._data.permissions.items(): + if org_uuid_str in p.orgs: + permissions.append(perm_id) + org = Org( + uuid=UUID(org_uuid_str), # Use the key directly + display_name=o.display_name, + permissions=permissions, + ) + # Load roles for this org + roles = [] + for role_uuid_str, r in self._data.roles.items(): + if r.org == org_uuid_str: + roles.append( + Role( + uuid=UUID(role_uuid_str), # Use the key directly + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions), + ) + ) + org.roles = roles + orgs.append(org) + return orgs + + async def update_organization(self, org: Org) -> None: + async with self.session(): + 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 + # 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 + for perm_id in org.permissions: + if perm_id in self._data.permissions: + self._data.permissions[perm_id].orgs[key] = True + + async def delete_organization(self, org_uuid: UUID) -> None: + async with self.session(): + 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] + + async def add_user_to_organization( + self, user_uuid: UUID, org_id: str, role: str + ) -> None: + async with self.session(): + 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 + + async def transfer_user_to_organization( + self, user_uuid: UUID, new_org_id: str, new_role: str | None = None + ) -> None: + raise ValueError("Users cannot be transferred to a different organization") + + async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: + async with self._lock: + 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] + org_uuid = r.org + if org_uuid not in self._data.orgs: + raise ValueError("Organization not found") + o = self._data.orgs[org_uuid] + org = Org( + uuid=UUID(org_uuid), + display_name=o.display_name, + permissions=[], # Could populate from permissions if needed + ) + return org, r.display_name + + async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: + async with self._lock: + # Get all roles for this org + org_role_uuids = { + role_uuid_str + for role_uuid_str, r in self._data.roles.items() + if r.org == org_id + } + results = [] + for user_uuid_str, u in self._data.users.items(): + if u.role in org_role_uuids: + role_name = self._data.roles[u.role].display_name + user = User( + uuid=UUID(user_uuid_str), + display_name=u.display_name, + role_uuid=UUID(u.role), + created_at=u.created_at, + last_seen=u.last_seen, + visits=u.visits, + ) + results.append((user, role_name)) + return results + + async def get_roles_by_organization(self, org_id: str) -> list[Role]: + async with self._lock: + roles = [] + for role_uuid_str, r in self._data.roles.items(): + if r.org == org_id: + roles.append( + Role( + uuid=UUID(role_uuid_str), # Use the key directly + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions), + ) + ) + return roles + + async def get_user_role_in_organization( + self, user_uuid: UUID, org_id: str + ) -> str | None: + async with self._lock: + 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 + + async def update_user_role_in_organization( + self, user_uuid: UUID, new_role: str + ) -> None: + async with self.session(): + 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 + # ------------------------------------------------------------------------- + + async def create_permission(self, permission: Permission) -> None: + async with self.session(): + self._data.permissions[permission.id] = _PermissionData( + display_name=permission.display_name, + orgs={}, # Will be populated when orgs are allowed to grant this permission + ) + + async def get_permission(self, permission_id: str) -> Permission: + async with self._lock: + if permission_id not in self._data.permissions: + raise ValueError("Permission not found") + p = self._data.permissions[permission_id] + return Permission(id=permission_id, display_name=p.display_name) + + async def list_permissions(self) -> list[Permission]: + async with self._lock: + return [ + Permission(id=pid, display_name=p.display_name) + for pid, p in self._data.permissions.items() + ] + + async def update_permission(self, permission: Permission) -> None: + async with self.session(): + if permission.id not in self._data.permissions: + raise ValueError("Permission not found") + self._data.permissions[permission.id].display_name = permission.display_name + + async def delete_permission(self, permission_id: str) -> None: + async with self.session(): + if permission_id in self._data.permissions: + del self._data.permissions[permission_id] + # Remove from roles (permissions is a dict) + for r in self._data.roles.values(): + if permission_id in r.permissions: + del r.permissions[permission_id] + + async def rename_permission( + self, old_id: str, new_id: str, display_name: str + ) -> None: + async with self.session(): + if old_id == new_id: + if old_id in self._data.permissions: + self._data.permissions[old_id].display_name = display_name + return + if old_id not in self._data.permissions: + raise ValueError("Original permission not found") + if new_id in self._data.permissions: + raise ValueError("New permission id already exists") + + # Create new permission with same orgs + old_perm = self._data.permissions[old_id] + self._data.permissions[new_id] = _PermissionData( + display_name=display_name, + orgs=dict(old_perm.orgs), + ) + # Update role references (roles store permissions as dict) + for r in self._data.roles.values(): + if old_id in r.permissions: + del r.permissions[old_id] + r.permissions[new_id] = True + # Delete old permission + del self._data.permissions[old_id] + + async def add_permission_to_organization( + self, org_id: str, permission_id: str + ) -> None: + async with self.session(): + if org_id not in self._data.orgs: + raise ValueError("Organization not found") + if permission_id not in self._data.permissions: + raise ValueError("Permission not found") + self._data.permissions[permission_id].orgs[org_id] = True + + async def remove_permission_from_organization( + self, org_id: str, permission_id: str + ) -> None: + async with self.session(): + if permission_id in self._data.permissions: + orgs = self._data.permissions[permission_id].orgs + if org_id in orgs: + del orgs[org_id] + + async def get_organization_permissions(self, org_id: str) -> list[Permission]: + async with self._lock: + 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(id=pid, display_name=p.display_name)) + return permissions + + async def get_permission_organizations(self, permission_id: str) -> list[Org]: + async with self._lock: + if permission_id not in self._data.permissions: + return [] + org_ids = self._data.permissions[permission_id].orgs + orgs = [] + for org_id in org_ids: + if org_id in self._data.orgs: + o = self._data.orgs[org_id] + # Get permissions for this org + permissions = [] + for pid, p in self._data.permissions.items(): + if org_id in p.orgs: + permissions.append(pid) + orgs.append( + Org( + uuid=UUID(org_id), + display_name=o.display_name, + permissions=permissions, + ) + ) + return orgs + + # ------------------------------------------------------------------------- + # Role-permission operations + # ------------------------------------------------------------------------- + + async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None: + async with self.session(): + key = str(role_uuid) + if key not in self._data.roles: + raise ValueError("Role not found") + if permission_id not in self._data.permissions: + raise ValueError("Permission not found") + self._data.roles[key].permissions[permission_id] = True + + async def remove_permission_from_role( + self, role_uuid: UUID, permission_id: str + ) -> None: + async with self.session(): + key = str(role_uuid) + if key in self._data.roles: + if permission_id in self._data.roles[key].permissions: + del self._data.roles[key].permissions[permission_id] + + async def get_role_permissions(self, role_uuid: UUID) -> list[Permission]: + async with self._lock: + key = str(role_uuid) + if key not in self._data.roles: + return [] + perm_ids = list(self._data.roles[key].permissions) + permissions = [] + for pid in perm_ids: + if pid in self._data.permissions: + p = self._data.permissions[pid] + permissions.append(Permission(id=pid, display_name=p.display_name)) + return permissions + + async def get_permission_roles(self, permission_id: str) -> list[Role]: + async with self._lock: + roles = [] + for role_uuid_str, r in self._data.roles.items(): + if permission_id in r.permissions: + roles.append( + Role( + uuid=UUID(role_uuid_str), # Use the key directly + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions), + ) + ) + return roles + + # ------------------------------------------------------------------------- + # Combined operations + # ------------------------------------------------------------------------- + + async def login(self, user_uuid: UUID, credential: Credential) -> None: + async with self.session(): + # 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 + ) + + async def create_user_and_credential( + self, user: User, credential: Credential + ) -> None: + async with self.session(): + # 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, + ) + + async 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, + ) -> None: + async with self.session(): + 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 + 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, + renewed=credential.last_used, + ) + + # 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 + ) + + async def cleanup(self) -> None: + async with self.session(): + current_time = datetime.now(timezone.utc) + session_threshold = current_time - SESSION_LIFETIME + + # Clean expired sessions + to_delete_sessions = [] + for k, s in self._data.sessions.items(): + renewed = s.renewed + if renewed and renewed < session_threshold: + 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(): + expiry = t.expiry + if expiry and expiry < current_time: + to_delete_tokens.append(k) + for k in to_delete_tokens: + del self._data.reset_tokens[k] + + async def get_session_context( + self, session_key: bytes, host: str | None = None + ) -> SessionContext | None: + # Need to acquire session lock for potential write (host binding) + async with self._lock: + 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 + # Mark for save + await self._save() + elif s.host != host: + return None + + # Build session object + session_obj = Session( + key=_str_to_bytes(sess_key_b64), # type: ignore[arg-type] + user_uuid=UUID(s.user), + credential_uuid=UUID(s.credential), + host=s.host, + ip=s.ip, + user_agent=s.user_agent, + renewed=s.renewed, # Already datetime + ) + + # Get user + user_key = s.user + if user_key not in self._data.users: + return None + u = self._data.users[user_key] + user_obj = User( + uuid=UUID(user_key), + display_name=u.display_name, + role_uuid=UUID(u.role), + created_at=u.created_at, + last_seen=u.last_seen, + visits=u.visits, + ) + + # Get role + role_uuid = u.role + if role_uuid not in self._data.roles: + return None + r = self._data.roles[role_uuid] + role_obj = Role( + uuid=UUID(role_uuid), + org_uuid=UUID(r.org), + display_name=r.display_name, + permissions=list(r.permissions), + ) + + # Get org + org_uuid = r.org + if org_uuid not in self._data.orgs: + return None + o = self._data.orgs[org_uuid] + org_obj = Org( + uuid=UUID(org_uuid), # Use the key directly + display_name=o.display_name, + permissions=[], # Could populate from permissions if needed + ) + + # Get credential (optional) + cred_uuid = s.credential + credential_obj = None + if cred_uuid in self._data.credentials: + c = self._data.credentials[cred_uuid] + credential_obj = Credential( + uuid=UUID(cred_uuid), # Use the key directly + credential_id=c.credential_id, # Already bytes + user_uuid=UUID(c.user), + aaguid=UUID(c.aaguid), + public_key=c.public_key, # Already bytes + sign_count=c.sign_count, + created_at=c.created_at, # Already datetime + last_used=c.last_used, + last_verified=c.last_verified, + ) + + # Collect permissions for the role + permissions = [] + for pid in role_obj.permissions: + if pid in self._data.permissions: + p = self._data.permissions[pid] + permissions.append(Permission(id=pid, display_name=p.display_name)) + + # Filter effective permissions: only include permissions that the org can grant + effective_permissions = [ + p for p in permissions if p.id in org_obj.permissions + ] + + # Filter effective permissions: only include permissions that the org can grant + effective_permissions = [ + p for p in permissions if p.id in org_obj.permissions + ] + + return SessionContext( + session=session_obj, + user=user_obj, + org=org_obj, + role=role_obj, + credential=credential_obj, + permissions=effective_permissions if effective_permissions else None, + ) diff --git a/paskia/db/sql.py b/paskia/db/sql.py deleted file mode 100644 index 49b7d5b..0000000 --- a/paskia/db/sql.py +++ /dev/null @@ -1,1424 +0,0 @@ -""" -Async database implementation for WebAuthn passkey authentication. - -This module provides an async database layer using SQLAlchemy async mode -for managing users and credentials in a WebAuthn authentication system. -""" - -import os -from contextlib import asynccontextmanager -from datetime import datetime, timezone -from uuid import UUID - -from sqlalchemy import ( - DateTime, - ForeignKey, - Integer, - LargeBinary, - String, - delete, - event, - insert, - select, - text, - update, -) -from sqlalchemy.dialects.sqlite import BLOB -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - -from paskia.config import SESSION_LIFETIME -from paskia.db import ( - Credential, - DatabaseInterface, - Org, - Permission, - ResetToken, - Role, - Session, - SessionContext, - User, -) -from paskia.globals import db - -DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite" - - -def _normalize_dt(value: datetime | None) -> datetime | None: - if value is None: - return None - if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) - - -async def init(*args, **kwargs): - db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) - db.instance = DB(db_path) - await db.instance.init_db() - - -class Base(DeclarativeBase): - pass - - -class OrgModel(Base): - __tablename__ = "orgs" - - uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) - display_name: Mapped[str] = mapped_column(String, nullable=False) - - def as_dataclass(self): - # Base Org without permissions/roles (filled by data accessors) - return Org(UUID(bytes=self.uuid), self.display_name) - - @staticmethod - def from_dataclass(org: Org): - return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name) - - -class RoleModel(Base): - __tablename__ = "roles" - - uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) - org_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE"), nullable=False - ) - display_name: Mapped[str] = mapped_column(String, nullable=False) - - def as_dataclass(self): - # Base Role without permissions (filled by data accessors) - return Role( - uuid=UUID(bytes=self.uuid), - org_uuid=UUID(bytes=self.org_uuid), - display_name=self.display_name, - ) - - @staticmethod - def from_dataclass(role: Role): - return RoleModel( - uuid=role.uuid.bytes, - org_uuid=role.org_uuid.bytes, - display_name=role.display_name, - ) - - -class UserModel(Base): - __tablename__ = "users" - - uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) - display_name: Mapped[str] = mapped_column(String, nullable=False) - role_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - last_seen: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - - def as_dataclass(self) -> User: - return User( - uuid=UUID(bytes=self.uuid), - display_name=self.display_name, - role_uuid=UUID(bytes=self.role_uuid), - created_at=_normalize_dt(self.created_at) or self.created_at, - last_seen=_normalize_dt(self.last_seen) or self.last_seen, - visits=self.visits, - ) - - @staticmethod - def from_dataclass(user: User): - return UserModel( - uuid=user.uuid.bytes, - display_name=user.display_name, - role_uuid=user.role_uuid.bytes, - created_at=user.created_at or datetime.now(timezone.utc), - last_seen=user.last_seen, - visits=user.visits, - ) - - -class CredentialModel(Base): - __tablename__ = "credentials" - - uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) - credential_id: Mapped[bytes] = mapped_column( - LargeBinary(64), unique=True, index=True - ) - user_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE") - ) - aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False) - public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False) - sign_count: Mapped[int] = mapped_column(Integer, nullable=False) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - # Columns declared timezone-aware going forward; legacy rows may still be naive in storage - last_used: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - last_verified: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - - def as_dataclass(self): # type: ignore[override] - return Credential( - uuid=UUID(bytes=self.uuid), - credential_id=self.credential_id, - user_uuid=UUID(bytes=self.user_uuid), - aaguid=UUID(bytes=self.aaguid), - public_key=self.public_key, - sign_count=self.sign_count, - created_at=_normalize_dt(self.created_at) or self.created_at, - last_used=_normalize_dt(self.last_used) or self.last_used, - last_verified=_normalize_dt(self.last_verified) or self.last_verified, - ) - - -class SessionModel(Base): - __tablename__ = "sessions" - - key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) - user_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False - ) - credential_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), - ForeignKey("credentials.uuid", ondelete="CASCADE"), - nullable=False, - ) - host: Mapped[str] = mapped_column(String, nullable=False) - ip: Mapped[str] = mapped_column(String(64), nullable=False) - user_agent: Mapped[str] = mapped_column(String(512), nullable=False) - renewed: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - nullable=False, - ) - - def as_dataclass(self): - return Session( - key=self.key, - user_uuid=UUID(bytes=self.user_uuid), - credential_uuid=UUID(bytes=self.credential_uuid), - host=self.host, - ip=self.ip, - user_agent=self.user_agent, - renewed=_normalize_dt(self.renewed) or self.renewed, - ) - - @staticmethod - def from_dataclass(session: Session): - return SessionModel( - key=session.key, - user_uuid=session.user_uuid.bytes, - credential_uuid=session.credential_uuid.bytes, - host=session.host, - ip=session.ip, - user_agent=session.user_agent, - renewed=session.renewed, - ) - - -class ResetTokenModel(Base): - __tablename__ = "reset_tokens" - - key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) - user_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False - ) - token_type: Mapped[str] = mapped_column(String, nullable=False) - expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - - def as_dataclass(self) -> ResetToken: - return ResetToken( - key=self.key, - user_uuid=UUID(bytes=self.user_uuid), - token_type=self.token_type, - expiry=_normalize_dt(self.expiry) or self.expiry, - ) - - -class PermissionModel(Base): - __tablename__ = "permissions" - - id: Mapped[str] = mapped_column(String(64), primary_key=True) - display_name: Mapped[str] = mapped_column(String, nullable=False) - - def as_dataclass(self): - return Permission(self.id, self.display_name) - - @staticmethod - def from_dataclass(permission: Permission): - return PermissionModel(id=permission.id, display_name=permission.display_name) - - -## Join tables (no dataclass equivalents) - - -class OrgPermission(Base): - """Permissions each organization is allowed to grant to its roles.""" - - __tablename__ = "org_permissions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) # Not used - org_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE") - ) - permission_id: Mapped[str] = mapped_column( - String(64), ForeignKey("permissions.id", ondelete="CASCADE") - ) - - -class RolePermission(Base): - """Permissions that each role grants to its members.""" - - __tablename__ = "role_permissions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) # Not used - role_uuid: Mapped[bytes] = mapped_column( - LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE") - ) - permission_id: Mapped[str] = mapped_column( - String(64), ForeignKey("permissions.id", ondelete="CASCADE") - ) - - -class DB(DatabaseInterface): - """Database class that handles its own connections.""" - - def __init__(self, db_path: str = DB_PATH_DEFAULT): - """Initialize with database path.""" - self.engine = create_async_engine(db_path, echo=False) - # Ensure SQLite foreign key enforcement is ON for every new connection - if db_path.startswith("sqlite"): - - @event.listens_for(self.engine.sync_engine, "connect") - def _fk_on(dbapi_connection, connection_record): # type: ignore - try: - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON;") - cursor.close() - except Exception: - pass - - self.async_session_factory = async_sessionmaker( - self.engine, expire_on_commit=False - ) - - @asynccontextmanager - async def session(self): - """Async context manager that provides a database session with transaction.""" - async with self.async_session_factory() as session: - async with session.begin(): - yield session - await session.flush() - await session.commit() - - async def init_db(self) -> None: - """Initialize database tables.""" - async with self.engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - result = await conn.execute(text("PRAGMA table_info('sessions')")) - columns = {row[1] for row in result} - expected = { - "key", - "user_uuid", - "credential_uuid", - "host", - "ip", - "user_agent", - "renewed", - } - needs_recreate = False - if columns and columns != expected: - await conn.execute(text("DROP TABLE sessions")) - needs_recreate = True - result = await conn.execute(text("PRAGMA table_info('reset_tokens')")) - if not list(result): - needs_recreate = True - if needs_recreate: - await conn.run_sync(Base.metadata.create_all) - # Run one-time migration to add UTC tzinfo to any naive datetimes - await self._migrate_naive_datetimes() - - async def _migrate_naive_datetimes(self) -> None: - """Attach UTC tzinfo to any legacy naive datetime rows. - - SQLite stores datetimes as text; older rows may have been inserted naive. - We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z. - """ - # Helper SQL fragment for detecting naive (no timezone offset) for ISO strings - # We only update rows whose textual representation lacks a 'Z' or '+' sign. - async with self.session() as session: - # Users - for model, fields in [ - (UserModel, ["created_at", "last_seen"]), - (CredentialModel, ["created_at", "last_used", "last_verified"]), - (SessionModel, ["renewed"]), - (ResetTokenModel, ["expiry"]), - ]: - stmt = select(model) - result = await session.execute(stmt) - rows = result.scalars().all() - dirty = False - for row in rows: - for fname in fields: - value = getattr(row, fname, None) - if isinstance(value, datetime) and value.tzinfo is None: - setattr(row, fname, value.replace(tzinfo=timezone.utc)) - dirty = True - if dirty: - # SQLAlchemy autoflush/commit in context manager will persist - pass - - async def get_user_by_uuid(self, user_uuid: UUID) -> User: - async with self.session() as session: - stmt = select(UserModel).where(UserModel.uuid == user_uuid.bytes) - result = await session.execute(stmt) - user_model = result.scalar_one_or_none() - - if user_model: - return user_model.as_dataclass() - raise ValueError("User not found") - - async def create_user(self, user: User) -> None: - async with self.session() as session: - session.add(UserModel.from_dataclass(user)) - - async def update_user_display_name( - self, user_uuid: UUID, display_name: str - ) -> None: - async with self.session() as session: - stmt = ( - update(UserModel) - .where(UserModel.uuid == user_uuid.bytes) - .values(display_name=display_name) - ) - result = await session.execute(stmt) - if result.rowcount == 0: # type: ignore[attr-defined] - raise ValueError("User not found") - - async def create_role(self, role: Role) -> None: - async with self.session() as session: - # Create role record - session.add(RoleModel.from_dataclass(role)) - # Persist role permissions - if role.permissions: - for perm_id in role.permissions: - session.add( - RolePermission( - role_uuid=role.uuid.bytes, - permission_id=perm_id, - ) - ) - - async def create_credential(self, credential: Credential) -> None: - async with self.session() as session: - credential_model = CredentialModel( - uuid=credential.uuid.bytes, - credential_id=credential.credential_id, - user_uuid=credential.user_uuid.bytes, - aaguid=credential.aaguid.bytes, - 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, - ) - session.add(credential_model) - - async def get_credential_by_id(self, credential_id: bytes) -> Credential: - async with self.session() as session: - stmt = select(CredentialModel).where( - CredentialModel.credential_id == credential_id - ) - result = await session.execute(stmt) - credential_model = result.scalar_one_or_none() - - if not credential_model: - raise ValueError("Credential not found") - return Credential( - uuid=UUID(bytes=credential_model.uuid), - credential_id=credential_model.credential_id, - user_uuid=UUID(bytes=credential_model.user_uuid), - aaguid=UUID(bytes=credential_model.aaguid), - public_key=credential_model.public_key, - sign_count=credential_model.sign_count, - created_at=credential_model.created_at, - last_used=credential_model.last_used, - last_verified=credential_model.last_verified, - ) - - async def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]: - async with self.session() as session: - stmt = select(CredentialModel.credential_id).where( - CredentialModel.user_uuid == user_uuid.bytes - ) - result = await session.execute(stmt) - return [row[0] for row in result.fetchall()] - - async def update_credential(self, credential: Credential) -> None: - async with self.session() as session: - stmt = ( - update(CredentialModel) - .where(CredentialModel.credential_id == credential.credential_id) - .values( - sign_count=credential.sign_count, - created_at=credential.created_at, - last_used=credential.last_used, - last_verified=credential.last_verified, - ) - ) - await session.execute(stmt) - - async def login(self, user_uuid: UUID, credential: Credential) -> None: - async with self.session() as session: - # Update credential - stmt = ( - update(CredentialModel) - .where(CredentialModel.credential_id == credential.credential_id) - .values( - sign_count=credential.sign_count, - created_at=credential.created_at, - last_used=credential.last_used, - last_verified=credential.last_verified, - ) - ) - await session.execute(stmt) - - # Update user's last_seen and increment visits - stmt = ( - update(UserModel) - .where(UserModel.uuid == user_uuid.bytes) - .values(last_seen=credential.last_used, visits=UserModel.visits + 1) - ) - await session.execute(stmt) - - async def create_user_and_credential( - self, user: User, credential: Credential - ) -> None: - async with self.session() as session: - # Create user - user_model = UserModel.from_dataclass(user) - session.add(user_model) - - # Create credential - credential_model = CredentialModel( - uuid=credential.uuid.bytes, - credential_id=credential.credential_id, - user_uuid=credential.user_uuid.bytes, - aaguid=credential.aaguid.bytes, - 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, - ) - session.add(credential_model) - - async 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, - ) -> None: - """Atomic credential + (optional old session delete) + (optional rename) + new session.""" - async with self.session() as session: - # Ensure credential has last_used / last_verified for immediate login semantics - 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 - session.add( - CredentialModel( - uuid=credential.uuid.bytes, - credential_id=credential.credential_id, - user_uuid=credential.user_uuid.bytes, - aaguid=credential.aaguid.bytes, - 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, - ) - ) - # Delete old reset token if provided - if reset_key: - await session.execute( - delete(ResetTokenModel).where(ResetTokenModel.key == reset_key) - ) - # Optional rename - if display_name: - await session.execute( - update(UserModel) - .where(UserModel.uuid == user_uuid.bytes) - .values(display_name=display_name) - ) - # New session - session.add( - SessionModel( - key=session_key, - user_uuid=user_uuid.bytes, - credential_uuid=credential.uuid.bytes, - host=host, - ip=ip, - user_agent=user_agent, - ) - ) - # Login side-effects: update user analytics (last_seen + visits increment) - await session.execute( - update(UserModel) - .where(UserModel.uuid == user_uuid.bytes) - .values(last_seen=credential.last_used, visits=UserModel.visits + 1) - ) - - async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None: - async with self.session() as session: - stmt = ( - delete(CredentialModel) - .where(CredentialModel.uuid == uuid.bytes) - .where(CredentialModel.user_uuid == user_uuid.bytes) - ) - await session.execute(stmt) - - async def create_session( - self, - user_uuid: UUID, - key: bytes, - credential_uuid: UUID, - host: str, - ip: str, - user_agent: str, - renewed: datetime, - ) -> None: - async with self.session() as session: - session_model = SessionModel( - key=key, - user_uuid=user_uuid.bytes, - credential_uuid=credential_uuid.bytes, - host=host, - ip=ip, - user_agent=user_agent, - renewed=renewed, - ) - session.add(session_model) - - async def get_session(self, key: bytes) -> Session | None: - async with self.session() as session: - stmt = select(SessionModel).where(SessionModel.key == key) - result = await session.execute(stmt) - session_model = result.scalar_one_or_none() - - if session_model: - return session_model.as_dataclass() - return None - - async def delete_session(self, key: bytes) -> None: - async with self.session() as session: - await session.execute(delete(SessionModel).where(SessionModel.key == key)) - - async def delete_sessions_for_user(self, user_uuid: UUID) -> None: - async with self.session() as session: - await session.execute( - delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes) - ) - - async def create_reset_token( - self, - user_uuid: UUID, - key: bytes, - expiry: datetime, - token_type: str, - ) -> None: - async with self.session() as session: - model = ResetTokenModel( - key=key, - user_uuid=user_uuid.bytes, - token_type=token_type, - expiry=expiry, - ) - session.add(model) - - async def get_reset_token(self, key: bytes) -> ResetToken | None: - async with self.session() as session: - stmt = select(ResetTokenModel).where(ResetTokenModel.key == key) - result = await session.execute(stmt) - model = result.scalar_one_or_none() - return model.as_dataclass() if model else None - - async def delete_reset_token(self, key: bytes) -> None: - async with self.session() as session: - await session.execute( - delete(ResetTokenModel).where(ResetTokenModel.key == key) - ) - - async def update_session( - self, - key: bytes, - *, - ip: str, - user_agent: str, - renewed: datetime, - ) -> Session | None: - async with self.session() as session: - model = await session.get(SessionModel, key) - if not model: - return None - model.ip = ip - model.user_agent = user_agent - model.renewed = renewed - await session.flush() - return model.as_dataclass() - - async def set_session_host(self, key: bytes, host: str) -> None: - async with self.session() as session: - model = await session.get(SessionModel, key) - if model and model.host is None: - model.host = host - await session.flush() - - async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: - async with self.session() as session: - stmt = ( - select(SessionModel) - .where(SessionModel.user_uuid == user_uuid.bytes) - .order_by(SessionModel.renewed.desc()) - ) - result = await session.execute(stmt) - session_models = [ - model - for model in result.scalars().all() - if model.key.startswith(b"sess") - ] - return [model.as_dataclass() for model in session_models] - - # Organization operations - async def create_organization(self, org: Org) -> None: - async with self.session() as session: - org_model = OrgModel( - uuid=org.uuid.bytes, - display_name=org.display_name, - ) - session.add(org_model) - # Persist any explicitly provided org grantable permissions - if org.permissions: - for perm_id in set(org.permissions): - session.add( - OrgPermission(org_uuid=org.uuid.bytes, permission_id=perm_id) - ) - - # Automatically create an organization admin permission if not present. - auto_perm_id = f"auth:org:{org.uuid}" - # Only create if it does not already exist (in case caller passed it) - existing_perm = await session.execute( - select(PermissionModel).where(PermissionModel.id == auto_perm_id) - ) - if not existing_perm.scalar_one_or_none(): - session.add( - PermissionModel( - id=auto_perm_id, - display_name=f"{org.display_name} Admin", - ) - ) - # Ensure org is allowed to grant its own admin permission (insert if missing) - existing_org_perm = await session.execute( - select(OrgPermission).where( - OrgPermission.org_uuid == org.uuid.bytes, - OrgPermission.permission_id == auto_perm_id, - ) - ) - if not existing_org_perm.scalar_one_or_none(): - session.add( - OrgPermission(org_uuid=org.uuid.bytes, permission_id=auto_perm_id) - ) - # Reflect the automatically added permission in the dataclass instance - if auto_perm_id not in org.permissions: - org.permissions.append(auto_perm_id) - - async def get_organization(self, org_id: str) -> Org: - async with self.session() as session: - # Convert string ID to UUID bytes for lookup - org_uuid = UUID(org_id) - stmt = select(OrgModel).where(OrgModel.uuid == org_uuid.bytes) - result = await session.execute(stmt) - org_model = result.scalar_one_or_none() - - if not org_model: - raise ValueError("Organization not found") - - # Build Org with permissions and roles - org_dc = org_model.as_dataclass() - - # Load org permission IDs - perm_stmt = select(OrgPermission.permission_id).where( - OrgPermission.org_uuid == org_uuid.bytes - ) - perm_result = await session.execute(perm_stmt) - org_dc.permissions = [row[0] for row in perm_result.fetchall()] - - # Load roles for org - roles_stmt = select(RoleModel).where(RoleModel.org_uuid == org_uuid.bytes) - roles_result = await session.execute(roles_stmt) - roles_models = roles_result.scalars().all() - roles: list[Role] = [] - if roles_models: - # For each role, load permission IDs - for r_model in roles_models: - r_dc = r_model.as_dataclass() - r_perm_stmt = select(RolePermission.permission_id).where( - RolePermission.role_uuid == r_model.uuid - ) - r_perm_result = await session.execute(r_perm_stmt) - r_dc.permissions = [row[0] for row in r_perm_result.fetchall()] - roles.append(r_dc) - org_dc.roles = roles - - return org_dc - - async def list_organizations(self) -> list[Org]: - async with self.session() as session: - # Load all orgs - orgs_result = await session.execute(select(OrgModel)) - org_models = orgs_result.scalars().all() - if not org_models: - return [] - - # Preload org permissions mapping - org_perms_result = await session.execute(select(OrgPermission)) - org_perms = org_perms_result.scalars().all() - perms_by_org: dict[bytes, list[str]] = {} - for op in org_perms: - perms_by_org.setdefault(op.org_uuid, []).append(op.permission_id) - - # Preload roles - roles_result = await session.execute(select(RoleModel)) - role_models = roles_result.scalars().all() - - # Preload role permissions mapping - rp_result = await session.execute(select(RolePermission)) - rps = rp_result.scalars().all() - perms_by_role: dict[bytes, list[str]] = {} - for rp in rps: - perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id) - - # Build org dataclasses with roles and permission IDs - roles_by_org: dict[bytes, list[Role]] = {} - for rm in role_models: - r_dc = rm.as_dataclass() - r_dc.permissions = perms_by_role.get(rm.uuid, []) - roles_by_org.setdefault(rm.org_uuid, []).append(r_dc) - - orgs: list[Org] = [] - for om in org_models: - o_dc = om.as_dataclass() - o_dc.permissions = perms_by_org.get(om.uuid, []) - o_dc.roles = roles_by_org.get(om.uuid, []) - orgs.append(o_dc) - - return orgs - - async def update_organization(self, org: Org) -> None: - async with self.session() as session: - stmt = ( - update(OrgModel) - .where(OrgModel.uuid == org.uuid.bytes) - .values(display_name=org.display_name) - ) - await session.execute(stmt) - # Synchronize org permissions join table to match org.permissions - # Delete existing rows for this org - await session.execute( - delete(OrgPermission).where(OrgPermission.org_uuid == org.uuid.bytes) - ) - # Insert new rows - if org.permissions: - for perm_id in org.permissions: - await session.merge( - OrgPermission(org_uuid=org.uuid.bytes, permission_id=perm_id) - ) - - async def delete_organization(self, org_uuid: UUID) -> None: - async with self.session() as session: - # Convert string ID to UUID bytes for lookup - stmt = delete(OrgModel).where(OrgModel.uuid == org_uuid.bytes) - await session.execute(stmt) - - async def add_user_to_organization( - self, user_uuid: UUID, org_id: str, role: str - ) -> None: - async with self.session() as session: - org_uuid = UUID(org_id) - # Get user and organization models - user_stmt = select(UserModel).where(UserModel.uuid == user_uuid.bytes) - user_result = await session.execute(user_stmt) - user_model = user_result.scalar_one_or_none() - - # Convert string ID to UUID bytes for lookup - org_stmt = select(OrgModel).where(OrgModel.uuid == org_uuid.bytes) - org_result = await session.execute(org_stmt) - org_model = org_result.scalar_one_or_none() - - if not user_model: - raise ValueError("User not found") - if not org_model: - raise ValueError("Organization not found") - - # Find the role within this organization by display_name - role_stmt = select(RoleModel).where( - RoleModel.org_uuid == org_uuid.bytes, - RoleModel.display_name == role, - ) - role_result = await session.execute(role_stmt) - role_model = role_result.scalar_one_or_none() - if not role_model: - raise ValueError("Role not found in organization") - - # Update the user's role assignment - stmt = ( - update(UserModel) - .where(UserModel.uuid == user_uuid.bytes) - .values(role_uuid=role_model.uuid) - ) - await session.execute(stmt) - - async def transfer_user_to_organization( - self, user_uuid: UUID, new_org_id: str, new_role: str | None = None - ) -> None: - # Users are members of an org that never changes after creation. - # Disallow transfers across organizations to enforce invariant. - raise ValueError("Users cannot be transferred to a different organization") - - async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: - async with self.session() as session: - stmt = select(UserModel).where(UserModel.uuid == user_uuid.bytes) - result = await session.execute(stmt) - user_model = result.scalar_one_or_none() - - if not user_model: - raise ValueError("User not found") - - # Find user's role to get org - role_stmt = select(RoleModel).where(RoleModel.uuid == user_model.role_uuid) - role_result = await session.execute(role_stmt) - role_model = role_result.scalar_one() - - # Fetch the organization details - org_stmt = select(OrgModel).where(OrgModel.uuid == role_model.org_uuid) - org_result = await session.execute(org_stmt) - org_model = org_result.scalar_one() - - # Convert UUID bytes back to string for the interface - return org_model.as_dataclass(), role_model.display_name - - async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: - async with self.session() as session: - org_uuid = UUID(org_id) - # Join users with roles to filter by org and return role names - stmt = ( - select(UserModel, RoleModel.display_name) - .join(RoleModel, UserModel.role_uuid == RoleModel.uuid) - .where(RoleModel.org_uuid == org_uuid.bytes) - ) - result = await session.execute(stmt) - rows = result.fetchall() - return [(u.as_dataclass(), role_name) for (u, role_name) in rows] - - async def get_user_role_in_organization( - self, user_uuid: UUID, org_id: str - ) -> str | None: - """Get a user's role in a specific organization.""" - async with self.session() as session: - # Convert string ID to UUID bytes for lookup - org_uuid = UUID(org_id) - stmt = ( - select(RoleModel.display_name) - .select_from(UserModel) - .join(RoleModel, UserModel.role_uuid == RoleModel.uuid) - .where( - UserModel.uuid == user_uuid.bytes, - RoleModel.org_uuid == org_uuid.bytes, - ) - ) - result = await session.execute(stmt) - return result.scalar_one_or_none() - - async def update_user_role_in_organization( - self, user_uuid: UUID, new_role: str - ) -> None: - """Update a user's role in their organization.""" - async with self.session() as session: - # Find user's current org via their role - user_stmt = select(UserModel).where(UserModel.uuid == user_uuid.bytes) - user_result = await session.execute(user_stmt) - user_model = user_result.scalar_one_or_none() - if not user_model: - raise ValueError("User not found") - - current_role_stmt = select(RoleModel).where( - RoleModel.uuid == user_model.role_uuid - ) - current_role_result = await session.execute(current_role_stmt) - current_role = current_role_result.scalar_one() - - # Find the new role within the same organization - role_stmt = select(RoleModel).where( - RoleModel.org_uuid == current_role.org_uuid, - RoleModel.display_name == new_role, - ) - role_result = await session.execute(role_stmt) - role_model = role_result.scalar_one_or_none() - if not role_model: - raise ValueError("Role not found in user's organization") - - stmt = ( - update(UserModel) - .where(UserModel.uuid == user_uuid.bytes) - .values(role_uuid=role_model.uuid) - ) - await session.execute(stmt) - - # Permission operations - async def create_permission(self, permission: Permission) -> None: - async with self.session() as session: - permission_model = PermissionModel( - id=permission.id, - display_name=permission.display_name, - ) - session.add(permission_model) - - async def get_permission(self, permission_id: str) -> Permission: - async with self.session() as session: - stmt = select(PermissionModel).where(PermissionModel.id == permission_id) - result = await session.execute(stmt) - permission_model = result.scalar_one_or_none() - - if permission_model: - return Permission( - id=permission_model.id, - display_name=permission_model.display_name, - ) - raise ValueError("Permission not found") - - async def update_permission(self, permission: Permission) -> None: - async with self.session() as session: - stmt = ( - update(PermissionModel) - .where(PermissionModel.id == permission.id) - .values(display_name=permission.display_name) - ) - await session.execute(stmt) - - async def rename_permission( - self, old_id: str, new_id: str, display_name: str - ) -> None: - """Rename a permission's primary key and update referencing tables. - - Approach: insert new row (if id changes), update FKs, delete old row. - Wrapped in a transaction; will raise on conflict. - """ - if old_id == new_id: - # Just update display name - async with self.session() as session: - stmt = ( - update(PermissionModel) - .where(PermissionModel.id == old_id) - .values(display_name=display_name) - ) - await session.execute(stmt) - return - async with self.session() as session: - # Ensure old exists - existing_old = await session.execute( - select(PermissionModel).where(PermissionModel.id == old_id) - ) - if not existing_old.scalar_one_or_none(): - raise ValueError("Original permission not found") - - # Check new not taken - existing_new = await session.execute( - select(PermissionModel).where(PermissionModel.id == new_id) - ) - if existing_new.scalar_one_or_none(): - raise ValueError("New permission id already exists") - - # Create new permission row first - session.add(PermissionModel(id=new_id, display_name=display_name)) - await session.flush() - - # Update org_permissions - await session.execute( - update(OrgPermission) - .where(OrgPermission.permission_id == old_id) - .values(permission_id=new_id) - ) - await session.flush() - # Update role_permissions - await session.execute( - update(RolePermission) - .where(RolePermission.permission_id == old_id) - .values(permission_id=new_id) - ) - await session.flush() - # Delete old permission row - await session.execute( - delete(PermissionModel).where(PermissionModel.id == old_id) - ) - await session.flush() - - async def delete_permission(self, permission_id: str) -> None: - async with self.session() as session: - stmt = delete(PermissionModel).where(PermissionModel.id == permission_id) - await session.execute(stmt) - - async def list_permissions(self) -> list[Permission]: - async with self.session() as session: - result = await session.execute(select(PermissionModel)) - return [p.as_dataclass() for p in result.scalars().all()] - - async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None: - async with self.session() as session: - # Ensure role exists - role_stmt = select(RoleModel).where(RoleModel.uuid == role_uuid.bytes) - role_result = await session.execute(role_stmt) - role_model = role_result.scalar_one_or_none() - if not role_model: - raise ValueError("Role not found") - - # Ensure permission exists - perm_stmt = select(PermissionModel).where( - PermissionModel.id == permission_id - ) - perm_result = await session.execute(perm_stmt) - if not perm_result.scalar_one_or_none(): - raise ValueError("Permission not found") - - session.add( - RolePermission(role_uuid=role_uuid.bytes, permission_id=permission_id) - ) - - async def remove_permission_from_role( - self, role_uuid: UUID, permission_id: str - ) -> None: - async with self.session() as session: - await session.execute( - delete(RolePermission) - .where(RolePermission.role_uuid == role_uuid.bytes) - .where(RolePermission.permission_id == permission_id) - ) - - async def get_role_permissions(self, role_uuid: UUID) -> list[Permission]: - async with self.session() as session: - stmt = ( - select(PermissionModel) - .join( - RolePermission, PermissionModel.id == RolePermission.permission_id - ) - .where(RolePermission.role_uuid == role_uuid.bytes) - ) - result = await session.execute(stmt) - return [p.as_dataclass() for p in result.scalars().all()] - - async def get_permission_roles(self, permission_id: str) -> list[Role]: - async with self.session() as session: - stmt = ( - select(RoleModel) - .join(RolePermission, RoleModel.uuid == RolePermission.role_uuid) - .where(RolePermission.permission_id == permission_id) - ) - result = await session.execute(stmt) - return [r.as_dataclass() for r in result.scalars().all()] - - async def update_role(self, role: Role) -> None: - async with self.session() as session: - # Update role display_name - await session.execute( - update(RoleModel) - .where(RoleModel.uuid == role.uuid.bytes) - .values(display_name=role.display_name) - ) - # Sync role permissions: delete all then insert current set - await session.execute( - delete(RolePermission).where( - RolePermission.role_uuid == role.uuid.bytes - ) - ) - if role.permissions: - for perm_id in set(role.permissions): - await session.execute( - insert(RolePermission).values( - role_uuid=role.uuid.bytes, permission_id=perm_id - ) - ) - - async def delete_role(self, role_uuid: UUID) -> None: - async with self.session() as session: - # Prevent deleting a role that still has users - # Quick existence check for users assigned to the role - existing_user = await session.execute( - select(UserModel.uuid).where(UserModel.role_uuid == role_uuid.bytes) - ) - if existing_user.first() is not None: - raise ValueError("Cannot delete role with assigned users") - - await session.execute( - delete(RoleModel).where(RoleModel.uuid == role_uuid.bytes) - ) - - async def get_role(self, role_uuid: UUID) -> Role: - async with self.session() as session: - result = await session.execute( - select(RoleModel).where(RoleModel.uuid == role_uuid.bytes) - ) - role_model = result.scalar_one_or_none() - if not role_model: - raise ValueError("Role not found") - r_dc = role_model.as_dataclass() - perms_result = await session.execute( - select(RolePermission.permission_id).where( - RolePermission.role_uuid == role_uuid.bytes - ) - ) - r_dc.permissions = [row[0] for row in perms_result.fetchall()] - return r_dc - - async def get_roles_by_organization(self, org_id: str) -> list[Role]: - async with self.session() as session: - org_uuid = UUID(org_id) - result = await session.execute( - select(RoleModel).where(RoleModel.org_uuid == org_uuid.bytes) - ) - role_models = result.scalars().all() - roles: list[Role] = [] - for rm in role_models: - r_dc = rm.as_dataclass() - perms_result = await session.execute( - select(RolePermission.permission_id).where( - RolePermission.role_uuid == rm.uuid - ) - ) - r_dc.permissions = [row[0] for row in perms_result.fetchall()] - roles.append(r_dc) - return roles - - async def add_permission_to_organization( - self, org_id: str, permission_id: str - ) -> None: - async with self.session() as session: - # Get organization and permission models - org_uuid = UUID(org_id) - org_stmt = select(OrgModel).where(OrgModel.uuid == org_uuid.bytes) - org_result = await session.execute(org_stmt) - org_model = org_result.scalar_one_or_none() - - permission_stmt = select(PermissionModel).where( - PermissionModel.id == permission_id - ) - permission_result = await session.execute(permission_stmt) - permission_model = permission_result.scalar_one_or_none() - - if not org_model: - raise ValueError("Organization not found") - if not permission_model: - raise ValueError("Permission not found") - - # Create the org-permission relationship - org_permission = OrgPermission( - org_uuid=org_uuid.bytes, permission_id=permission_id - ) - session.add(org_permission) - - async def remove_permission_from_organization( - self, org_id: str, permission_id: str - ) -> None: - async with self.session() as session: - # Convert string ID to UUID bytes for lookup - org_uuid = UUID(org_id) - # Delete the org-permission relationship - stmt = delete(OrgPermission).where( - OrgPermission.org_uuid == org_uuid.bytes, - OrgPermission.permission_id == permission_id, - ) - await session.execute(stmt) - - async def get_organization_permissions(self, org_id: str) -> list[Permission]: - async with self.session() as session: - # Convert string ID to UUID bytes for lookup - org_uuid = UUID(org_id) - stmt = select(OrgPermission).where(OrgPermission.org_uuid == org_uuid.bytes) - result = await session.execute(stmt) - org_permission_models = result.scalars().all() - - # Fetch the permission details for each org-permission relationship - permissions = [] - for org_permission in org_permission_models: - permission_stmt = select(PermissionModel).where( - PermissionModel.id == org_permission.permission_id - ) - permission_result = await session.execute(permission_stmt) - permission_model = permission_result.scalar_one() - - permission = Permission( - id=permission_model.id, - display_name=permission_model.display_name, - ) - permissions.append(permission) - - return permissions - - async def get_permission_organizations(self, permission_id: str) -> list[Org]: - async with self.session() as session: - stmt = select(OrgPermission).where( - OrgPermission.permission_id == permission_id - ) - result = await session.execute(stmt) - org_permission_models = result.scalars().all() - - # Fetch the organization details for each org-permission relationship - organizations = [] - for org_permission in org_permission_models: - org_stmt = select(OrgModel).where( - OrgModel.uuid == org_permission.org_uuid - ) - org_result = await session.execute(org_stmt) - org_model = org_result.scalar_one() - organizations.append(org_model.as_dataclass()) - - return organizations - - async def cleanup(self) -> None: - async with self.session() as session: - current_time = datetime.now(timezone.utc) - session_threshold = current_time - SESSION_LIFETIME - await session.execute( - delete(SessionModel).where(SessionModel.renewed < session_threshold) - ) - await session.execute( - delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time) - ) - - async def get_session_context( - self, session_key: bytes, host: str | None = None - ) -> SessionContext | None: - """Get complete session context including user, organization, role, and permissions. - - Uses efficient JOINs to retrieve all related data in a single database query. - """ - async with self.session() as session: - # Build a query that joins sessions, users, roles, organizations, credentials and role_permissions - stmt = ( - select( - SessionModel, - UserModel, - RoleModel, - OrgModel, - CredentialModel, - PermissionModel, - ) - .select_from(SessionModel) - .join(UserModel, SessionModel.user_uuid == UserModel.uuid) - .join(RoleModel, UserModel.role_uuid == RoleModel.uuid) - .join(OrgModel, RoleModel.org_uuid == OrgModel.uuid) - .outerjoin( - CredentialModel, - SessionModel.credential_uuid == CredentialModel.uuid, - ) - .outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid) - .outerjoin( - PermissionModel, RolePermission.permission_id == PermissionModel.id - ) - .where(SessionModel.key == session_key) - ) - - result = await session.execute(stmt) - rows = result.fetchall() - - if not rows: - return None - - # Extract the first row to get session and user data - first_row = rows[0] - session_model, user_model, role_model, org_model, credential_model, _ = ( - first_row - ) - - # Create the session object - if host is not None: - if session_model.host is None: - await session.execute( - update(SessionModel) - .where(SessionModel.key == session_key) - .values(host=host) - ) - session_model.host = host - elif session_model.host != host: - return None - - session_obj = session_model.as_dataclass() - - # Create the user object - user_obj = user_model.as_dataclass() - - # Create organization object (fill permissions later if needed) - organization = Org(UUID(bytes=org_model.uuid), org_model.display_name) - - # Create role object - role = Role( - uuid=UUID(bytes=role_model.uuid), - org_uuid=UUID(bytes=role_model.org_uuid), - display_name=role_model.display_name, - ) - - # Create credential object if available - credential_obj = ( - credential_model.as_dataclass() if credential_model else None - ) - - # Collect all unique permissions for the role - permissions = [] - seen_permission_ids = set() - for row in rows: - _, _, _, _, _, permission_model = row - if permission_model and permission_model.id not in seen_permission_ids: - permissions.append( - Permission( - id=permission_model.id, - display_name=permission_model.display_name, - ) - ) - seen_permission_ids.add(permission_model.id) - - # Attach permission IDs to role - role.permissions = list(seen_permission_ids) - - # Load org permission IDs as well - org_perm_stmt = select(OrgPermission.permission_id).where( - OrgPermission.org_uuid == org_model.uuid - ) - org_perm_result = await session.execute(org_perm_stmt) - organization.permissions = [row[0] for row in org_perm_result.fetchall()] - - # Filter effective permissions: only include permissions that the org can grant - effective_permissions = [ - p for p in permissions if p.id in organization.permissions - ] - - return SessionContext( - session=session_obj, - user=user_obj, - org=organization, - role=role, - credential=credential_obj, - permissions=effective_permissions if effective_permissions else None, - ) diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index acf21c7..d197237 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse from paskia.authsession import reset_expires from paskia.fastapi import authz from paskia.fastapi.session import AUTH_COOKIE -from paskia.globals import db +from paskia import db from paskia.util import ( frontend, hostutil, @@ -59,7 +59,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): match=permutil.has_any, host=request.headers.get("host"), ) - orgs = await db.instance.list_organizations() + orgs = await db.list_organizations() if "auth:admin" not in ctx.role.permissions: orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions] @@ -72,7 +72,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): } async def org_to_dict(o): - users = await db.instance.get_organization_users(str(o.uuid)) + users = await db.get_organization_users(str(o.uuid)) return { "uuid": str(o.uuid), "display_name": o.display_name, @@ -107,7 +107,7 @@ async def admin_create_org( display_name = payload.get("display_name") or "New Organization" permissions = payload.get("permissions") or [] org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) - await db.instance.create_organization(org) + await db.create_organization(org) # Automatically create Administration role with org admin permission role_uuid = uuid4() @@ -117,7 +117,7 @@ async def admin_create_org( display_name="Administration", permissions=[f"auth:org:{org_uuid}"], ) - await db.instance.create_role(admin_role) + await db.create_role(admin_role) return {"uuid": str(org_uuid)} @@ -137,7 +137,7 @@ async def admin_update_org( ) from ..db import Org as OrgDC # local import to avoid cycles - current = await db.instance.get_organization(str(org_uuid)) + current = await db.get_organization(str(org_uuid)) display_name = payload.get("display_name") or current.display_name permissions = payload.get("permissions") if permissions is None: @@ -157,7 +157,7 @@ async def admin_update_org( ) org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) - await db.instance.update_organization(org) + await db.update_organization(org) return {"status": "ok"} @@ -175,7 +175,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): # Delete organization-specific permissions org_perm_pattern = f"org:{str(org_uuid).lower()}" - all_permissions = await db.instance.list_permissions() + all_permissions = await db.list_permissions() for perm in all_permissions: perm_id_lower = perm.id.lower() # Check if permission contains "org:{uuid}" separated by colons or at boundaries @@ -185,9 +185,9 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): or perm_id_lower.endswith(f":{org_perm_pattern}") or perm_id_lower == org_perm_pattern ): - await db.instance.delete_permission(perm.id) + await db.delete_permission(perm.id) - await db.instance.delete_organization(org_uuid) + await db.delete_organization(org_uuid) return {"status": "ok"} @@ -201,7 +201,7 @@ async def admin_add_org_permission( await authz.verify( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) - await db.instance.add_permission_to_organization(str(org_uuid), permission_id) + await db.add_permission_to_organization(str(org_uuid), permission_id) return {"status": "ok"} @@ -215,7 +215,7 @@ async def admin_remove_org_permission( await authz.verify( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) - await db.instance.remove_permission_from_organization(str(org_uuid), permission_id) + await db.remove_permission_from_organization(str(org_uuid), permission_id) return {"status": "ok"} @@ -240,10 +240,10 @@ async def admin_create_role( role_uuid = uuid4() display_name = payload.get("display_name") or "New Role" perms = payload.get("permissions") or [] - org = await db.instance.get_organization(str(org_uuid)) + org = await db.get_organization(str(org_uuid)) grantable = set(org.permissions or []) for pid in perms: - await db.instance.get_permission(pid) + await db.get_permission(pid) if pid not in grantable: raise ValueError(f"Permission not grantable by org: {pid}") role = RoleDC( @@ -252,7 +252,7 @@ async def admin_create_role( display_name=display_name, permissions=perms, ) - await db.instance.create_role(role) + await db.create_role(role) return {"uuid": str(role_uuid)} @@ -271,7 +271,7 @@ async def admin_update_role( match=permutil.has_any, host=request.headers.get("host"), ) - role = await db.instance.get_role(role_uuid) + role = await db.get_role(role_uuid) if role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") from ..db import Role as RoleDC @@ -280,11 +280,11 @@ async def admin_update_role( permissions = payload.get("permissions") if permissions is None: permissions = role.permissions - org = await db.instance.get_organization(str(org_uuid)) + org = await db.get_organization(str(org_uuid)) grantable = set(org.permissions or []) existing_permissions = set(role.permissions) for pid in permissions: - await db.instance.get_permission(pid) + await db.get_permission(pid) if pid not in existing_permissions and pid not in grantable: raise ValueError(f"Permission not grantable by org: {pid}") @@ -302,7 +302,7 @@ async def admin_update_role( display_name=display_name, permissions=permissions, ) - await db.instance.update_role(updated) + await db.update_role(updated) return {"status": "ok"} @@ -320,7 +320,7 @@ async def admin_delete_role( host=request.headers.get("host"), max_age="5m", ) - role = await db.instance.get_role(role_uuid) + role = await db.get_role(role_uuid) if role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") @@ -328,7 +328,7 @@ async def admin_delete_role( if ctx.role.uuid == role_uuid: raise ValueError("Cannot delete your own role") - await db.instance.delete_role(role_uuid) + await db.delete_role(role_uuid) return {"status": "ok"} @@ -354,7 +354,7 @@ async def admin_create_user( raise ValueError("display_name and role are required") from ..db import User as UserDC - roles = await db.instance.get_roles_by_organization(str(org_uuid)) + roles = await db.get_roles_by_organization(str(org_uuid)) role_obj = next((r for r in roles if r.display_name == role_name), None) if not role_obj: raise ValueError("Role not found in organization") @@ -366,7 +366,7 @@ async def admin_create_user( visits=0, created_at=None, ) - await db.instance.create_user(user) + await db.create_user(user) return {"uuid": str(user_uuid)} @@ -388,12 +388,12 @@ async def admin_update_user_role( if not new_role: raise ValueError("role is required") try: - user_org, _current_role = await db.instance.get_user_organization(user_uuid) + user_org, _current_role = await db.get_user_organization(user_uuid) except ValueError: raise ValueError("User not found") if user_org.uuid != org_uuid: raise ValueError("User does not belong to this organization") - roles = await db.instance.get_roles_by_organization(str(org_uuid)) + roles = await db.get_roles_by_organization(str(org_uuid)) if not any(r.display_name == new_role for r in roles): raise ValueError("Role not found in organization") @@ -410,7 +410,7 @@ async def admin_update_user_role( "Cannot change your own role to one without admin permissions" ) - await db.instance.update_user_role_in_organization(user_uuid, new_role) + await db.update_user_role_in_organization(user_uuid, new_role) return {"status": "ok"} @@ -422,7 +422,7 @@ async def admin_create_user_registration_link( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.instance.get_user_organization(user_uuid) + user_org, _role_name = await db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -443,12 +443,12 @@ async def admin_create_user_registration_link( ) # Check if user has existing credentials - credentials = await db.instance.get_credentials_by_user_uuid(user_uuid) + credentials = await db.get_credentials_by_user_uuid(user_uuid) token_type = "user registration" if not credentials else "account recovery" token = passphrase.generate() expiry = reset_expires() - await db.instance.create_reset_token( + await db.create_reset_token( user_uuid=user_uuid, key=tokens.reset_key(token), expiry=expiry, @@ -473,7 +473,7 @@ async def admin_get_user_detail( auth=AUTH_COOKIE, ): try: - user_org, role_name = await db.instance.get_user_organization(user_uuid) + user_org, role_name = await db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -491,13 +491,13 @@ async def admin_get_user_detail( raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - user = await db.instance.get_user_by_uuid(user_uuid) - cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid) + user = await db.get_user_by_uuid(user_uuid) + cred_ids = await db.get_credentials_by_user_uuid(user_uuid) creds: list[dict] = [] aaguids: set[str] = set() for cid in cred_ids: try: - c = await db.instance.get_credential_by_id(cid) + c = await db.get_credential_by_id(cid) except ValueError: # pragma: no cover - race condition handling continue aaguid_str = str(c.aaguid) @@ -552,7 +552,7 @@ async def admin_get_user_detail( # Get sessions for the user normalized_request_host = hostutil.normalize_host(request.headers.get("host")) - session_records = await db.instance.list_sessions_for_user(user_uuid) + session_records = await db.list_sessions_for_user(user_uuid) current_session_key = session_key(auth) sessions_payload: list[dict] = [] for entry in session_records: @@ -623,7 +623,7 @@ async def admin_update_user_display_name( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.instance.get_user_organization(user_uuid) + user_org, _role_name = await db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -646,7 +646,7 @@ async def admin_update_user_display_name( raise HTTPException(status_code=400, detail="display_name required") if len(new_name) > 64: raise HTTPException(status_code=400, detail="display_name too long") - await db.instance.update_user_display_name(user_uuid, new_name) + await db.update_user_display_name(user_uuid, new_name) return {"status": "ok"} @@ -659,7 +659,7 @@ async def admin_delete_user_credential( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.instance.get_user_organization(user_uuid) + user_org, _role_name = await db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -678,7 +678,7 @@ async def admin_delete_user_credential( raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - await db.instance.delete_credential(credential_uuid, user_uuid) + await db.delete_credential(credential_uuid, user_uuid) return {"status": "ok"} @@ -691,7 +691,7 @@ async def admin_delete_user_session( auth=AUTH_COOKIE, ): try: - user_org, _role_name = await db.instance.get_user_organization(user_uuid) + user_org, _role_name = await db.get_user_organization(user_uuid) except ValueError: raise HTTPException(status_code=404, detail="User not found") if user_org.uuid != org_uuid: @@ -717,11 +717,11 @@ async def admin_delete_user_session( status_code=400, detail="Invalid session identifier" ) from exc - target_session = await db.instance.get_session(target_key) + target_session = await db.get_session(target_key) if not target_session or target_session.user_uuid != user_uuid: raise HTTPException(status_code=404, detail="Session not found") - await db.instance.delete_session(target_key) + await db.delete_session(target_key) # Check if admin terminated their own session current_terminated = target_key == session_key(auth) @@ -739,7 +739,7 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): match=permutil.has_any, host=request.headers.get("host"), ) - perms = await db.instance.list_permissions() + perms = await db.list_permissions() # Global admins see all permissions if "auth:admin" in ctx.role.permissions: @@ -771,7 +771,7 @@ async def admin_create_permission( if not perm_id or not display_name: raise ValueError("id and display_name are required") querysafe.assert_safe(perm_id, field="id") - await db.instance.create_permission(PermDC(id=perm_id, display_name=display_name)) + await db.create_permission(PermDC(id=perm_id, display_name=display_name)) return {"status": "ok"} @@ -790,7 +790,7 @@ async def admin_update_permission( if not display_name: raise ValueError("display_name is required") querysafe.assert_safe(permission_id, field="permission_id") - await db.instance.update_permission( + await db.update_permission( PermDC(id=permission_id, display_name=display_name) ) return {"status": "ok"} @@ -818,12 +818,10 @@ async def admin_rename_permission( querysafe.assert_safe(old_id, field="old_id") querysafe.assert_safe(new_id, field="new_id") if display_name is None: - perm = await db.instance.get_permission(old_id) + perm = await db.get_permission(old_id) display_name = perm.display_name - rename_fn = getattr(db.instance, "rename_permission", None) - if not rename_fn: # pragma: no cover - all current backends support rename - raise ValueError("Permission renaming not supported by this backend") - await rename_fn(old_id, new_id, display_name) + # All current backends support rename_permission + await db.rename_permission(old_id, new_id, display_name) return {"status": "ok"} @@ -846,5 +844,5 @@ async def admin_delete_permission( if permission_id == "auth:admin": raise ValueError("Cannot delete the master admin permission") - await db.instance.delete_permission(permission_id) + await db.delete_permission(permission_id) return {"status": "ok"} diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 835ef07..4cb5e18 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -22,7 +22,7 @@ from paskia.authsession import ( ) from paskia.fastapi import authz, session, user from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME -from paskia.globals import db +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 @@ -227,7 +227,7 @@ async def api_token_info(token: str): # Check if this is a reset token try: reset_token = await get_reset(token) - user = await db.instance.get_user_by_uuid(reset_token.user_uuid) + user = await db.get_user_by_uuid(reset_token.user_uuid) return { "type": "reset", "user_name": user.display_name, @@ -297,7 +297,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): except ValueError: return {"message": "Already logged out"} with suppress(Exception): - await db.instance.delete_session(session_key(auth)) + await db.delete_session(session_key(auth)) session.clear_session_cookie(response) return {"message": "Logged out successfully"} diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 75803b2..2b9295b 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -50,7 +50,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path yield -app = FastAPI(lifespan=lifespan) +app = FastAPI(lifespan=lifespan, redirect_slashes=False) # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/) app.middleware("http")(auth_host.redirect_middleware) @@ -96,6 +96,7 @@ async def admin_root_redirect(): @app.get("/admin/", include_in_schema=False) +@app.get("/auth/admin/", include_in_schema=False) async def admin_root(request: Request, auth=AUTH_COOKIE): return await admin.adminapp(request, auth) # Delegated to admin app diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index f3f5285..a417dd7 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -19,7 +19,8 @@ from paskia import 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.globals import db, passkey +from paskia import db +from paskia.globals import passkey from paskia.util import passphrase, pow # Create a FastAPI subapp for remote auth WebSocket endpoints @@ -323,7 +324,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): # Fetch and verify credential try: - stored_cred = await db.instance.get_credential_by_id( + stored_cred = await db.get_credential_by_id( credential.raw_id ) except ValueError: @@ -337,7 +338,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): ) # Update credential last_used - await db.instance.login(stored_cred.user_uuid, stored_cred) + await db.login(stored_cred.user_uuid, stored_cred) # Create a session for the REQUESTING device assert stored_cred.uuid is not None @@ -352,7 +353,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): token_str = passphrase.generate() expiry = expires() - await db.instance.create_reset_token( + await db.create_reset_token( user_uuid=stored_cred.user_uuid, key=tokens.reset_key(token_str), expiry=expiry, diff --git a/paskia/fastapi/reset.py b/paskia/fastapi/reset.py index ad41684..1e61b1b 100644 --- a/paskia/fastapi/reset.py +++ b/paskia/fastapi/reset.py @@ -16,7 +16,7 @@ import asyncio from uuid import UUID from paskia import authsession as _authsession -from paskia import globals as _g +from paskia import db as _db from paskia.util import hostutil, passphrase from paskia.util import tokens as _tokens @@ -27,9 +27,9 @@ async def _resolve_targets(query: str | None): targets: list[tuple] = [] try: q_uuid = UUID(query) - perm_orgs = await _g.db.instance.get_permission_organizations("auth:admin") + perm_orgs = await _db.get_permission_organizations("auth:admin") for o in perm_orgs: - users = await _g.db.instance.get_organization_users(str(o.uuid)) + users = await _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 _g.db.instance.get_permission_organizations("auth:admin") + perm_orgs = await _db.get_permission_organizations("auth:admin") for o in perm_orgs: - users = await _g.db.instance.get_organization_users(str(o.uuid)) + users = await _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 _g.db.instance.get_permission_organizations("auth:admin") + perm_orgs = await _db.get_permission_organizations("auth:admin") if not perm_orgs: return [] - users = await _g.db.instance.get_organization_users(str(perm_orgs[0].uuid)) + users = await _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,7 +64,7 @@ async def _resolve_targets(query: str | None): async def _create_reset(user, role_name: str): token = passphrase.generate() expiry = _authsession.reset_expires() - await _g.db.instance.create_reset_token( + await _db.create_reset_token( user_uuid=user.uuid, key=_tokens.reset_key(token), expiry=expiry, diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 9dd128e..327786a 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -17,7 +17,7 @@ from paskia.authsession import ( ) from paskia.fastapi import authz, session from paskia.fastapi.session import AUTH_COOKIE -from paskia.globals import db +from paskia import db from paskia.util import hostutil, passphrase, tokens from paskia.util.tokens import decode_session_key, session_key @@ -55,7 +55,7 @@ async def user_update_display_name( raise HTTPException(status_code=400, detail="display_name required") if len(new_name) > 64: raise HTTPException(status_code=400, detail="display_name too long") - await db.instance.update_user_display_name(s.user_uuid, new_name) + await db.update_user_display_name(s.user_uuid, new_name) return {"status": "ok"} @@ -69,7 +69,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE) raise authz.AuthException( status_code=401, detail="Session expired", mode="login" ) - await db.instance.delete_sessions_for_user(s.user_uuid) + await db.delete_sessions_for_user(s.user_uuid) session.clear_session_cookie(response) return {"message": "Logged out from all hosts"} @@ -99,11 +99,11 @@ async def api_delete_session( status_code=400, detail="Invalid session identifier" ) from exc - target_session = await db.instance.get_session(target_key) + target_session = await db.get_session(target_key) if not target_session or target_session.user_uuid != current_session.user_uuid: raise HTTPException(status_code=404, detail="Session not found") - await db.instance.delete_session(target_key) + await db.delete_session(target_key) current_terminated = target_key == session_key(auth) if current_terminated: session.clear_session_cookie(response) # explicit because 200 @@ -144,7 +144,7 @@ async def api_create_link( ) from e token = passphrase.generate() expiry = expires() - await db.instance.create_reset_token( + await db.create_reset_token( user_uuid=s.user_uuid, key=tokens.reset_key(token), expiry=expiry, diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index fe7f24c..f96cdcc 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -6,7 +6,8 @@ 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.globals import db, passkey +from paskia import db +from paskia.globals import passkey from paskia.util import passphrase from paskia.util.tokens import create_token, session_key @@ -65,13 +66,13 @@ async def websocket_register_add( s = ctx.session # Get user information and determine effective user_name for this registration - user = await db.instance.get_user_by_uuid(user_uuid) + user = await db.get_user_by_uuid(user_uuid) user_name = user.display_name if name is not None: stripped = name.strip() if stripped: user_name = stripped - challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid) + challenge_ids = await db.get_credentials_by_user_uuid(user_uuid) # WebAuthn registration credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids) @@ -79,7 +80,7 @@ async def websocket_register_add( # Create a new session and store everything in database token = create_token() metadata = infodict(ws, "authenticated") - await db.instance.create_credential_session( # type: ignore[attr-defined] + await db.create_credential_session( # type: ignore[attr-defined] user_uuid=user_uuid, credential=credential, reset_key=(s.key if reset is not None else None), @@ -115,7 +116,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): try: session = await get_session(auth, host=host) session_user_uuid = session.user_uuid - credential_ids = await db.instance.get_credentials_by_user_uuid( + credential_ids = await db.get_credentials_by_user_uuid( session_user_uuid ) except ValueError: @@ -129,7 +130,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): credential = passkey.instance.auth_parse(await ws.receive_json()) # Fetch from the database by credential ID try: - stored_cred = await db.instance.get_credential_by_id(credential.raw_id) + stored_cred = await db.get_credential_by_id(credential.raw_id) except ValueError: raise ValueError( f"This passkey is no longer registered with {passkey.instance.rp_name}" @@ -142,7 +143,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): # Verify the credential matches the stored data passkey.instance.auth_verify(credential, challenge, stored_cred, origin) # Update both credential and user's last_seen timestamp - await db.instance.login(stored_cred.user_uuid, stored_cred) + await db.login(stored_cred.user_uuid, stored_cred) # Create a session token for the authenticated user assert stored_cred.uuid is not None diff --git a/paskia/globals.py b/paskia/globals.py index d843653..907d998 100644 --- a/paskia/globals.py +++ b/paskia/globals.py @@ -1,6 +1,5 @@ from typing import Generic, TypeVar -from paskia.db import DatabaseInterface from paskia.sansio import Passkey T = TypeVar("T") @@ -38,8 +37,13 @@ async def init( If bootstrap=True (default) the system bootstrap_if_needed() will be invoked. In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping since the CLI performs it once before servers start. + + Database configuration: + 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 # Initialize passkey instance with provided parameters passkey.instance = Passkey( @@ -48,13 +52,9 @@ async def init( origins=origins, ) - # Test if we have a database already initialized, otherwise use SQL - try: - db.instance - except RuntimeError: - from .db import sql - - await sql.init() + # Initialize database if not already done + if json_db._db is None: + await json_db.init() # Initialize remote auth manager await remoteauth.init() @@ -68,4 +68,3 @@ async def init( # Global instances passkey = Manager[Passkey]("Passkey") -db = Manager[DatabaseInterface]("Database") diff --git a/paskia/migrate/__init__.py b/paskia/migrate/__init__.py new file mode 100644 index 0000000..558a82e --- /dev/null +++ b/paskia/migrate/__init__.py @@ -0,0 +1,216 @@ +""" +SQL to JSON migration module for Paskia. + +This module contains the legacy SQL database implementation and migration tools +for converting from the old SQLite database to the new JSONL format. + +Usage: + python -m paskia.migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl + +Or via the CLI entry point (if installed): + paskia-migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl +""" + +import asyncio +from datetime import datetime, timezone + +import base64url + +from .sql import ( + DB as SQLDB, +) +from .sql import ( + CredentialModel, + ResetTokenModel, + SessionModel, + UserModel, +) + +# Re-export for convenience +__all__ = ["migrate_from_sql", "main", "SQLDB"] + +# Default paths +SQL_DB_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite" +JSON_DB_DEFAULT = "paskia.jsonl" + + +def _bytes_to_str(b: bytes | None) -> str | None: + """Convert bytes to base64url string.""" + if b is None: + return None + return base64url.enc(b) + + +async def migrate_from_sql( + sql_db_path: str = SQL_DB_DEFAULT, + json_db_path: str = JSON_DB_DEFAULT, +) -> None: + """Migrate data from SQL database to JSON format. + + Args: + sql_db_path: SQLAlchemy connection string for the source SQL database + json_db_path: Path for the destination JSONL file + """ + # Import here to avoid circular imports and to not require JSON db at import time + from sqlalchemy import select + + from paskia.db.json import ( + DB as JSONDB, + ) + from paskia.db.json import ( + CredentialData, + OrgData, + PermissionData, + ResetTokenData, + RoleData, + SessionData, + UserData, + ) + + # Initialize source SQL database + sql_db = SQLDB(sql_db_path) + await sql_db.init_db() + + # Initialize destination JSON database + json_db = JSONDB(json_db_path) + await json_db.init_db() + + print(f"Migrating from {sql_db_path} to {json_db_path}...") + + # Build all data directly without saving (we'll save once at the end) + async with json_db._lock: + # Migrate permissions + permissions = await sql_db.list_permissions() + for perm in permissions: + json_db._data.permissions[perm.id] = PermissionData( + display_name=perm.display_name, + orgs={}, + ) + print(f" Migrated {len(permissions)} permissions") + + # Migrate organizations + orgs = await sql_db.list_organizations() + for org in orgs: + key = str(org.uuid) + json_db._data.orgs[key] = OrgData( + display_name=org.display_name, + ) + # Update permissions to allow this org to grant them + for perm_id in org.permissions: + if perm_id in json_db._data.permissions: + json_db._data.permissions[perm_id].orgs[key] = True + print(f" Migrated {len(orgs)} organizations") + + # Migrate roles + role_count = 0 + for org in orgs: + for role in org.roles: + key = str(role.uuid) + json_db._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 {}, + ) + role_count += 1 + print(f" Migrated {role_count} roles") + + # Migrate users + async with sql_db.session() as session: + result = await session.execute(select(UserModel)) + user_models = result.scalars().all() + for um in user_models: + user = um.as_dataclass() + key = str(user.uuid) + json_db._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, + ) + print(f" Migrated {len(user_models)} users") + + # Migrate credentials + async with sql_db.session() as session: + result = await session.execute(select(CredentialModel)) + cred_models = result.scalars().all() + for cm in cred_models: + cred = cm.as_dataclass() + key = str(cred.uuid) + json_db._data.credentials[key] = CredentialData( + credential_id=cred.credential_id, + user=str(cred.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, + ) + print(f" Migrated {len(cred_models)} credentials") + + # Migrate sessions + async with sql_db.session() as session: + result = await session.execute(select(SessionModel)) + session_models = result.scalars().all() + for sm in session_models: + sess = sm.as_dataclass() + key_b64 = _bytes_to_str(sess.key) + json_db._data.sessions[key_b64] = SessionData( + user=str(sess.user_uuid), + credential=str(sess.credential_uuid), + host=sess.host, + ip=sess.ip, + user_agent=sess.user_agent, + renewed=sess.renewed, + ) + print(f" Migrated {len(session_models)} sessions") + + # Migrate reset tokens + async with sql_db.session() as session: + result = await session.execute(select(ResetTokenModel)) + token_models = result.scalars().all() + for tm in token_models: + token = tm.as_dataclass() + key_b64 = _bytes_to_str(token.key) + json_db._data.reset_tokens[key_b64] = ResetTokenData( + user=str(token.user_uuid), + expiry=token.expiry, + token_type=token.token_type, + ) + print(f" Migrated {len(token_models)} reset tokens") + + # Save all changes as a single diff with actor "migrate" + # Start from empty {} so diff shows pure insertions + json_db._previous_builtins = {} + await json_db._save(actor="migrate") + + print("Migration complete!") + + +def main(): + """CLI entry point for migration.""" + import argparse + + parser = argparse.ArgumentParser( + description="Migrate Paskia database from SQL to JSON" + ) + parser.add_argument( + "--sql", + default=SQL_DB_DEFAULT, + help=f"Source SQL database connection string (default: {SQL_DB_DEFAULT})", + ) + parser.add_argument( + "--json", + default=JSON_DB_DEFAULT, + help=f"Destination JSONL file path (default: {JSON_DB_DEFAULT})", + ) + args = parser.parse_args() + + asyncio.run(migrate_from_sql(args.sql, args.json)) + + +if __name__ == "__main__": + main() diff --git a/paskia/migrate/sql.py b/paskia/migrate/sql.py new file mode 100644 index 0000000..c06c32e --- /dev/null +++ b/paskia/migrate/sql.py @@ -0,0 +1,355 @@ +""" +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. +""" + +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + LargeBinary, + String, + event, + select, +) +from sqlalchemy.dialects.sqlite import BLOB +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from paskia.db import ( + Credential, + Org, + Permission, + ResetToken, + Role, + Session, + User, +) + +DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite" + + +def _normalize_dt(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +class Base(DeclarativeBase): + pass + + +class OrgModel(Base): + __tablename__ = "orgs" + + uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) + display_name: Mapped[str] = mapped_column(String, nullable=False) + + def as_dataclass(self): + # Base Org without permissions/roles (filled by data accessors) + return Org(UUID(bytes=self.uuid), self.display_name) + + @staticmethod + def from_dataclass(org: Org): + return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name) + + +class RoleModel(Base): + __tablename__ = "roles" + + uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) + org_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE"), nullable=False + ) + display_name: Mapped[str] = mapped_column(String, nullable=False) + + def as_dataclass(self): + # Base Role without permissions (filled by data accessors) + return Role( + uuid=UUID(bytes=self.uuid), + org_uuid=UUID(bytes=self.org_uuid), + display_name=self.display_name, + ) + + @staticmethod + def from_dataclass(role: Role): + return RoleModel( + uuid=role.uuid.bytes, + org_uuid=role.org_uuid.bytes, + display_name=role.display_name, + ) + + +class UserModel(Base): + __tablename__ = "users" + + uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) + display_name: Mapped[str] = mapped_column(String, nullable=False) + role_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + last_seen: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + def as_dataclass(self) -> User: + return User( + uuid=UUID(bytes=self.uuid), + display_name=self.display_name, + role_uuid=UUID(bytes=self.role_uuid), + created_at=_normalize_dt(self.created_at) or self.created_at, + last_seen=_normalize_dt(self.last_seen) or self.last_seen, + visits=self.visits, + ) + + @staticmethod + def from_dataclass(user: User): + return UserModel( + uuid=user.uuid.bytes, + display_name=user.display_name, + role_uuid=user.role_uuid.bytes, + created_at=user.created_at or datetime.now(timezone.utc), + last_seen=user.last_seen, + visits=user.visits, + ) + + +class CredentialModel(Base): + __tablename__ = "credentials" + + uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) + credential_id: Mapped[bytes] = mapped_column( + LargeBinary(64), unique=True, index=True + ) + user_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE") + ) + aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False) + public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False) + sign_count: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + last_used: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + last_verified: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + def as_dataclass(self): + return Credential( + uuid=UUID(bytes=self.uuid), + credential_id=self.credential_id, + user_uuid=UUID(bytes=self.user_uuid), + aaguid=UUID(bytes=self.aaguid), + public_key=self.public_key, + sign_count=self.sign_count, + created_at=_normalize_dt(self.created_at) or self.created_at, + last_used=_normalize_dt(self.last_used) or self.last_used, + last_verified=_normalize_dt(self.last_verified) or self.last_verified, + ) + + +class SessionModel(Base): + __tablename__ = "sessions" + + key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) + user_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False + ) + credential_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), + ForeignKey("credentials.uuid", ondelete="CASCADE"), + nullable=False, + ) + host: Mapped[str] = mapped_column(String, nullable=False) + ip: Mapped[str] = mapped_column(String(64), nullable=False) + user_agent: Mapped[str] = mapped_column(String(512), nullable=False) + renewed: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + def as_dataclass(self): + return Session( + key=self.key, + user_uuid=UUID(bytes=self.user_uuid), + credential_uuid=UUID(bytes=self.credential_uuid), + host=self.host, + ip=self.ip, + user_agent=self.user_agent, + renewed=_normalize_dt(self.renewed) or self.renewed, + ) + + @staticmethod + def from_dataclass(session: Session): + return SessionModel( + key=session.key, + user_uuid=session.user_uuid.bytes, + credential_uuid=session.credential_uuid.bytes, + host=session.host, + ip=session.ip, + user_agent=session.user_agent, + renewed=session.renewed, + ) + + +class ResetTokenModel(Base): + __tablename__ = "reset_tokens" + + key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) + user_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False + ) + token_type: Mapped[str] = mapped_column(String, nullable=False) + expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + def as_dataclass(self) -> ResetToken: + return ResetToken( + key=self.key, + user_uuid=UUID(bytes=self.user_uuid), + token_type=self.token_type, + expiry=_normalize_dt(self.expiry) or self.expiry, + ) + + +class PermissionModel(Base): + __tablename__ = "permissions" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + display_name: Mapped[str] = mapped_column(String, nullable=False) + + def as_dataclass(self): + return Permission(self.id, self.display_name) + + @staticmethod + def from_dataclass(permission: Permission): + return PermissionModel(id=permission.id, display_name=permission.display_name) + + +class OrgPermission(Base): + """Permissions each organization is allowed to grant to its roles.""" + + __tablename__ = "org_permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + org_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE") + ) + permission_id: Mapped[str] = mapped_column( + String(64), ForeignKey("permissions.id", ondelete="CASCADE") + ) + + +class RolePermission(Base): + """Permissions that each role grants to its members.""" + + __tablename__ = "role_permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + role_uuid: Mapped[bytes] = mapped_column( + LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE") + ) + permission_id: Mapped[str] = mapped_column( + String(64), ForeignKey("permissions.id", ondelete="CASCADE") + ) + + +class DB: + """Legacy SQL database class for migration purposes only.""" + + def __init__(self, db_path: str = DB_PATH_DEFAULT): + """Initialize with database path.""" + self.engine = create_async_engine(db_path, echo=False) + # Ensure SQLite foreign key enforcement is ON for every new connection + if db_path.startswith("sqlite"): + + @event.listens_for(self.engine.sync_engine, "connect") + def _fk_on(dbapi_connection, connection_record): + try: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON;") + cursor.close() + except Exception: + pass + + self.async_session_factory = async_sessionmaker( + self.engine, expire_on_commit=False + ) + + @asynccontextmanager + async def session(self): + """Async context manager that provides a database session with transaction.""" + async with self.async_session_factory() as session: + async with session.begin(): + yield session + await session.flush() + await session.commit() + + async def init_db(self) -> None: + """Initialize database tables.""" + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def list_permissions(self) -> list[Permission]: + async with self.session() as session: + result = await session.execute(select(PermissionModel)) + return [p.as_dataclass() for p in result.scalars().all()] + + async def list_organizations(self) -> list[Org]: + async with self.session() as session: + # Load all orgs + orgs_result = await session.execute(select(OrgModel)) + org_models = orgs_result.scalars().all() + if not org_models: + return [] + + # Preload org permissions mapping + org_perms_result = await session.execute(select(OrgPermission)) + org_perms = org_perms_result.scalars().all() + perms_by_org: dict[bytes, list[str]] = {} + for op in org_perms: + perms_by_org.setdefault(op.org_uuid, []).append(op.permission_id) + + # Preload roles + roles_result = await session.execute(select(RoleModel)) + role_models = roles_result.scalars().all() + + # Preload role permissions mapping + rp_result = await session.execute(select(RolePermission)) + rps = rp_result.scalars().all() + perms_by_role: dict[bytes, list[str]] = {} + for rp in rps: + perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id) + + # Build org dataclasses with roles and permission IDs + roles_by_org: dict[bytes, list[Role]] = {} + for rm in role_models: + r_dc = rm.as_dataclass() + r_dc.permissions = perms_by_role.get(rm.uuid, []) + roles_by_org.setdefault(rm.org_uuid, []).append(r_dc) + + orgs: list[Org] = [] + for om in org_models: + o_dc = om.as_dataclass() + o_dc.permissions = perms_by_org.get(om.uuid, []) + o_dc.roles = roles_by_org.get(om.uuid, []) + orgs.append(o_dc) + + return orgs diff --git a/paskia/util/permutil.py b/paskia/util/permutil.py index 8f56e3b..466efed 100644 --- a/paskia/util/permutil.py +++ b/paskia/util/permutil.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from fnmatch import fnmatchcase -from paskia.globals import db +from paskia import db from paskia.util.hostutil import normalize_host from paskia.util.tokens import session_key @@ -29,4 +29,4 @@ async def session_context(auth: str | None, host: str | None = None): if not auth: return None normalized_host = normalize_host(host) if host else None - return await db.instance.get_session_context(session_key(auth), normalized_host) + return await db.get_session_context(session_key(auth), normalized_host) diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py index 1d01dc0..c4ccfdf 100644 --- a/paskia/util/userinfo.py +++ b/paskia/util/userinfo.py @@ -4,7 +4,7 @@ from datetime import timezone from paskia import aaguid from paskia.authsession import session_key -from paskia.globals import db +from paskia import db from paskia.util import hostutil, permutil, tokens, useragent @@ -41,17 +41,17 @@ async def format_user_info( - Sessions list - Permissions """ - u = await db.instance.get_user_by_uuid(user_uuid) + u = await db.get_user_by_uuid(user_uuid) ctx = await permutil.session_context(auth, request_host) # Fetch and format credentials - credential_ids = await db.instance.get_credentials_by_user_uuid(user_uuid) + credential_ids = await db.get_credentials_by_user_uuid(user_uuid) credentials: list[dict] = [] user_aaguids: set[str] = set() for cred_id in credential_ids: try: - c = await db.instance.get_credential_by_id(cred_id) + c = await db.get_credential_by_id(cred_id) except ValueError: continue @@ -98,7 +98,7 @@ async def format_user_info( # Format sessions normalized_request_host = hostutil.normalize_host(request_host) - session_records = await db.instance.list_sessions_for_user(user_uuid) + session_records = await db.list_sessions_for_user(user_uuid) current_session_key = session_key(auth) sessions_payload: list[dict] = [] @@ -150,7 +150,7 @@ async def format_reset_user_info(user_uuid, reset_token) -> dict: Returns: Dictionary with minimal user info for password reset flow """ - u = await db.instance.get_user_by_uuid(user_uuid) + u = await db.get_user_by_uuid(user_uuid) return { "authenticated": False, diff --git a/pyproject.toml b/pyproject.toml index 84aff08..ad495ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,11 @@ dependencies = [ "websockets>=12.0", "webauthn>=1.11.1", "base64url>=1.0.0", - "sqlalchemy[asyncio]>=2.0.0", - "aiosqlite>=0.19.0", "uuid7-standard>=1.0.0", "pyjwt>=2.8.0", "user-agents>=2.2.0", + "jsondiff>=2.2.1", + "msgspec>=0.20.0", ] requires-python = ">=3.10" @@ -42,6 +42,10 @@ dev = [ "pytest-asyncio>=0.24.0", "httpx>=0.27.0", ] +migrate = [ + "sqlalchemy[asyncio]>=2.0.0", + "aiosqlite>=0.19.0", +] [tool.coverage.run] source = ["paskia"] @@ -89,6 +93,7 @@ dev = [ [project.scripts] paskia = "paskia.fastapi.__main__:main" +paskia-migrate = "paskia.migrate:main" [tool.hatch.build] artifacts = ["paskia/frontend-build"] diff --git a/tests/conftest.py b/tests/conftest.py index 705055b..6c14f8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ in the database to test authenticated endpoints. import asyncio import os +import tempfile from collections.abc import AsyncGenerator from datetime import datetime, timezone from uuid import UUID @@ -20,16 +21,12 @@ import pytest import pytest_asyncio import uuid7 -from paskia import globals from paskia.db import Credential, Org, Permission, Role, User -from paskia.db.sql import DB +from paskia.db.json import DB from paskia.fastapi.session import AUTH_COOKIE_NAME from paskia.sansio import Passkey from paskia.util.tokens import create_token, session_key -# Use in-memory SQLite for tests -os.environ["PASKIA_DB"] = "sqlite+aiosqlite:///:memory:" - @pytest.fixture(scope="session") def event_loop(): @@ -41,16 +38,19 @@ def event_loop(): @pytest_asyncio.fixture(scope="function") async def test_db() -> AsyncGenerator[DB, None]: - """Create an in-memory SQLite database for testing. + """Create an in-memory JSON database for testing. - We use :memory: for speed - each test gets a fresh database. + Uses a temp file that gets cleaned up after each test. """ - db = DB("sqlite+aiosqlite:///:memory:") - await db.init_db() - globals.db._instance = db - yield db - # Clean up - globals.db._instance = None + import paskia.db.json as json_db + + with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: + db = DB(f.name) + await db.init_db() + json_db._db = db + yield db + # Clean up + json_db._db = None @pytest_asyncio.fixture(scope="function") diff --git a/tests/test_admin.py b/tests/test_admin.py index c7296d9..6c3f150 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -20,7 +20,7 @@ import pytest_asyncio import uuid7 from paskia.db import Credential, Org, Permission, Role, User -from paskia.db.sql import DB +from paskia.db.json import DB from paskia.util.tokens import create_token, encode_session_key, session_key from tests.conftest import auth_headers