Replace SQL database with JSONL based solution that keeps history.

This commit is contained in:
Leo Vasanko
2026-01-23 00:54:37 +00:00
parent dfb86efc65
commit 887c0f92a2
21 changed files with 2070 additions and 1949 deletions
+1
View File
@@ -5,6 +5,7 @@ dist/
*.lock
package-lock.json
paskia.sqlite
paskia.jsonl
/paskia/frontend-build
/paskia/_version.py
coverage-html/
+9 -8
View File
@@ -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)
+11 -11
View File
@@ -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()
+45 -394
View File
@@ -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",
]
+1316
View File
File diff suppressed because it is too large Load Diff
-1424
View File
File diff suppressed because it is too large Load Diff
+48 -50
View File
@@ -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"}
+3 -3
View File
@@ -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"}
+2 -1
View File
@@ -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
+5 -4
View File
@@ -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,
+8 -8
View File
@@ -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,
+6 -6
View File
@@ -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,
+8 -7
View File
@@ -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
+8 -9
View File
@@ -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")
+216
View File
@@ -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()
+355
View File
@@ -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
+2 -2
View File
@@ -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)
+6 -6
View File
@@ -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,
+7 -2
View File
@@ -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"]
+13 -13
View File
@@ -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")
+1 -1
View File
@@ -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