From 86966526c4bb1475a740343d42de436904ad61de Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 27 Jan 2026 03:00:18 +0000 Subject: [PATCH] Finalize database API class merge. --- paskia/db/operations.py | 62 ++++++++++++++------------------------ paskia/db/structs.py | 52 +++++++++++++++----------------- paskia/fastapi/admin.py | 24 +++++++-------- paskia/fastapi/api.py | 7 +++-- paskia/migrate/__init__.py | 14 +++++---- tests/conftest.py | 11 +++---- tests/test_admin.py | 6 ++-- 7 files changed, 78 insertions(+), 98 deletions(-) diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 7d9fbdb..d40498c 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -30,6 +30,7 @@ from paskia.db.jsonl import ( ) from paskia.db.structs import ( Credential, + DatabaseData, Org, Permission, ResetToken, @@ -37,9 +38,6 @@ from paskia.db.structs import ( Session, SessionContext, User, - _DatabaseData, - _OrgData, - _RoleData, ) from paskia.util.passphrase import is_well_formed as _is_passphrase @@ -47,7 +45,7 @@ _logger = logging.getLogger(__name__) # msgspec encoder/decoder _json_encoder = msgspec.json.Encoder() -_json_decoder = msgspec.json.Decoder(_DatabaseData) +_json_decoder = msgspec.json.Decoder(DatabaseData) class DB: @@ -59,7 +57,7 @@ class DB: def __init__(self, db_path: str = DB_PATH_DEFAULT): self.db_path = Path(db_path) - self._data = _DatabaseData( + self._data = DatabaseData( permissions={}, orgs={}, roles={}, @@ -179,27 +177,19 @@ def build_user(uuid: UUID) -> User: def build_role(uuid: UUID) -> Role: r = _db._data.roles[uuid] - role = Role( - org=r.org, - display_name=r.display_name, - permissions=[str(pid) for pid in r.permissions.keys()], - ) - role.uuid = uuid - return role + r.uuid = uuid + return r def build_org(uuid: UUID, include_roles: bool = False) -> Org: o = _db._data.orgs[uuid] - perm_uuids = [ - str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs - ] - org = Org(display_name=o.display_name, permissions=perm_uuids) - org.uuid = uuid + o.uuid = uuid + o.permissions = {pid for pid, p in _db._data.permissions.items() if uuid in p.orgs} if include_roles: - org.roles = [ + o.roles = [ build_role(rid) for rid, r in _db._data.roles.items() if r.org == uuid ] - return org + return o def build_credential(uuid: UUID) -> Credential: @@ -460,15 +450,14 @@ def get_session_context( # Effective permissions: role's permissions that the org can grant # Also filter by domain if host is provided - org_perm_uuids = set(org.permissions) # Set of permission UUID strings + org_perm_uuids = org.permissions # set[UUID] computed by build_org normalized_host = normalize_host(host) host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None effective_perms = [] - for perm_uuid_str in role.permissions: - if perm_uuid_str not in org_perm_uuids: + for perm_uuid in role.permission_set: + if perm_uuid not in org_perm_uuids: continue - perm_uuid = UUID(perm_uuid_str) if perm_uuid not in _db._data.permissions: continue p = _db._data.permissions[perm_uuid] @@ -560,16 +549,9 @@ def create_organization(org: Org, *, ctx: SessionContext | None = None) -> None: if org.uuid in _db._data.orgs: raise ValueError(f"Organization {org.uuid} already exists") with _db.transaction("Created organization", ctx): - _db._data.orgs[org.uuid] = _OrgData( + _db._data.orgs[org.uuid] = Org( display_name=org.display_name, created_at=datetime.now(timezone.utc) ) - # Grant listed permissions to this org (org.permissions contains UUIDs now) - for perm_uuid_str in org.permissions: - perm_uuid = ( - UUID(perm_uuid_str) if isinstance(perm_uuid_str, str) else perm_uuid_str - ) - if perm_uuid in _db._data.permissions: - _db._data.permissions[perm_uuid].orgs[org.uuid] = True # Create Administration role with org admin permission import uuid7 @@ -581,11 +563,13 @@ def create_organization(org: Org, *, ctx: SessionContext | None = None) -> None: org_admin_perm_uuid = pid break role_permissions = {org_admin_perm_uuid: True} if org_admin_perm_uuid else {} - _db._data.roles[admin_role_uuid] = _RoleData( + admin_role = Role( org=org.uuid, display_name="Administration", permissions=role_permissions, ) + admin_role.uuid = admin_role_uuid + _db._data.roles[admin_role_uuid] = admin_role def update_organization_name( @@ -699,11 +683,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None: if role.org not in _db._data.orgs: raise ValueError(f"Organization {role.org} not found") with _db.transaction("Created role", ctx): - _db._data.roles[role.uuid] = _RoleData( - org=role.org, - display_name=role.display_name, - permissions={UUID(pid): True for pid in role.permissions}, - ) + _db._data.roles[role.uuid] = role def update_role_name( @@ -1263,17 +1243,21 @@ def bootstrap( _db._data.permissions[perm_org_admin_uuid] = perm_org_admin # Create organization - _db._data.orgs[org_uuid] = _OrgData( + new_org = Org( display_name=org_name, created_at=now, ) + new_org.uuid = org_uuid + _db._data.orgs[org_uuid] = new_org # Create Administration role with both permissions - _db._data.roles[role_uuid] = _RoleData( + admin_role = Role( org=org_uuid, display_name="Administration", permissions={perm_admin_uuid: True, perm_org_admin_uuid: True}, ) + admin_role.uuid = role_uuid + _db._data.roles[role_uuid] = admin_role # Create admin user admin_user = User( diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 7884546..2346ae7 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -14,6 +14,11 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True): def __post_init__(self): self.uuid: UUID | None = None # Convenience field, not serialized + @property + def org_set(self) -> set[UUID]: + """Get orgs that can grant this permission as a set.""" + return set(self.orgs.keys()) + @classmethod def create( cls, @@ -31,49 +36,51 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True): return perm -class Role(msgspec.Struct, dict=True): +class Role(msgspec.Struct, dict=True, omit_defaults=True): org: UUID display_name: str - permissions: list[str] = [] # permission UUIDs this role grants + permissions: dict[UUID, bool] = {} # permission_uuid -> True def __post_init__(self): self.uuid: UUID | None = None # Convenience field, not serialized + @property + def permission_set(self) -> set[UUID]: + """Get permissions as a set of UUIDs.""" + return set(self.permissions.keys()) + @classmethod def create( cls, org: UUID, display_name: str, - permissions: list[str] | None = None, + permissions: set[UUID] | None = None, ) -> "Role": """Create a new Role with auto-generated uuid7.""" role = cls( org=org, display_name=display_name, - permissions=permissions or [], + permissions={p: True for p in (permissions or set())}, ) role.uuid = uuid7.create() return role -class Org(msgspec.Struct, dict=True): +class Org(msgspec.Struct, dict=True, omit_defaults=True): display_name: str - permissions: list[str] = [] # permission UUIDs this org can grant - roles: list[Role] = [] # roles belonging to this org + created_at: datetime | None = None def __post_init__(self): self.uuid: UUID | None = None # Convenience field, not serialized @classmethod - def create( - cls, - display_name: str, - permissions: list[str] | None = None, - ) -> "Org": + def create(cls, display_name: str) -> "Org": """Create a new Org with auto-generated uuid7.""" + from datetime import timezone + org = cls( display_name=display_name, - permissions=permissions or [], + created_at=datetime.now(timezone.utc), ) org.uuid = uuid7.create() return org @@ -188,25 +195,14 @@ class SessionContext(msgspec.Struct): # ------------------------------------------------------------------------- -# Internal storage types (different structure for efficient storage) +# Database storage structure # ------------------------------------------------------------------------- -class _OrgData(msgspec.Struct): - display_name: str - created_at: datetime | None = None - - -class _RoleData(msgspec.Struct): - org: UUID - display_name: str - permissions: dict[UUID, bool] = {} # permission_uuid -> True - - -class _DatabaseData(msgspec.Struct, omit_defaults=True): +class DatabaseData(msgspec.Struct, omit_defaults=True): permissions: dict[UUID, Permission] - orgs: dict[UUID, _OrgData] - roles: dict[UUID, _RoleData] + orgs: dict[UUID, Org] + roles: dict[UUID, Role] users: dict[UUID, User] credentials: dict[UUID, Credential] sessions: dict[str, Session] diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 2a997fe..4c58f57 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -137,8 +137,11 @@ async def admin_create_org( display_name = payload.get("display_name") or "New Organization" permissions = payload.get("permissions") or [] - org = OrgDC.create(display_name=display_name, permissions=permissions) + org = OrgDC.create(display_name=display_name) db.create_organization(org, ctx=ctx) + # Grant requested permissions to the new org + for perm in permissions: + db.add_permission_to_organization(str(org.uuid), perm) return {"uuid": str(org.uuid)} @@ -266,18 +269,17 @@ async def admin_create_role( display_name = payload.get("display_name") or "New Role" perms = payload.get("permissions") or [] org = db.get_organization(str(org_uuid)) - grantable = set(org.permissions or []) + grantable = org.permissions # set[UUID] computed by build_org # Normalize permission IDs to UUIDs - permission_uuids = [] + permission_uuids: set[UUID] = set() for pid in perms: perm = db.get_permission(pid) if not perm: raise ValueError(f"Permission {pid} not found") - perm_uuid_str = str(perm.uuid) - if perm_uuid_str not in grantable: + if perm.uuid not in grantable: raise ValueError(f"Permission not grantable by org: {pid}") - permission_uuids.append(perm_uuid_str) + permission_uuids.add(perm.uuid) role = RoleDC.create( org=org_uuid, @@ -348,7 +350,7 @@ async def admin_add_role_permission( if not perm: raise HTTPException(status_code=404, detail="Permission not found") org = db.get_organization(str(org_uuid)) - if str(permission_uuid) not in org.permissions: + if permission_uuid not in org.permissions: raise ValueError("Permission not grantable by organization") db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx) @@ -380,13 +382,11 @@ async def admin_remove_role_permission( raise HTTPException(status_code=404, detail="Role not found in organization") # Sanity check: prevent admin from removing their own access - # Find auth:admin and auth:org:admin permission UUIDs - perm_uuid_str = str(permission_uuid) perm = db.get_permission(permission_uuid) if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid: if perm and perm.scope in ["auth:admin", "auth:org:admin"]: # Check if removing this permission would leave no admin access - remaining_perms = set(role.permissions) - {perm_uuid_str} + remaining_perms = role.permission_set - {permission_uuid} has_admin = False for rp_uuid in remaining_perms: rp = db.get_permission(rp_uuid) @@ -918,8 +918,8 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): return [_perm_to_dict(p) for p in perms] # Org admins only see permissions their org can grant (by UUID) - grantable = set(ctx.org.permissions or []) - filtered_perms = [p for p in perms if str(p.uuid) in grantable] + grantable = ctx.org.permissions # set[UUID] + filtered_perms = [p for p in perms if p.uuid in grantable] return [_perm_to_dict(p) for p in filtered_perms] diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index b929cf4..2d717af 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -157,9 +157,10 @@ async def forward_authentication( ctx = await authz.verify( auth, perm, host=request.headers.get("host"), max_age=max_age ) - role_permissions = set(ctx.role.permissions or []) - if ctx.permissions: - role_permissions.update(permission.scope for permission in ctx.permissions) + # Build permission scopes for Remote-Groups header + role_permissions = ( + {p.scope for p in ctx.permissions} if ctx.permissions else set() + ) remote_headers: dict[str, str] = { "Remote-User": str(ctx.user.uuid), diff --git a/paskia/migrate/__init__.py b/paskia/migrate/__init__.py index afa7737..f2316d0 100644 --- a/paskia/migrate/__init__.py +++ b/paskia/migrate/__init__.py @@ -56,12 +56,12 @@ async def migrate_from_sql( from paskia.db.operations import DB as JSONDB from paskia.db.structs import ( Credential, + Org, Permission, ResetToken, + Role, Session, User, - _OrgData, - _RoleData, ) # Initialize source SQL database @@ -131,9 +131,9 @@ async def migrate_from_sql( orgs = await sql_db.list_organizations() for org in orgs: org_key: UUID = org.uuid - json_db._data.orgs[org_key] = _OrgData( - display_name=org.display_name, - ) + new_org = Org(display_name=org.display_name) + new_org.uuid = org_key + json_db._data.orgs[org_key] = new_org # Update permissions to allow this org to grant them (by UUID) for old_perm_id in org.permissions: perm_uuid = perm_id_to_uuid.get(old_perm_id) @@ -154,11 +154,13 @@ async def migrate_from_sql( perm_uuid = perm_id_to_uuid.get(old_perm_id) if perm_uuid: new_permissions[perm_uuid] = True - json_db._data.roles[role_key] = _RoleData( + new_role = Role( org=role.org_uuid, display_name=role.display_name, permissions=new_permissions, ) + new_role.uuid = role_key + json_db._data.roles[role_key] = new_role role_count += 1 print(f" Migrated {role_count} roles") diff --git a/tests/conftest.py b/tests/conftest.py index 7571ea9..7767f91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,13 +13,11 @@ import asyncio import os import tempfile from collections.abc import AsyncGenerator -from datetime import datetime, timezone from uuid import UUID import httpx import pytest import pytest_asyncio -import uuid7 from paskia import globals as paskia_globals from paskia.authsession import expires @@ -84,11 +82,10 @@ async def passkey_instance() -> Passkey: @pytest_asyncio.fixture(scope="function") async def test_org(test_db: DB, admin_permission: Permission) -> Org: """Create a test organization with admin permission.""" - org = Org.create( - display_name="Test Organization", - permissions=[str(admin_permission.uuid)], # Org can grant this permission - ) + org = Org.create(display_name="Test Organization") create_organization(org) + # Grant admin permission to this org + add_permission_to_organization(str(org.uuid), str(admin_permission.uuid)) return org @@ -121,7 +118,7 @@ async def test_role( role = Role.create( org=test_org.uuid, display_name="Test Admin Role", - permissions=[str(admin_permission.uuid), str(org_admin_permission.uuid)], + permissions={admin_permission.uuid, org_admin_permission.uuid}, ) create_role(role) return role diff --git a/tests/test_admin.py b/tests/test_admin.py index fde45d8..961fd5d 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -58,7 +58,7 @@ async def second_org_role( role = Role.create( org=second_org.uuid, display_name="Second Org Admin Role", - permissions=[str(admin_permission.uuid)], + permissions={admin_permission.uuid}, ) create_role(role) return role @@ -117,7 +117,7 @@ async def org_admin_role( role = Role.create( org=test_org.uuid, display_name="Org Admin Role", - permissions=[str(org_admin_permission.uuid)], + permissions={org_admin_permission.uuid}, ) create_role(role) return role @@ -397,7 +397,7 @@ class TestAdminOrganizations: client: httpx.AsyncClient, session_token: str, test_db: DB, - ): + ): """Admin should be able to delete another organization.""" # Create org to delete org_to_delete = Org.create(