From b79f3674958e8332a9c5a942107f65de5071dbc0 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 28 Jan 2026 17:19:56 +0000 Subject: [PATCH] Implement full ORM. Various other cleanup. --- paskia/authsession.py | 6 +- paskia/db/background.py | 6 +- paskia/db/jsonl.py | 4 +- paskia/db/operations.py | 202 +++++++++++++++++------------------ paskia/db/structs.py | 212 +++++++++++++++++++++++++++++-------- paskia/fastapi/admin.py | 130 ++++++++--------------- paskia/fastapi/api.py | 12 +-- paskia/fastapi/remote.py | 8 +- paskia/fastapi/user.py | 8 +- paskia/fastapi/ws.py | 10 +- paskia/migrate/__init__.py | 12 +-- paskia/migrate/sql.py | 14 +-- paskia/remoteauth.py | 12 +-- paskia/util/sessionutil.py | 4 +- paskia/util/userinfo.py | 13 ++- pyproject.toml | 4 - tests/conftest.py | 12 +-- tests/test_admin.py | 23 ++-- tests/test_api.py | 12 +-- 19 files changed, 377 insertions(+), 327 deletions(-) diff --git a/paskia/authsession.py b/paskia/authsession.py index 08252ab..ea9df82 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -8,7 +8,7 @@ independent of any web framework: - Credential management """ -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import TYPE_CHECKING from uuid import UUID @@ -23,11 +23,11 @@ EXPIRES = SESSION_LIFETIME def expires() -> datetime: - return datetime.now(timezone.utc) + EXPIRES + return datetime.now(UTC) + EXPIRES def reset_expires() -> datetime: - return datetime.now(timezone.utc) + RESET_LIFETIME + return datetime.now(UTC) + RESET_LIFETIME def get_reset(token: str) -> "ResetToken": diff --git a/paskia/db/background.py b/paskia/db/background.py index 737e27f..2232e4e 100644 --- a/paskia/db/background.py +++ b/paskia/db/background.py @@ -6,7 +6,7 @@ Periodically flushes pending changes to disk and cleans up expired items. import asyncio import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from paskia.db.operations import _store, cleanup_expired @@ -33,7 +33,7 @@ async def _background_loop(): cleanup_expired() await flush() - last_cleanup = datetime.now(timezone.utc) + last_cleanup = datetime.now(UTC) while True: try: @@ -42,7 +42,7 @@ async def _background_loop(): await flush() # Run cleanup periodically - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL: cleanup_expired() await flush() # Flush cleanup changes diff --git a/paskia/db/jsonl.py b/paskia/db/jsonl.py index 3901568..3cc171a 100644 --- a/paskia/db/jsonl.py +++ b/paskia/db/jsonl.py @@ -10,7 +10,7 @@ import logging import sys from collections import deque from contextlib import contextmanager -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any from uuid import UUID @@ -97,7 +97,7 @@ def create_change_record( ) -> _ChangeRecord: """Create a change record for persistence.""" return _ChangeRecord( - ts=datetime.now(timezone.utc), + ts=datetime.now(UTC), a=action, u=user, diff=diff, diff --git a/paskia/db/operations.py b/paskia/db/operations.py index dbccbda..07f235f 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -10,7 +10,7 @@ import hashlib import logging import os import secrets -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import UUID import uuid7 @@ -68,21 +68,18 @@ def get_user_organization(user_uuid: UUID) -> tuple[Org, str]: Raises ValueError if user not found. Call sites: - - Get user's organization when updating user role (admin.py:493) - - Get user's organization for user credential listing (admin.py:530) - - Get user's organization for user details API (admin.py:579) - - Get user's organization for updating user display name (admin.py:721) - - Get user's organization for deleting user credential (admin.py:754) - - Get user's organization for deleting user session (admin.py:783) + - update_user_role_in_organization: org only + - admin_create_user_registration_link: org only + - admin_get_user_detail: org and role + - admin_update_user_display_name: org only + - admin_delete_user_credential: org only + - admin_delete_user_session: org only """ if user_uuid not in _db.users: raise ValueError(f"User {user_uuid} not found") - role_uuid = _db.users[user_uuid].role - if role_uuid not in _db.roles: - raise ValueError(f"Role {role_uuid} not found") - role_data = _db.roles[role_uuid] - org_uuid = role_data.org - return _db.orgs[org_uuid], role_data.display_name + user = _db.users[user_uuid] + role = user.role + return role.org, role.display_name def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]: @@ -90,10 +87,8 @@ def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]: Returns list of (User, role_display_name) tuples. """ - role_map = { - rid: r.display_name for rid, r in _db.roles.items() if r.org == org_uuid - } - return [(u, role_map[u.role]) for u in _db.users.values() if u.role in role_map] + org = _db.orgs[org_uuid] + return [(u, u.role.display_name) for role in org.roles for u in role.users] def get_user_credential_ids(user_uuid: UUID) -> list[bytes]: @@ -101,7 +96,8 @@ def get_user_credential_ids(user_uuid: UUID) -> list[bytes]: Returns empty list if user has no credentials. """ - return [c.credential_id for c in _db.credentials.values() if c.user == user_uuid] + assert user_uuid + return [c.credential_id for c in _db.users[user_uuid].credentials] def _reset_key(passphrase: str) -> bytes: @@ -177,9 +173,9 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None: if org.uuid in _db.orgs: raise ValueError(f"Organization {org.uuid} already exists") with _db.transaction("admin:create_org", ctx): - new_org = Org(display_name=org.display_name) - _db.orgs[org.uuid] = new_org + new_org = Org.create(display_name=org.display_name) new_org.uuid = org.uuid + _db.orgs[org.uuid] = new_org # Create Administration role with org admin permission admin_role_uuid = uuid7.create() @@ -191,7 +187,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None: break role_permissions = {org_admin_perm_uuid: True} if org_admin_perm_uuid else {} admin_role = Role( - org=org.uuid, + org_uuid=org.uuid, display_name="Administration", permissions=role_permissions, ) @@ -217,17 +213,15 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None: if uuid not in _db.orgs: raise ValueError(f"Organization {uuid} not found") with _db.transaction("admin:delete_org", ctx): + org = _db.orgs[uuid] # Remove org from all permissions for p in _db.permissions.values(): p.orgs.pop(uuid, None) - # Delete roles in this org - role_uuids = [rid for rid, r in _db.roles.items() if r.org == uuid] - for rid in role_uuids: - del _db.roles[rid] - # Delete users with those roles - user_uuids = [uid for uid, u in _db.users.items() if u.role in role_uuids] - for uid in user_uuids: - del _db.users[uid] + # Delete roles in this org and their users + for role in org.roles: + for user in role.users: + del _db.users[user.uuid] + del _db.roles[role.uuid] del _db.orgs[uuid] @@ -269,8 +263,8 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None: """Create a new role.""" if role.uuid in _db.roles: raise ValueError(f"Role {role.uuid} already exists") - if role.org not in _db.orgs: - raise ValueError(f"Organization {role.org} not found") + if role.org_uuid not in _db.orgs: + raise ValueError(f"Organization {role.org_uuid} not found") with _db.transaction("admin:create_role", ctx): _db.roles[role.uuid] = role @@ -321,7 +315,8 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None: if uuid not in _db.roles: raise ValueError(f"Role {uuid} not found") # Check no users have this role - if any(u.role == uuid for u in _db.users.values()): + role = _db.roles[uuid] + if role.users: raise ValueError(f"Cannot delete role {uuid}: users still assigned") with _db.transaction("admin:delete_role", ctx): del _db.roles[uuid] @@ -331,8 +326,8 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None: """Create a new user.""" if new_user.uuid in _db.users: raise ValueError(f"User {new_user.uuid} already exists") - if new_user.role not in _db.roles: - raise ValueError(f"Role {new_user.role} not found") + if new_user.role_uuid not in _db.roles: + raise ValueError(f"Role {new_user.role_uuid} not found") with _db.transaction("admin:create_user", ctx): _db.users[new_user.uuid] = new_user @@ -369,7 +364,7 @@ def update_user_role( if role_uuid not in _db.roles: raise ValueError(f"Role {role_uuid} not found") with _db.transaction("admin:update_user_role", ctx): - _db.users[uuid].role = role_uuid + _db.users[uuid].role_uuid = role_uuid def update_user_role_in_organization( @@ -381,39 +376,35 @@ def update_user_role_in_organization( """Update user's role by role name within their current organization.""" if user_uuid not in _db.users: raise ValueError(f"User {user_uuid} not found") - current_role_uuid = _db.users[user_uuid].role - if current_role_uuid not in _db.roles: - raise ValueError("Current role not found") - org_uuid = _db.roles[current_role_uuid].org + user = _db.users[user_uuid] + org = user.org # Find role by name in the same org new_role_uuid = None - for rid, r in _db.roles.items(): - if r.org == org_uuid and r.display_name == role_name: - new_role_uuid = rid + for r in org.roles: + if r.display_name == role_name: + new_role_uuid = r.uuid break if new_role_uuid is None: raise ValueError(f"Role '{role_name}' not found in organization") with _db.transaction("admin:update_user_role", ctx): - _db.users[user_uuid].role = new_role_uuid + _db.users[user_uuid].role_uuid = new_role_uuid def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None: """Delete user and their credentials/sessions.""" if uuid not in _db.users: raise ValueError(f"User {uuid} not found") + user = _db.users[uuid] with _db.transaction("admin:delete_user", ctx): # Delete credentials - cred_uuids = [cid for cid, c in _db.credentials.items() if c.user == uuid] - for cid in cred_uuids: - del _db.credentials[cid] + for cred in user.credentials: + del _db.credentials[cred.uuid] # Delete sessions - sess_keys = [k for k, s in _db.sessions.items() if s.user == uuid] - for k in sess_keys: - del _db.sessions[k] + for sess in user.sessions: + del _db.sessions[sess.key] # Delete reset tokens - token_keys = [k for k, t in _db.reset_tokens.items() if t.user == uuid] - for k in token_keys: - del _db.reset_tokens[k] + for token in user.reset_tokens: + del _db.reset_tokens[token.key] del _db.users[uuid] @@ -421,8 +412,8 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> """Create a new credential.""" if cred.uuid in _db.credentials: raise ValueError(f"Credential {cred.uuid} already exists") - if cred.user not in _db.users: - raise ValueError(f"User {cred.user} not found") + if cred.user_uuid not in _db.users: + raise ValueError(f"User {cred.user_uuid} not found") with _db.transaction("create_credential", ctx): _db.credentials[cred.uuid] = cred @@ -455,20 +446,19 @@ def delete_credential( """ if uuid not in _db.credentials: raise ValueError(f"Credential {uuid} not found") + cred = _db.credentials[uuid] if user_uuid is not None: - cred_user = _db.credentials[uuid].user - if cred_user != user_uuid: + if cred.user_uuid != user_uuid: raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}") with _db.transaction("delete_credential", ctx): # Delete all sessions using this credential - keys = [k for k, s in _db.sessions.items() if s.credential == uuid] - for k in keys: - del _db.sessions[k] + for sess in cred.sessions: + print(sess, repr(sess.key)) + del _db.sessions[sess.key] del _db.credentials[uuid] def create_session( - key: str, user_uuid: UUID, credential_uuid: UUID, host: str, @@ -477,23 +467,25 @@ def create_session( expiry: datetime, *, ctx: SessionContext | None = None, -) -> None: - """Create a new session.""" - if key in _db.sessions: - raise ValueError("Session already exists") +) -> str: + """Create a new session. Returns the session key.""" if user_uuid not in _db.users: raise ValueError(f"User {user_uuid} not found") if credential_uuid not in _db.credentials: raise ValueError(f"Credential {credential_uuid} not found") + session = Session.create( + user=user_uuid, + credential=credential_uuid, + host=host, + ip=ip, + user_agent=user_agent, + expiry=expiry, + ) + if session.key in _db.sessions: + raise ValueError("Session already exists") with _db.transaction("create_session", ctx): - _db.sessions[key] = Session( - user=user_uuid, - credential=credential_uuid, - host=host, - ip=ip, - user_agent=user_agent, - expiry=expiry, - ) + _db.sessions[session.key] = session + return session.key def update_session( @@ -547,10 +539,12 @@ def delete_sessions_for_user( For user logout-all, pass ctx of the user's session. For admin bulk termination, pass admin's ctx. """ + user = _db.users.get(user_uuid) + if not user: + return with _db.transaction("admin:delete_sessions_for_user", ctx): - keys = [k for k, s in _db.sessions.items() if s.user == user_uuid] - for k in keys: - del _db.sessions[k] + for sess in user.sessions: + del _db.sessions[sess.key] def create_reset_token( @@ -575,7 +569,7 @@ def create_reset_token( raise ValueError(f"User {user_uuid} not found") with _db.transaction("create_reset_token", ctx): _db.reset_tokens[key] = ResetToken( - user=user_uuid, expiry=expiry, token_type=token_type + user_uuid=user_uuid, expiry=expiry, token_type=token_type ) @@ -594,7 +588,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None def cleanup_expired() -> int: """Remove expired sessions and reset tokens. Returns count removed.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) count = 0 with _db.transaction("expiry"): expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now] @@ -639,13 +633,20 @@ def login( """ if isinstance(user_uuid, str): user_uuid = UUID(user_uuid) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if user_uuid not in _db.users: raise ValueError(f"User {user_uuid} not found") if credential_uuid not in _db.credentials: raise ValueError(f"Credential {credential_uuid} not found") - session_key = _create_token() + session = Session.create( + user=user_uuid, + credential=credential_uuid, + host=host, + ip=ip, + user_agent=user_agent, + expiry=expiry, + ) user_str = str(user_uuid) with _db.transaction("login", user=user_str): # Update user @@ -655,15 +656,8 @@ def login( _db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].last_used = now # Create session - _db.sessions[session_key] = Session( - user=user_uuid, - credential=credential_uuid, - host=host, - ip=ip, - user_agent=user_agent, - expiry=expiry, - ) - return session_key + _db.sessions[session.key] = session + return session.key def create_credential_session( @@ -686,13 +680,20 @@ def create_credential_session( Returns the generated session token. """ - now = datetime.now(timezone.utc) + now = datetime.now(UTC) expiry = now + SESSION_LIFETIME - session_key = _create_token() if user_uuid not in _db.users: raise ValueError(f"User {user_uuid} not found") + session = Session.create( + user=user_uuid, + credential=credential.uuid, + host=host, + ip=ip, + user_agent=user_agent, + expiry=expiry, + ) user_str = str(user_uuid) with _db.transaction("create_credential_session", user=user_str): # Update display name if provided @@ -703,20 +704,13 @@ def create_credential_session( _db.credentials[credential.uuid] = credential # Create session - _db.sessions[session_key] = Session( - user=user_uuid, - credential=credential.uuid, - host=host, - ip=ip, - user_agent=user_agent, - expiry=expiry, - ) + _db.sessions[session.key] = session # Delete reset token if provided if reset_key: if reset_key in _db.reset_tokens: del _db.reset_tokens[reset_key] - return session_key + return session.key # ------------------------------------------------------------------------- @@ -775,7 +769,7 @@ def bootstrap( reset_expiry = reset_expires() reset_key = _reset_key(reset_passphrase) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) with _db.transaction("bootstrap"): # Create auth:admin permission @@ -797,13 +791,13 @@ def bootstrap( _db.permissions[perm_org_admin_uuid] = perm_org_admin # Create organization - new_org = Org(display_name=org_name) + new_org = Org.create(display_name=org_name) new_org.uuid = org_uuid _db.orgs[org_uuid] = new_org # Create Administration role with both permissions admin_role = Role( - org=org_uuid, + org_uuid=org_uuid, display_name="Administration", permissions={perm_admin_uuid: True, perm_org_admin_uuid: True}, ) @@ -813,7 +807,7 @@ def bootstrap( # Create admin user admin_user = User( display_name=admin_name, - role=role_uuid, + role_uuid=role_uuid, created_at=now, last_seen=None, visits=0, @@ -823,7 +817,7 @@ def bootstrap( # Create reset token _db.reset_tokens[reset_key] = ResetToken( - user=user_uuid, + user_uuid=user_uuid, expiry=reset_expiry, token_type="admin bootstrap", ) diff --git a/paskia/db/structs.py b/paskia/db/structs.py index d211dee..d9289e9 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -1,9 +1,13 @@ -from datetime import datetime, timezone +from __future__ import annotations + +import secrets +from datetime import UTC, datetime from uuid import UUID import msgspec import uuid7 +from paskia import db from paskia.util.hostutil import normalize_host # Sentinel for uuid fields before they are set by create() or DB post init @@ -31,13 +35,22 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True): """Get orgs that can grant this permission as a set.""" return set(self.orgs.keys()) + @property + def orgs_list(self) -> list[Org]: + """Get list of Org objects that can grant this permission.""" + return [ + db.data().orgs[org_uuid] + for org_uuid in self.orgs.keys() + if org_uuid in db.data().orgs + ] + @classmethod def create( cls, scope: str, display_name: str, domain: str | None = None, - ) -> "Permission": + ) -> Permission: """Create a new Permission with auto-generated uuid7.""" perm = cls( scope=scope, @@ -48,15 +61,41 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True): return perm +class Org(msgspec.Struct, dict=True): + """Organization data structure.""" + + display_name: str + + def __post_init__(self): + self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized + + @property + def roles(self) -> list[Role]: + """Get all roles that belong to this organization.""" + return [r for r in db.data().roles.values() if r.org_uuid == self.uuid] + + @property + def permissions(self) -> list[Permission]: + """Get all permissions that this organization can grant.""" + return [p for p in db.data().permissions.values() if self.uuid in p.orgs] + + @classmethod + def create(cls, display_name: str) -> Org: + """Create a new Org with auto-generated uuid7.""" + org = cls(display_name=display_name) + org.uuid = uuid7.create() + return org + + class Role(msgspec.Struct, dict=True, omit_defaults=True): """Role data structure. Mutable fields: display_name, permissions - Immutable fields: org (set at creation, never modified) + Immutable fields: org_uuid (set at creation, never modified) uuid is generated at creation. """ - org: UUID + org_uuid: UUID = msgspec.field(name="org") display_name: str permissions: dict[UUID, bool] = {} # permission_uuid -> True @@ -68,16 +107,36 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True): """Get permissions as a set of UUIDs.""" return set(self.permissions.keys()) + @property + def permissions_list(self) -> list[Permission]: + """Get list of Permission objects for this role.""" + return [ + db.data().permissions[perm_uuid] + for perm_uuid in self.permissions.keys() + if perm_uuid in db.data().permissions + ] + + @property + def org(self) -> Org: + """Get the organization object this role belongs to.""" + return db.data().orgs[self.org_uuid] + + @property + def users(self) -> list[User]: + """Get all users that have this role.""" + return [u for u in db.data().users.values() if u.role_uuid == self.uuid] + @classmethod def create( cls, - org: UUID, + org: UUID | Org, display_name: str, permissions: set[UUID] | None = None, - ) -> "Role": + ) -> Role: """Create a new Role with auto-generated uuid7.""" + org_uuid = org if isinstance(org, UUID) else org.uuid role = cls( - org=org, + org_uuid=org_uuid, display_name=display_name, permissions={p: True for p in (permissions or set())}, ) @@ -85,32 +144,16 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True): return role -class Org(msgspec.Struct, dict=True): - """Organization data structure.""" - - display_name: str - - def __post_init__(self): - self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized - - @classmethod - def create(cls, display_name: str) -> "Org": - """Create a new Org with auto-generated uuid7.""" - org = cls(display_name=display_name) - org.uuid = uuid7.create() - return org - - class User(msgspec.Struct, dict=True): """User data structure. - Mutable fields: display_name, role, last_seen, visits + Mutable fields: display_name, role_uuid, last_seen, visits Immutable fields: created_at (set at creation, never modified) uuid is derived from created_at using uuid7. """ display_name: str - role: UUID + role_uuid: UUID = msgspec.field(name="role") created_at: datetime last_seen: datetime | None = None visits: int = 0 @@ -118,19 +161,44 @@ class User(msgspec.Struct, dict=True): def __post_init__(self): self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized + @property + def role(self) -> Role: + """Get the role object this user has.""" + return db.data().roles[self.role_uuid] + + @property + def org(self) -> Org: + """Get the organization this user belongs to (via role).""" + return self.role.org + + @property + def credentials(self) -> list[Credential]: + """Get all credentials for this user.""" + return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid] + + @property + def sessions(self) -> list[Session]: + """Get all sessions for this user.""" + return [s for s in db.data().sessions.values() if s.user_uuid == self.uuid] + + @property + def reset_tokens(self) -> list[ResetToken]: + """Get all reset tokens for this user.""" + return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid] + @classmethod def create( cls, display_name: str, - role: UUID, + role: UUID | Role, created_at: datetime | None = None, - ) -> "User": + ) -> User: """Create a new User with auto-generated uuid7.""" - + role_uuid = role if isinstance(role, UUID) else role.uuid user = cls( display_name=display_name, - role=role, - created_at=created_at or datetime.now(timezone.utc), + role_uuid=role_uuid, + created_at=created_at or datetime.now(UTC), ) user.uuid = uuid7.create(user.created_at) return user @@ -145,7 +213,7 @@ class Credential(msgspec.Struct, dict=True): """ credential_id: bytes # Long binary ID from the authenticator - user: UUID + user_uuid: UUID = msgspec.field(name="user") aaguid: UUID public_key: bytes sign_count: int @@ -156,21 +224,34 @@ class Credential(msgspec.Struct, dict=True): def __post_init__(self): self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized + @property + def user(self) -> User: + """Get the User object for this credential.""" + return db.data().users[self.user_uuid] + + @property + def sessions(self) -> list[Session]: + """Get all sessions using this credential.""" + return [ + s for s in db.data().sessions.values() if s.credential_uuid == self.uuid + ] + @classmethod def create( cls, credential_id: bytes, - user: UUID, + user: UUID | User, aaguid: UUID, public_key: bytes, sign_count: int, created_at: datetime | None = None, - ) -> "Credential": + ) -> Credential: """Create a new Credential with auto-generated uuid7.""" - now = created_at or datetime.now(timezone.utc) + user_uuid = user if isinstance(user, UUID) else user.uuid + now = created_at or datetime.now(UTC) cred = cls( credential_id=credential_id, - user=user, + user_uuid=user_uuid, aaguid=aaguid, public_key=public_key, sign_count=sign_count, @@ -186,12 +267,12 @@ class Session(msgspec.Struct, dict=True): """Session data structure. Mutable fields: expiry (updated on session refresh) - Immutable fields: user, credential, host, ip, user_agent + Immutable fields: user_uuid, credential_uuid, host, ip, user_agent key is stored in the dict key, not in the struct. """ - user: UUID - credential: UUID + user_uuid: UUID = msgspec.field(name="user") + credential_uuid: UUID = msgspec.field(name="credential") host: str ip: str user_agent: str @@ -200,6 +281,16 @@ class Session(msgspec.Struct, dict=True): def __post_init__(self): self.key: str = "" # Convenience field, not serialized + @property + def user(self) -> User: + """Get the User object for this session.""" + return db.data().users[self.user_uuid] + + @property + def credential(self) -> Credential: + """Get the Credential object for this session.""" + return db.data().credentials[self.credential_uuid] + def metadata(self) -> dict: """Return session metadata for backwards compatibility.""" return { @@ -208,6 +299,32 @@ class Session(msgspec.Struct, dict=True): "expiry": self.expiry.isoformat(), } + @classmethod + def create( + cls, + user: UUID | User, + credential: UUID | Credential, + host: str, + ip: str, + user_agent: str, + expiry: datetime, + ) -> Session: + """Create a new Session with auto-generated key.""" + user_uuid = user if isinstance(user, UUID) else user.uuid + credential_uuid = ( + credential if isinstance(credential, UUID) else credential.uuid + ) + session = cls( + user_uuid=user_uuid, + credential_uuid=credential_uuid, + host=host, + ip=ip, + user_agent=user_agent, + expiry=expiry, + ) + session.key = secrets.token_urlsafe(12) + return session + class ResetToken(msgspec.Struct, dict=True): """Reset/device-addition token data structure. @@ -216,13 +333,18 @@ class ResetToken(msgspec.Struct, dict=True): key is stored in the dict key, not in the struct. """ - user: UUID + user_uuid: UUID = msgspec.field(name="user") expiry: datetime token_type: str def __post_init__(self): self.key: bytes = b"" # Convenience field, not serialized + @property + def user(self) -> User: + """Get the User object for this reset token.""" + return db.data().users[self.user_uuid] + class SessionContext(msgspec.Struct): session: Session @@ -296,18 +418,16 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): return None try: - user = self.users[s.user] - role = self.roles[user.role] - org = self.orgs[role.org] - credential = self.credentials[s.credential] + user = s.user + role = user.role + org = role.org + credential = s.credential except KeyError: return None # Effective permissions: role's permissions that the org can grant # Also filter by domain if host is provided - org_perm_uuids = { - pid for pid, p in self.permissions.items() if org.uuid in p.orgs - } + org_perm_uuids = {p.uuid for p in org.permissions} normalized_host = normalize_host(host) host_without_port = ( normalized_host.rsplit(":", 1)[0] if normalized_host else None diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index e149b46..e993987 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -1,5 +1,5 @@ import logging -from datetime import timezone +from datetime import UTC from uuid import UUID from fastapi import Body, FastAPI, HTTPException, Query, Request, Response @@ -28,38 +28,18 @@ from paskia.util.hostutil import normalize_host app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) -def is_global_admin(ctx) -> bool: - """Check if user has global admin permission.""" - effective_scopes = ( - {p.scope for p in (ctx.permissions or [])} - if ctx.permissions - else set(ctx.role.permissions or []) +def master_admin(ctx) -> bool: + return any(p.scope == "auth:admin" for p in ctx.permissions) + + +def org_admin(ctx, org_uuid: UUID) -> bool: + return ctx.org.uuid == org_uuid and any( + p.scope == "auth:org:admin" for p in ctx.permissions ) - return "auth:admin" in effective_scopes - - -def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool: - """Check if user has org admin permission. - - If org_uuid is provided, checks if user is admin of that specific org. - If org_uuid is None, checks if user is admin of their own org. - """ - effective_scopes = ( - {p.scope for p in (ctx.permissions or [])} - if ctx.permissions - else set(ctx.role.permissions or []) - ) - if "auth:org:admin" not in effective_scopes: - return False - if org_uuid is None: - return True - # User must belong to the target org (via their role) - return ctx.org.uuid == org_uuid def can_manage_org(ctx, org_uuid: UUID) -> bool: - """Check if user can manage the specified organization.""" - return is_global_admin(ctx) or is_org_admin(ctx, org_uuid) + return master_admin(ctx) or org_admin(ctx, org_uuid) @app.exception_handler(ValueError) @@ -99,14 +79,14 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): host=request.headers.get("host"), ) orgs = list(db.data().orgs.values()) - if not is_global_admin(ctx): + if not master_admin(ctx): # Org admins can only see their own organization orgs = [o for o in orgs if o.uuid == ctx.org.uuid] def role_to_dict(r): return { "uuid": str(r.uuid), - "org": str(r.org), + "org": str(r.org_uuid), "display_name": r.display_name, "permissions": list(r.permissions.keys()), } @@ -116,12 +96,8 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): return { "uuid": str(o.uuid), "display_name": o.display_name, - "permissions": { - pid for pid, p in db.data().permissions.items() if o.uuid in p.orgs - }, - "roles": [ - role_to_dict(r) for r in db.data().roles.values() if r.org == o.uuid - ], + "permissions": {p.uuid for p in o.permissions}, + "roles": [role_to_dict(r) for r in o.roles], "users": [ { "uuid": str(u.uuid), @@ -283,7 +259,8 @@ async def admin_create_role( perms = payload.get("permissions") or [] if org_uuid not in db.data().orgs: raise HTTPException(status_code=404, detail="Organization not found") - grantable = {pid for pid, p in db.data().permissions.items() if org_uuid in p.orgs} + org = db.data().orgs[org_uuid] + grantable = {p.uuid for p in org.permissions} # Normalize permission IDs to UUIDs permission_uuids: set[UUID] = set() @@ -324,7 +301,7 @@ async def admin_update_role_name( status_code=403, detail="Insufficient permissions", mode="forbidden" ) role = db.data().roles.get(role_uuid) - if not role or role.org != org_uuid: + if not role or role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") display_name = payload.get("display_name") @@ -356,7 +333,7 @@ async def admin_add_role_permission( ) role = db.data().roles.get(role_uuid) - if not role or role.org != org_uuid: + if not role or role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") # Verify permission exists and org can grant it @@ -391,7 +368,7 @@ async def admin_remove_role_permission( ) role = db.data().roles.get(role_uuid) - if not role or role.org != org_uuid: + if not role or role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") # Sanity check: prevent admin from removing their own access @@ -432,7 +409,7 @@ async def admin_delete_role( status_code=403, detail="Insufficient permissions", mode="forbidden" ) role = db.data().roles.get(role_uuid) - if not role or role.org != org_uuid: + if not role or role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") # Sanity check: prevent admin from deleting their own role @@ -468,8 +445,11 @@ async def admin_create_user( if not display_name or not role_name: raise ValueError("display_name and role are required") - roles = [r for r in db.data().roles.values() if r.org == org_uuid] - role_obj = next((r for r in roles if r.display_name == role_name), None) + org = db.data().orgs[org_uuid] + role_obj = next( + (r for r in org.roles if r.display_name == role_name), + None, + ) if not role_obj: raise ValueError("Role not found in organization") user = UserDC.create( @@ -507,7 +487,7 @@ async def admin_update_user_role( raise ValueError("User not found") if user_org.uuid != org_uuid: raise ValueError("User does not belong to this organization") - roles = [r for r in db.data().roles.values() if r.org == org_uuid] + roles = user_org.roles if not any(r.display_name == new_role for r in roles): raise ValueError("Role not found in organization") @@ -573,9 +553,9 @@ async def admin_create_user_registration_link( return { "url": url, "expires": ( - expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + expiry.astimezone(UTC).isoformat().replace("+00:00", "Z") if expiry.tzinfo - else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") + else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") ), } @@ -604,7 +584,7 @@ async def admin_get_user_detail( status_code=403, detail="Insufficient permissions", mode="forbidden" ) user = db.data().users.get(user_uuid) - user_creds = [c for c in db.data().credentials.values() if c.user == user_uuid] + user_creds = user.credentials creds: list[dict] = [] aaguids: set[str] = set() for c in user_creds: @@ -615,21 +595,17 @@ async def admin_get_user_detail( "credential": str(c.uuid), "aaguid": aaguid_str, "created_at": ( - c.created_at.astimezone(timezone.utc) - .isoformat() - .replace("+00:00", "Z") + c.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z") if c.created_at.tzinfo - else c.created_at.replace(tzinfo=timezone.utc) + else c.created_at.replace(tzinfo=UTC) .isoformat() .replace("+00:00", "Z") ), "last_used": ( - c.last_used.astimezone(timezone.utc) - .isoformat() - .replace("+00:00", "Z") + c.last_used.astimezone(UTC).isoformat().replace("+00:00", "Z") if c.last_used and c.last_used.tzinfo else ( - c.last_used.replace(tzinfo=timezone.utc) + c.last_used.replace(tzinfo=UTC) .isoformat() .replace("+00:00", "Z") if c.last_used @@ -637,12 +613,10 @@ async def admin_get_user_detail( ) ), "last_verified": ( - c.last_verified.astimezone(timezone.utc) - .isoformat() - .replace("+00:00", "Z") + c.last_verified.astimezone(UTC).isoformat().replace("+00:00", "Z") if c.last_verified and c.last_verified.tzinfo else ( - c.last_verified.replace(tzinfo=timezone.utc) + c.last_verified.replace(tzinfo=UTC) .isoformat() .replace("+00:00", "Z") if c.last_verified @@ -659,7 +633,7 @@ async def admin_get_user_detail( # Get sessions for the user normalized_request_host = hostutil.normalize_host(request.headers.get("host")) - session_records = [s for s in db.data().sessions.values() if s.user == user_uuid] + session_records = user.sessions current_session_key = auth sessions_payload: list[dict] = [] for entry in session_records: @@ -672,11 +646,9 @@ async def admin_get_user_detail( "ip": entry.ip, "user_agent": useragent.compact_user_agent(entry.user_agent), "last_renewed": ( - renewed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + renewed.astimezone(UTC).isoformat().replace("+00:00", "Z") if renewed.tzinfo - else renewed.replace(tzinfo=timezone.utc) - .isoformat() - .replace("+00:00", "Z") + else renewed.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") ), "is_current": entry.key == current_session_key, "is_current_host": bool( @@ -693,23 +665,19 @@ async def admin_get_user_detail( "role": role_name, "visits": user.visits, "created_at": ( - user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + user.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z") if user.created_at and user.created_at.tzinfo else ( - user.created_at.replace(tzinfo=timezone.utc) - .isoformat() - .replace("+00:00", "Z") + user.created_at.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") if user.created_at else None ) ), "last_seen": ( - user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + user.last_seen.astimezone(UTC).isoformat().replace("+00:00", "Z") if user.last_seen and user.last_seen.tzinfo else ( - user.last_seen.replace(tzinfo=timezone.utc) - .isoformat() - .replace("+00:00", "Z") + user.last_seen.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") if user.last_seen else None ) @@ -808,7 +776,7 @@ async def admin_delete_user_session( ) target_session = db.data().sessions.get(session_id) - if not target_session or target_session.user != user_uuid: + if not target_session or target_session.user_uuid != user_uuid: raise HTTPException(status_code=404, detail="Session not found") db.delete_session(session_id, ctx=ctx) @@ -919,18 +887,8 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): match=permutil.has_any, host=request.headers.get("host"), ) - perms = list(db.data().permissions.values()) - - # Global admins see all permissions - if is_global_admin(ctx): - return [_perm_to_dict(p) for p in perms] - - # Org admins only see permissions their org can grant (by UUID) - grantable = { - pid for pid, p in db.data().permissions.items() if ctx.org.uuid in p.orgs - } - filtered_perms = [p for p in perms if p.uuid in grantable] - return [_perm_to_dict(p) for p in filtered_perms] + perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions + return [_perm_to_dict(p) for p in perms] @app.post("/permissions") diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index b1aae24..fecc592 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -1,6 +1,6 @@ import logging from contextlib import suppress -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from fastapi import ( Depends, @@ -90,7 +90,7 @@ async def validate_token( raise renewed = False if auth: - consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc)) + consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC)) if not timedelta(0) < consumed < _REFRESH_INTERVAL: try: refresh_session_token( @@ -123,7 +123,7 @@ async def token_info(credentials=Depends(bearer_auth)): except ValueError as e: raise HTTPException(401, str(e)) - u = db.data().users.get(reset_token.user) + u = reset_token.user return { "token_type": reset_token.token_type, "display_name": u.display_name, @@ -170,11 +170,9 @@ async def forward_authentication( "Remote-Role": str(ctx.role.uuid), "Remote-Role-Name": ctx.role.display_name, "Remote-Session-Expires": ( - ctx.session.expiry.astimezone(timezone.utc) - .isoformat() - .replace("+00:00", "Z") + ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z") if ctx.session.expiry.tzinfo - else ctx.session.expiry.replace(tzinfo=timezone.utc) + else ctx.session.expiry.replace(tzinfo=UTC) .isoformat() .replace("+00:00", "Z") ), diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index fd5a8ab..05a50fe 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -324,7 +324,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): token_str = passphrase.generate() expiry = expires() db.create_reset_token( - user_uuid=cred.user, + user_uuid=cred.user_uuid, passphrase=token_str, expiry=expiry, token_type="device addition", @@ -333,7 +333,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): # Also create a session so the device is logged in normalized_host = hostutil.normalize_host(request.host) session_token = db.login( - user_uuid=cred.user, + user_uuid=cred.user_uuid, credential_uuid=cred.uuid, sign_count=new_sign_count, host=normalized_host, @@ -346,7 +346,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): normalized_host = hostutil.normalize_host(request.host) session_token = db.login( - user_uuid=cred.user, + user_uuid=cred.user_uuid, credential_uuid=cred.uuid, sign_count=new_sign_count, host=normalized_host, @@ -359,7 +359,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): completed = await remoteauth.instance.complete_request( token=request.key, session_token=session_token, - user_uuid=cred.user, + user_uuid=cred.user_uuid, credential_uuid=cred.uuid, reset_token=reset_token, ) diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 17cd7a9..148fe06 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -1,4 +1,4 @@ -from datetime import timezone +from datetime import UTC from uuid import UUID from fastapi import ( @@ -91,7 +91,7 @@ async def api_delete_session( ) target_session = db.data().sessions.get(session_id) - if not target_session or target_session.user != ctx.user.uuid: + if not target_session or target_session.user_uuid != ctx.user.uuid: raise HTTPException(status_code=404, detail="Session not found") db.delete_session(session_id, ctx=ctx) @@ -141,8 +141,8 @@ async def api_create_link( "message": "Registration link generated successfully", "url": url, "expires": ( - expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + expiry.astimezone(UTC).isoformat().replace("+00:00", "Z") if expiry.tzinfo - else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") + else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") ), } diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 308f325..8355f08 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -38,11 +38,11 @@ async def websocket_register_add( f"The reset link for {passkey.instance.rp_name} is invalid or has expired" ) s = get_reset(reset) - user_uuid = s.user + user_uuid = s.user_uuid else: # Require recent authentication for adding a new passkey ctx = await authz.verify(auth, perm=[], host=host, max_age="5m") - user_uuid = ctx.session.user + user_uuid = ctx.session.user_uuid s = ctx.session # Get user information and determine effective user_name for this registration @@ -99,7 +99,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids) # If reauth mode, verify the credential belongs to the session's user - if session_user_uuid and cred.user != session_user_uuid: + if session_user_uuid and cred.user_uuid != session_user_uuid: raise ValueError("This passkey belongs to a different account") # Create session and update user/credential in a single transaction @@ -114,7 +114,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") token = db.login( - user_uuid=cred.user, + user_uuid=cred.user_uuid, credential_uuid=cred.uuid, sign_count=new_sign_count, host=normalized_host, @@ -125,7 +125,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): await ws.send_json( { - "user": str(cred.user), + "user": str(cred.user_uuid), "session_token": token, } ) diff --git a/paskia/migrate/__init__.py b/paskia/migrate/__init__.py index c9a9162..fa03df6 100644 --- a/paskia/migrate/__init__.py +++ b/paskia/migrate/__init__.py @@ -14,7 +14,7 @@ Or via the CLI entry point (if installed): import argparse import asyncio import re -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import UUID import base64url @@ -173,7 +173,7 @@ async def migrate_from_sql( new_user = User( display_name=legacy_user.display_name, role=legacy_user.role_uuid, - created_at=legacy_user.created_at or datetime.now(timezone.utc), + created_at=legacy_user.created_at or datetime.now(UTC), last_seen=legacy_user.last_seen, visits=legacy_user.visits, ) @@ -190,7 +190,7 @@ async def migrate_from_sql( cred_key: UUID = legacy_cred.uuid new_cred = Credential( credential_id=legacy_cred.credential_id, - user=legacy_cred.user_uuid, + user_uuid=legacy_cred.user_uuid, aaguid=legacy_cred.aaguid, public_key=legacy_cred.public_key, sign_count=legacy_cred.sign_count, @@ -217,8 +217,8 @@ async def migrate_from_sql( # Already in new format or unknown - try to use as-is session_key = base64url.enc(old_key[:12]) db.sessions[session_key] = Session( - user=sess.user_uuid, - credential=sess.credential_uuid, + user_uuid=sess.user_uuid, + credential_uuid=sess.credential_uuid, host=sess.host, ip=sess.ip, user_agent=sess.user_agent, @@ -241,7 +241,7 @@ async def migrate_from_sql( # Already in new format or unknown - truncate to 9 bytes token_key = old_key[:9] db.reset_tokens[token_key] = ResetToken( - user=token.user_uuid, + user_uuid=token.user_uuid, expiry=token.expiry, token_type=token.token_type, ) diff --git a/paskia/migrate/sql.py b/paskia/migrate/sql.py index 422b3a2..356f669 100644 --- a/paskia/migrate/sql.py +++ b/paskia/migrate/sql.py @@ -9,7 +9,7 @@ DO NOT use this module for new code. Use paskia.db instead. from contextlib import asynccontextmanager from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import UUID from sqlalchemy import ( @@ -112,8 +112,8 @@ 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) + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) class Base(DeclarativeBase): @@ -172,7 +172,7 @@ class UserModel(Base): LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False ) created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=lambda: datetime.now(UTC) ) last_seen: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True @@ -195,7 +195,7 @@ class UserModel(Base): 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), + created_at=user.created_at or datetime.now(UTC), last_seen=user.last_seen, visits=user.visits, ) @@ -215,7 +215,7 @@ class CredentialModel(Base): 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) + DateTime(timezone=True), default=lambda: datetime.now(UTC) ) last_used: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True @@ -255,7 +255,7 @@ class SessionModel(Base): user_agent: Mapped[str] = mapped_column(String(512), nullable=False) renewed: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), nullable=False, ) diff --git a/paskia/remoteauth.py b/paskia/remoteauth.py index b2ec544..143d004 100644 --- a/paskia/remoteauth.py +++ b/paskia/remoteauth.py @@ -19,9 +19,9 @@ The first 3 words of the token serve as the pairing code for manual entry. import asyncio import logging +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from typing import Callable +from datetime import UTC, datetime, timedelta from uuid import UUID from paskia.util import passphrase, pow @@ -94,7 +94,7 @@ class RemoteAuthManager: async def _cleanup_expired(self): """Remove expired requests and notify waiting clients.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) expired_keys = [] async with self._lock: for key, req in self._requests.items(): @@ -123,7 +123,7 @@ class RemoteAuthManager: Returns: (code, expiry) - The 3-word passphrase code and expiration time """ - now = datetime.now(timezone.utc) + now = datetime.now(UTC) expiry = now + REMOTE_AUTH_LIFETIME async with self._lock: @@ -160,7 +160,7 @@ class RemoteAuthManager: req = self._requests.get(normalized) if req is None: return None - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if now > req.created_at + REMOTE_AUTH_LIFETIME: # Expired del self._requests[normalized] @@ -331,7 +331,7 @@ class RemoteAuthManager: req = self._requests.get(token) if req is None: return None - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if now > req.created_at + REMOTE_AUTH_LIFETIME: del self._requests[token] return None diff --git a/paskia/util/sessionutil.py b/paskia/util/sessionutil.py index 3dce947..afeda44 100644 --- a/paskia/util/sessionutil.py +++ b/paskia/util/sessionutil.py @@ -1,6 +1,6 @@ """Utility functions for session validation and checking.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from paskia.authsession import EXPIRES from paskia.db import SessionContext @@ -34,5 +34,5 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool: else: auth_time = ctx.session.expiry - EXPIRES - time_since_auth = datetime.now(timezone.utc) - auth_time + time_since_auth = datetime.now(UTC) - auth_time return time_since_auth <= max_age_delta diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py index 6f6c833..d2b1a63 100644 --- a/paskia/util/userinfo.py +++ b/paskia/util/userinfo.py @@ -1,6 +1,6 @@ """User information formatting and retrieval logic.""" -from datetime import timezone +from datetime import UTC from paskia import aaguid, db from paskia.authsession import EXPIRES @@ -13,9 +13,9 @@ def _format_datetime(dt): if dt is None: return None if dt.tzinfo: - return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") else: - return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") + return dt.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") def format_session_context(ctx: SessionContext) -> dict: @@ -48,9 +48,8 @@ async def format_user_info( ctx = await permutil.session_context(auth, request_host) # Fetch and format credentials - user_credentials = [ - c for c in db.data().credentials.values() if c.user == user_uuid - ] + user = db.data().users[user_uuid] + user_credentials = user.credentials credentials: list[dict] = [] user_aaguids: set[str] = set() @@ -74,7 +73,7 @@ async def format_user_info( # Format sessions normalized_request_host = hostutil.normalize_host(request_host) - session_records = [s for s in db.data().sessions.values() if s.user == user_uuid] + session_records = user.sessions current_session_key = auth sessions_payload: list[dict] = [] diff --git a/pyproject.toml b/pyproject.toml index 4c99000..fb2cd22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,10 +74,6 @@ filterwarnings = [ "ignore::DeprecationWarning", ] -[tool.ruff] -target-version = "py39" -line-length = 88 - [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP", "PLC0415"] ignore = ["E501"] # Line too long diff --git a/tests/conftest.py b/tests/conftest.py index 8edc395..b448442 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,7 @@ from paskia.db import ( create_user, ) from paskia.db.jsonl import JsonlStore -from paskia.db.operations import DB, _create_token +from paskia.db.operations import DB from paskia.fastapi.mainapp import app from paskia.fastapi.session import AUTH_COOKIE_NAME from paskia.sansio import Passkey @@ -193,17 +193,14 @@ async def session_token( test_db: DB, test_user: User, test_credential: Credential ) -> str: """Create a session for the admin user and return the token.""" - token = _create_token() - create_session( + return create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, - key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", expiry=expires(), ) - return token @pytest_asyncio.fixture(scope="function") @@ -211,17 +208,14 @@ async def regular_session_token( test_db: DB, regular_user: User, regular_credential: Credential ) -> str: """Create a session for a regular user and return the token.""" - token = _create_token() - create_session( + return create_session( user_uuid=regular_user.uuid, credential_uuid=regular_credential.uuid, - key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", expiry=expires(), ) - return token @pytest_asyncio.fixture(scope="function") diff --git a/tests/test_admin.py b/tests/test_admin.py index f89ccd4..bfd2338 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -12,7 +12,8 @@ These tests cover: """ import os -from datetime import datetime, timezone +import secrets +from datetime import UTC, datetime from uuid import UUID import httpx @@ -36,7 +37,7 @@ from paskia.db import ( create_session, create_user, ) -from paskia.db.operations import DB, _create_token +from paskia.db.operations import DB from tests.conftest import auth_headers # -------------------- Additional Fixtures -------------------- @@ -97,17 +98,14 @@ async def second_org_session_token( test_db: DB, second_org_user: User, second_org_credential: Credential ) -> str: """Create a session for the second org admin user.""" - token = _create_token() - create_session( + return create_session( user_uuid=second_org_user.uuid, credential_uuid=second_org_credential.uuid, - key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", expiry=expires(), ) - return token @pytest_asyncio.fixture(scope="function") @@ -132,7 +130,7 @@ async def org_admin_user(test_db: DB, org_admin_role: Role) -> User: role=org_admin_role.uuid, ) user.visits = 5 - user.last_seen = datetime.now(timezone.utc) + user.last_seen = datetime.now(UTC) create_user(user) return user @@ -157,17 +155,14 @@ async def org_admin_session_token( test_db: DB, org_admin_user: User, org_admin_credential: Credential ) -> str: """Create a session for the org admin user.""" - token = _create_token() - create_session( + return create_session( user_uuid=org_admin_user.uuid, credential_uuid=org_admin_credential.uuid, - key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", expiry=expires(), ) - return token @pytest_asyncio.fixture(scope="function") @@ -1170,11 +1165,9 @@ class TestAdminSessions: ): """Admin should be able to delete a user's session.""" # Create an additional session to delete - extra_token = _create_token() - create_session( + extra_token = create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, - key=extra_token, host="other.host:4401", ip="192.168.1.1", user_agent="other-agent", @@ -1255,7 +1248,7 @@ class TestAdminSessions: ): """Deleting non-existent session should fail.""" # Use a valid format but non-existent key - fake_token = _create_token() + fake_token = secrets.token_urlsafe(12) response = await client.delete( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{fake_token}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, diff --git a/tests/test_api.py b/tests/test_api.py index 9d14ad7..92ca9f9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,14 +10,14 @@ These tests cover: - /auth/api/set-session - Set session from bearer token """ -from datetime import datetime, timedelta, timezone +import secrets +from datetime import UTC, datetime, timedelta import httpx import pytest from paskia.authsession import EXPIRES from paskia.db import create_session, delete_session -from paskia.db.operations import _create_token from paskia.util.passphrase import generate from tests.conftest import auth_headers @@ -503,7 +503,7 @@ class TestValidateSessionRefresh: """Validate should handle session expiry during refresh attempt.""" # Create a token but don't create a session for it - token = _create_token() + token = secrets.token_urlsafe(12) response = await client.post( "/auth/api/validate", headers={**auth_headers(token), "Host": "localhost:4401"}, @@ -522,12 +522,10 @@ class TestValidateSessionRefresh: """Validate should return 401 if session disappears during refresh.""" # Create a session with an old expiry time to trigger refresh - token = _create_token() - old_expiry = datetime.now(timezone.utc) + EXPIRES - timedelta(minutes=10) - create_session( + old_expiry = datetime.now(UTC) + EXPIRES - timedelta(minutes=10) + token = create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, - key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest",