diff --git a/paskia/db/jsonl.py b/paskia/db/jsonl.py index 1b61d8f..645d4da 100644 --- a/paskia/db/jsonl.py +++ b/paskia/db/jsonl.py @@ -20,12 +20,13 @@ _logger = logging.getLogger(__name__) DB_PATH_DEFAULT = "paskia.jsonl" -class _ChangeRecord(msgspec.Struct): +class _ChangeRecord(msgspec.Struct, omit_defaults=True): """A single change record in the JSONL file.""" ts: datetime - actor: str - diff: dict + a: str # action - describes the operation (e.g., "migrate", "login", "create_user") + u: str | None = None # user UUID who performed the action (None for system) + diff: dict = {} # msgspec encoder for change records @@ -83,11 +84,12 @@ def compute_diff(previous: dict, current: dict) -> dict | None: return diff if diff else None -def create_change_record(actor: str, diff: dict) -> _ChangeRecord: +def create_change_record(action: str, diff: dict, user: str | None = None) -> _ChangeRecord: """Create a change record for persistence.""" return _ChangeRecord( ts=datetime.now(timezone.utc), - actor=actor, + a=action, + u=user, diff=diff, ) diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 08a021b..72444a7 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -73,7 +73,8 @@ class DB: ) self._previous_builtins: dict[str, Any] = {} self._pending_changes: deque[_ChangeRecord] = deque() - self._current_actor: str = "system" + self._current_action: str = "system" + self._current_user: str | None = None async def load(self, db_path: str | None = None) -> None: """Load data from JSONL change log. @@ -98,31 +99,58 @@ class DB: diff = compute_diff(self._previous_builtins, current) if diff: self._pending_changes.append( - create_change_record(self._current_actor, diff) + create_change_record(self._current_action, diff, self._current_user) ) self._previous_builtins = current # Log the change with user display name if available - actor_display = self._current_actor - if self._current_actor not in ("system", "expiry", "migrate"): + user_display = None + if self._current_user: try: - user_uuid = UUID(self._current_actor) + user_uuid = UUID(self._current_user) if user_uuid in self._data.users: - actor_display = self._data.users[user_uuid].display_name + user_display = self._data.users[user_uuid].display_name except (ValueError, KeyError): - pass + user_display = self._current_user import json - _logger.info("DB change by %s: %s", actor_display, json.dumps(diff, default=str)) + + if user_display: + _logger.info( + "DB %s by %s: %s", + self._current_action, + user_display, + json.dumps(diff, default=str), + ) + else: + _logger.info( + "DB %s: %s", self._current_action, json.dumps(diff, default=str) + ) @contextmanager - def transaction(self, actor: str = "system"): - """Wrap writes in transaction. Queues change on successful exit.""" - old_actor = self._current_actor - self._current_actor = actor + def transaction( + self, + action: str, + ctx: SessionContext | None = None, + *, + user: str | None = None, + ): + """Wrap writes in transaction. Queues change on successful exit. + + Args: + action: Describes the operation (e.g., "Created user", "Login") + ctx: Session context of user performing the action (None for system operations) + user: User UUID string (alternative to ctx when full context unavailable) + """ + old_action = self._current_action + old_user = self._current_user + self._current_action = action + # Prefer ctx.user.uuid if ctx provided, otherwise use user param + self._current_user = str(ctx.user.uuid) if ctx else user try: yield self._queue_change() finally: - self._current_actor = old_actor + self._current_action = old_action + self._current_user = old_user # Global instance, always available (empty until init() loads data) @@ -176,7 +204,9 @@ def build_role(uuid: UUID) -> Role: 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] + perm_uuids = [ + str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs + ] org = Org(uuid=uuid, display_name=o.display_name, permissions=perm_uuids) if include_roles: org.roles = [ @@ -495,11 +525,13 @@ def get_session_context( # ------------------------------------------------------------------------- -def create_permission(perm: Permission, actor: str = "system") -> None: +def create_permission( + perm: Permission, *, ctx: SessionContext | None = None +) -> None: """Create a new permission.""" if perm.uuid in _db._data.permissions: raise ValueError(f"Permission {perm.uuid} already exists") - with _db.transaction(actor): + with _db.transaction("Created permission", ctx): _db._data.permissions[perm.uuid] = _PermissionData( scope=perm.scope, display_name=perm.display_name, @@ -508,11 +540,13 @@ def create_permission(perm: Permission, actor: str = "system") -> None: ) -def update_permission(perm: Permission, actor: str = "system") -> None: +def update_permission( + perm: Permission, *, ctx: SessionContext | None = None +) -> None: """Update a permission's scope, display_name, and domain.""" if perm.uuid not in _db._data.permissions: raise ValueError(f"Permission {perm.uuid} not found") - with _db.transaction(actor): + with _db.transaction("Updated permission", ctx): _db._data.permissions[perm.uuid].scope = perm.scope _db._data.permissions[perm.uuid].display_name = perm.display_name _db._data.permissions[perm.uuid].domain = perm.domain @@ -523,7 +557,8 @@ def rename_permission( new_scope: str, display_name: str, domain: str | None = None, - actor: str = "system", + *, + ctx: SessionContext | None = None, ) -> None: """Rename a permission's scope. The UUID remains the same. @@ -539,40 +574,46 @@ def rename_permission( if not key: raise ValueError(f"Permission with scope '{old_scope}' not found") - with _db.transaction(actor): + with _db.transaction("Renamed permission", ctx): # Update the permission _db._data.permissions[key].scope = new_scope _db._data.permissions[key].display_name = display_name _db._data.permissions[key].domain = domain -def delete_permission(uuid: str | UUID, actor: str = "system") -> None: +def delete_permission( + uuid: str | UUID, *, ctx: SessionContext | None = None +) -> None: """Delete a permission and remove it from all roles.""" if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db._data.permissions: raise ValueError(f"Permission {uuid} not found") - with _db.transaction(actor): + with _db.transaction("Deleted permission", ctx): # Remove this permission from all roles for role in _db._data.roles.values(): role.permissions.pop(uuid, None) del _db._data.permissions[uuid] -def create_organization(org: Org, actor: str = "system") -> None: +def create_organization( + org: Org, *, ctx: SessionContext | None = None +) -> None: """Create a new organization with an Administration role. Automatically creates an 'Administration' role with auth:org:admin permission. """ if org.uuid in _db._data.orgs: raise ValueError(f"Organization {org.uuid} already exists") - with _db.transaction(actor): + with _db.transaction("Created organization", ctx): _db._data.orgs[org.uuid] = _OrgData( 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 + 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 @@ -594,24 +635,29 @@ def create_organization(org: Org, actor: str = "system") -> None: def update_organization_name( - uuid: str | UUID, display_name: str, actor: str = "system" + uuid: str | UUID, + display_name: str, + *, + ctx: SessionContext | None = None, ) -> None: """Update organization display name.""" if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db._data.orgs: raise ValueError(f"Organization {uuid} not found") - with _db.transaction(actor): + with _db.transaction("Renamed organization", ctx): _db._data.orgs[uuid].display_name = display_name -def delete_organization(uuid: str | UUID, actor: str = "system") -> None: +def delete_organization( + uuid: str | UUID, *, ctx: SessionContext | None = None +) -> None: """Delete organization and all its roles/users.""" if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db._data.orgs: raise ValueError(f"Organization {uuid} not found") - with _db.transaction(actor): + with _db.transaction("Deleted organization", ctx): # Remove org from all permissions for p in _db._data.permissions.values(): p.orgs.pop(uuid, None) @@ -627,7 +673,10 @@ def delete_organization(uuid: str | UUID, actor: str = "system") -> None: def add_permission_to_organization( - org_uuid: str | UUID, permission_id: str | UUID, actor: str = "system" + org_uuid: str | UUID, + permission_id: str | UUID, + *, + ctx: SessionContext | None = None, ) -> None: """Grant a permission to an organization by UUID.""" if isinstance(org_uuid, str): @@ -653,12 +702,15 @@ def add_permission_to_organization( if permission_uuid not in _db._data.permissions: raise ValueError(f"Permission {permission_uuid} not found") - with _db.transaction(actor): + with _db.transaction("Granted org permission", ctx): _db._data.permissions[permission_uuid].orgs[org_uuid] = True def remove_permission_from_organization( - org_uuid: str | UUID, permission_id: str | UUID, actor: str = "system" + org_uuid: str | UUID, + permission_id: str | UUID, + *, + ctx: SessionContext | None = None, ) -> None: """Remove a permission from an organization by UUID.""" if isinstance(org_uuid, str): @@ -684,17 +736,19 @@ def remove_permission_from_organization( if permission_uuid not in _db._data.permissions: return # Permission not found, silently return - with _db.transaction(actor): + with _db.transaction("Revoked org permission", ctx): _db._data.permissions[permission_uuid].orgs.pop(org_uuid, None) -def create_role(role: Role, actor: str = "system") -> None: +def create_role( + role: Role, *, ctx: SessionContext | None = None +) -> None: """Create a new role.""" if role.uuid in _db._data.roles: raise ValueError(f"Role {role.uuid} already exists") if role.org_uuid not in _db._data.orgs: raise ValueError(f"Organization {role.org_uuid} not found") - with _db.transaction(actor): + with _db.transaction("Created role", ctx): _db._data.roles[role.uuid] = _RoleData( org=role.org_uuid, display_name=role.display_name, @@ -703,19 +757,25 @@ def create_role(role: Role, actor: str = "system") -> None: def update_role_name( - uuid: str | UUID, display_name: str, actor: str = "system" + uuid: str | UUID, + display_name: str, + *, + ctx: SessionContext | None = None, ) -> None: """Update role display name.""" if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db._data.roles: raise ValueError(f"Role {uuid} not found") - with _db.transaction(actor): + with _db.transaction("Renamed role", ctx): _db._data.roles[uuid].display_name = display_name def add_permission_to_role( - role_uuid: str | UUID, permission_uuid: str | UUID, actor: str = "system" + role_uuid: str | UUID, + permission_uuid: str | UUID, + *, + ctx: SessionContext | None = None, ) -> None: """Add permission to role by UUID.""" if isinstance(role_uuid, str): @@ -726,12 +786,15 @@ def add_permission_to_role( raise ValueError(f"Role {role_uuid} not found") if permission_uuid not in _db._data.permissions: raise ValueError(f"Permission {permission_uuid} not found") - with _db.transaction(actor): + with _db.transaction("Granted role permission", ctx): _db._data.roles[role_uuid].permissions[permission_uuid] = True def remove_permission_from_role( - role_uuid: str | UUID, permission_uuid: str | UUID, actor: str = "system" + role_uuid: str | UUID, + permission_uuid: str | UUID, + *, + ctx: SessionContext | None = None, ) -> None: """Remove permission from role by UUID.""" if isinstance(role_uuid, str): @@ -740,11 +803,13 @@ def remove_permission_from_role( permission_uuid = UUID(permission_uuid) if role_uuid not in _db._data.roles: raise ValueError(f"Role {role_uuid} not found") - with _db.transaction(actor): + with _db.transaction("Revoked role permission", ctx): _db._data.roles[role_uuid].permissions.pop(permission_uuid, None) -def delete_role(uuid: str | UUID, actor: str = "system") -> None: +def delete_role( + uuid: str | UUID, *, ctx: SessionContext | None = None +) -> None: """Delete a role.""" if isinstance(uuid, str): uuid = UUID(uuid) @@ -753,40 +818,54 @@ def delete_role(uuid: str | UUID, actor: str = "system") -> None: # Check no users have this role if any(u.role == uuid for u in _db._data.users.values()): raise ValueError(f"Cannot delete role {uuid}: users still assigned") - with _db.transaction(actor): + with _db.transaction("Deleted role", ctx): del _db._data.roles[uuid] -def create_user(user: User, actor: str = "system") -> None: +def create_user( + new_user: User, *, ctx: SessionContext | None = None +) -> None: """Create a new user.""" - if user.uuid in _db._data.users: - raise ValueError(f"User {user.uuid} already exists") - if user.role_uuid not in _db._data.roles: - raise ValueError(f"Role {user.role_uuid} not found") - with _db.transaction(actor): - _db._data.users[user.uuid] = _UserData( - display_name=user.display_name, - role=user.role_uuid, - created_at=user.created_at or datetime.now(timezone.utc), - last_seen=user.last_seen, - visits=user.visits, + if new_user.uuid in _db._data.users: + raise ValueError(f"User {new_user.uuid} already exists") + if new_user.role_uuid not in _db._data.roles: + raise ValueError(f"Role {new_user.role_uuid} not found") + with _db.transaction("Created user", ctx): + _db._data.users[new_user.uuid] = _UserData( + display_name=new_user.display_name, + role=new_user.role_uuid, + created_at=new_user.created_at or datetime.now(timezone.utc), + last_seen=new_user.last_seen, + visits=new_user.visits, ) def update_user_display_name( - uuid: str | UUID, display_name: str, actor: str = "system" + uuid: str | UUID, + display_name: str, + *, + ctx: SessionContext | None = None, ) -> None: - """Update user display name.""" + """Update user display name. + + For self-service (user updating own name), ctx can be None and user is derived from uuid. + For admin operations, ctx should be provided. + """ if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db._data.users: raise ValueError(f"User {uuid} not found") - with _db.transaction(actor): + # For self-service, derive user from the uuid being modified + user_str = str(uuid) if not ctx else None + with _db.transaction("Renamed user", ctx, user=user_str): _db._data.users[uuid].display_name = display_name def update_user_role( - uuid: str | UUID, role_uuid: str | UUID, actor: str = "system" + uuid: str | UUID, + role_uuid: str | UUID, + *, + ctx: SessionContext | None = None, ) -> None: """Update user's role.""" if isinstance(uuid, str): @@ -797,12 +876,15 @@ def update_user_role( raise ValueError(f"User {uuid} not found") if role_uuid not in _db._data.roles: raise ValueError(f"Role {role_uuid} not found") - with _db.transaction(actor): + with _db.transaction("Changed user role", ctx): _db._data.users[uuid].role = role_uuid def update_user_role_in_organization( - user_uuid: str | UUID, role_name: str, actor: str = "system" + user_uuid: str | UUID, + role_name: str, + *, + ctx: SessionContext | None = None, ) -> None: """Update user's role by role name within their current organization.""" if isinstance(user_uuid, str): @@ -821,17 +903,19 @@ def update_user_role_in_organization( break if new_role_uuid is None: raise ValueError(f"Role '{role_name}' not found in organization") - with _db.transaction(actor): + with _db.transaction("Changed user role", ctx): _db._data.users[user_uuid].role = new_role_uuid -def delete_user(uuid: str | UUID, actor: str = "system") -> None: +def delete_user( + uuid: str | UUID, *, ctx: SessionContext | None = None +) -> None: """Delete user and their credentials/sessions.""" if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db._data.users: raise ValueError(f"User {uuid} not found") - with _db.transaction(actor): + with _db.transaction("Deleted user", ctx): # Delete credentials cred_uuids = [cid for cid, c in _db._data.credentials.items() if c.user == uuid] for cid in cred_uuids: @@ -847,13 +931,15 @@ def delete_user(uuid: str | UUID, actor: str = "system") -> None: del _db._data.users[uuid] -def create_credential(cred: Credential, actor: str = "system") -> None: +def create_credential( + cred: Credential, *, ctx: SessionContext | None = None +) -> None: """Create a new credential.""" if cred.uuid in _db._data.credentials: raise ValueError(f"Credential {cred.uuid} already exists") if cred.user_uuid not in _db._data.users: raise ValueError(f"User {cred.user_uuid} not found") - with _db.transaction(actor): + with _db.transaction("Added credential", ctx): _db._data.credentials[cred.uuid] = _CredentialData( credential_id=cred.credential_id, user=cred.user_uuid, @@ -870,21 +956,25 @@ def update_credential_sign_count( uuid: str | UUID, sign_count: int, last_used: datetime | None = None, - actor: str = "system", + *, + ctx: SessionContext | None = None, ) -> None: """Update credential sign count and last_used.""" if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db._data.credentials: raise ValueError(f"Credential {uuid} not found") - with _db.transaction(actor): + with _db.transaction("Updated credential", ctx): _db._data.credentials[uuid].sign_count = sign_count if last_used: _db._data.credentials[uuid].last_used = last_used def delete_credential( - uuid: str | UUID, user_uuid: str | UUID | None = None, actor: str = "system" + uuid: str | UUID, + user_uuid: str | UUID | None = None, + *, + ctx: SessionContext | None = None, ) -> None: """Delete a credential. @@ -900,7 +990,7 @@ def delete_credential( cred_user = _db._data.credentials[uuid].user if cred_user != user_uuid: raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}") - with _db.transaction(actor): + with _db.transaction("Deleted credential", ctx): del _db._data.credentials[uuid] @@ -912,7 +1002,8 @@ def create_session( ip: str | None, user_agent: str | None, expiry: datetime, - actor: str = "system", + *, + ctx: SessionContext | None = None, ) -> None: """Create a new session.""" if key in _db._data.sessions: @@ -921,7 +1012,7 @@ def create_session( raise ValueError(f"User {user_uuid} not found") if credential_uuid not in _db._data.credentials: raise ValueError(f"Credential {credential_uuid} not found") - with _db.transaction(actor): + with _db.transaction("Created session", ctx): _db._data.sessions[key] = _SessionData( user=user_uuid, credential=credential_uuid, @@ -938,12 +1029,13 @@ def update_session( ip: str | None = None, user_agent: str | None = None, expiry: datetime | None = None, - actor: str = "system", + *, + ctx: SessionContext | None = None, ) -> None: """Update session metadata.""" if key not in _db._data.sessions: raise ValueError("Session not found") - with _db.transaction(actor): + with _db.transaction("Updated session", ctx): s = _db._data.sessions[key] if host is not None: s.host = host @@ -955,24 +1047,36 @@ def update_session( s.expiry = expiry -def set_session_host(key: str, host: str, actor: str = "system") -> None: +def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None: """Set the host for a session (first-time binding).""" - update_session(key, host=host, actor=actor) + update_session(key, host=host, ctx=ctx) -def delete_session(key: str, actor: str = "system") -> None: - """Delete a session.""" +def delete_session(key: str, *, ctx: SessionContext | None = None) -> None: + """Delete a session. + + For logout (user deleting own session), ctx can be None and user is derived from session. + For admin operations, ctx should be provided. + """ if key not in _db._data.sessions: raise ValueError("Session not found") - with _db.transaction(actor): + # For self-service logout, derive user from the session being deleted + user_str = str(_db._data.sessions[key].user) if not ctx else None + with _db.transaction("Deleted session", ctx, user=user_str): del _db._data.sessions[key] -def delete_sessions_for_user(user_uuid: str | UUID, actor: str = "system") -> None: - """Delete all sessions for a user.""" +def delete_sessions_for_user(user_uuid: str | UUID, *, ctx: SessionContext | None = None) -> None: + """Delete all sessions for a user. + + For logout-all (user deleting own sessions), ctx can be None and user is derived from user_uuid. + For admin operations, ctx should be provided. + """ if isinstance(user_uuid, str): user_uuid = UUID(user_uuid) - with _db.transaction(actor): + # For self-service, derive user from the user_uuid param + user_str = str(user_uuid) if not ctx else None + with _db.transaction("Deleted user sessions", ctx, user=user_str): keys = [k for k, s in _db._data.sessions.items() if s.user == user_uuid] for k in keys: del _db._data.sessions[k] @@ -983,25 +1087,32 @@ def create_reset_token( user_uuid: UUID, expiry: datetime, token_type: str, - actor: str = "system", + *, + ctx: SessionContext | None = None, ) -> None: - """Create a reset token from a passphrase.""" + """Create a reset token from a passphrase. + + For self-service (user creating own recovery link), ctx can be None and user is derived from user_uuid. + For admin operations, ctx should be provided. + """ key = _reset_key(passphrase) if key in _db._data.reset_tokens: raise ValueError("Reset token already exists") if user_uuid not in _db._data.users: raise ValueError(f"User {user_uuid} not found") - with _db.transaction(actor): + # For self-service, derive user from the user_uuid param + user_str = str(user_uuid) if not ctx else None + with _db.transaction("Created reset token", ctx, user=user_str): _db._data.reset_tokens[key] = _ResetTokenData( user=user_uuid, expiry=expiry, token_type=token_type ) -def delete_reset_token(key: bytes, actor: str = "system") -> None: +def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None: """Delete a reset token.""" if key not in _db._data.reset_tokens: raise ValueError("Reset token not found") - with _db.transaction(actor): + with _db.transaction("Deleted reset token", ctx): del _db._data.reset_tokens[key] @@ -1010,11 +1121,11 @@ def delete_reset_token(key: bytes, actor: str = "system") -> None: # ------------------------------------------------------------------------- -def cleanup_expired(actor: str = "system") -> int: +def cleanup_expired() -> int: """Remove expired sessions and reset tokens. Returns count removed.""" now = datetime.now(timezone.utc) count = 0 - with _db.transaction(actor): + with _db.transaction("Cleaned up expired"): expired_sessions = [k for k, s in _db._data.sessions.items() if s.expiry < now] for k in expired_sessions: del _db._data.sessions[k] @@ -1034,10 +1145,8 @@ def cleanup_expired(actor: str = "system") -> int: def _create_token() -> str: - """Generate a 16-character session token using standard base64.""" - import base64 - - return base64.b64encode(secrets.token_bytes(12)).decode() + """Generate a 16-character URL-safe session token.""" + return secrets.token_urlsafe(12) def login( @@ -1056,7 +1165,6 @@ def login( Creates: - new session - Actor is set to the user UUID being logged in. Returns the generated session token. """ if isinstance(user_uuid, str): @@ -1068,8 +1176,8 @@ def login( raise ValueError(f"Credential {credential.uuid} not found") session_key = _create_token() - actor = str(user_uuid) - with _db.transaction(actor): + user_str = str(user_uuid) + with _db.transaction("User logged in", user=user_str): # Update user _db._data.users[user_uuid].last_seen = now _db._data.users[user_uuid].visits += 1 @@ -1096,7 +1204,6 @@ def create_credential_session( user_agent: str | None, display_name: str | None = None, reset_key: bytes | None = None, - actor: str = "system", ) -> str: """Create a credential and session together, optionally consuming a reset token. @@ -1117,7 +1224,8 @@ def create_credential_session( if user_uuid not in _db._data.users: raise ValueError(f"User {user_uuid} not found") - with _db.transaction(actor): + user_str = str(user_uuid) + with _db.transaction("Registered credential", user=user_str): # Update display name if provided if display_name: _db._data.users[user_uuid].display_name = display_name diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 9f06d76..b74e3b1 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -135,12 +135,11 @@ async def admin_create_org( ) from ..db import Org as OrgDC # local import to avoid cycles - actor = str(ctx.user.uuid) org_uuid = uuid4() 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) - db.create_organization(org, actor=actor) + db.create_organization(org, ctx=ctx) return {"uuid": str(org_uuid)} @@ -167,7 +166,7 @@ async def admin_update_org_name( if not display_name: raise ValueError("display_name is required") - db.update_organization_name(org_uuid, display_name, actor=str(ctx.user.uuid)) + db.update_organization_name(org_uuid, display_name, ctx=ctx) return {"status": "ok"} @@ -199,9 +198,9 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): or perm_scope_lower.endswith(f":{org_perm_pattern}") or perm_scope_lower == org_perm_pattern ): - db.delete_permission(str(perm.uuid), actor=str(ctx.user.uuid)) + db.delete_permission(str(perm.uuid), ctx=ctx) - db.delete_organization(org_uuid, actor=str(ctx.user.uuid)) + db.delete_organization(org_uuid, ctx=ctx) return {"status": "ok"} @@ -216,7 +215,7 @@ async def admin_add_org_permission( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) db.add_permission_to_organization( - str(org_uuid), permission_id, actor=str(ctx.user.uuid) + str(org_uuid), permission_id, ctx=ctx ) return {"status": "ok"} @@ -242,7 +241,7 @@ async def admin_remove_org_permission( ) db.remove_permission_from_organization( - str(org_uuid), permission_id, actor=str(ctx.user.uuid) + str(org_uuid), permission_id, ctx=ctx ) return {"status": "ok"} @@ -284,7 +283,7 @@ async def admin_create_role( display_name=display_name, permissions=perms, ) - db.create_role(role, actor=str(ctx.user.uuid)) + db.create_role(role, ctx=ctx) return {"uuid": str(role_uuid)} @@ -315,7 +314,7 @@ async def admin_update_role_name( if not display_name: raise ValueError("display_name is required") - db.update_role_name(role_uuid, display_name, actor=str(ctx.user.uuid)) + db.update_role_name(role_uuid, display_name, ctx=ctx) return {"status": "ok"} @@ -351,7 +350,7 @@ async def admin_add_role_permission( if str(permission_uuid) not in org.permissions: raise ValueError("Permission not grantable by organization") - db.add_permission_to_role(role_uuid, permission_uuid, actor=str(ctx.user.uuid)) + db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx) return {"status": "ok"} @@ -396,7 +395,7 @@ async def admin_remove_role_permission( if not has_admin: raise ValueError("Cannot remove your own admin permissions") - db.remove_permission_from_role(role_uuid, permission_uuid, actor=str(ctx.user.uuid)) + db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx) return {"status": "ok"} @@ -426,7 +425,7 @@ async def admin_delete_role( if ctx.role.uuid == role_uuid: raise ValueError("Cannot delete your own role") - db.delete_role(role_uuid, actor=str(ctx.user.uuid)) + db.delete_role(role_uuid, ctx=ctx) return {"status": "ok"} @@ -468,7 +467,7 @@ async def admin_create_user( visits=0, created_at=None, ) - db.create_user(user, actor=str(ctx.user.uuid)) + db.create_user(user, ctx=ctx) return {"uuid": str(user_uuid)} @@ -516,7 +515,7 @@ async def admin_update_user_role( "Cannot change your own role to one without admin permissions" ) - db.update_user_role_in_organization(user_uuid, new_role, actor=str(ctx.user.uuid)) + db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx) return {"status": "ok"} @@ -556,7 +555,7 @@ async def admin_create_user_registration_link( passphrase=token, expiry=expiry, token_type=token_type, - actor=str(ctx.user.uuid), + ctx=ctx, ) url = hostutil.reset_link_url(token) return { @@ -739,7 +738,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") - db.update_user_display_name(user_uuid, new_name, actor=str(ctx.user.uuid)) + db.update_user_display_name(user_uuid, new_name, ctx=ctx) return {"status": "ok"} @@ -768,7 +767,7 @@ async def admin_delete_user_credential( raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - db.delete_credential(credential_uuid, user_uuid, actor=str(ctx.user.uuid)) + db.delete_credential(credential_uuid, user_uuid, ctx=ctx) return {"status": "ok"} @@ -801,7 +800,7 @@ async def admin_delete_user_session( 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, actor=str(ctx.user.uuid)) + db.delete_session(session_id, ctx=ctx) # Check if admin terminated their own session current_terminated = session_id == auth @@ -954,7 +953,7 @@ async def admin_create_permission( PermDC( uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain ), - actor=str(ctx.user.uuid), + ctx=ctx, ) return {"status": "ok"} @@ -1004,7 +1003,7 @@ async def admin_update_permission( display_name=new_display_name, domain=domain_value, ), - actor=str(ctx.user.uuid), + ctx=ctx, ) return {"status": "ok"} @@ -1051,7 +1050,7 @@ async def admin_rename_permission( # All current backends support rename_permission db.rename_permission( - old_scope, new_scope, display_name, domain_value, actor=str(ctx.user.uuid) + old_scope, new_scope, display_name, domain_value, ctx=ctx ) return {"status": "ok"} @@ -1083,5 +1082,5 @@ async def admin_delete_permission( if perm.scope == "auth:admin": _check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host")) - db.delete_permission(str(perm.uuid), actor=str(ctx.user.uuid)) + db.delete_permission(str(perm.uuid), ctx=ctx) return {"status": "ok"} diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index b6e20be..93a3d6c 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -288,11 +288,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): if not auth: return {"message": "Already logged out"} try: - s = await get_session(auth, host=request.headers.get("host")) + _s = await get_session(auth, host=request.headers.get("host")) except ValueError: return {"message": "Already logged out"} with suppress(Exception): - db.delete_session(auth, actor=str(s.user_uuid)) + db.delete_session(auth) session.clear_session_cookie(response) return {"message": "Logged out successfully"} diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index 0263270..e1f733b 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -351,7 +351,6 @@ async def websocket_remote_auth_permit(ws: WebSocket): passphrase=token_str, expiry=expiry, token_type="device addition", - actor=str(stored_cred.user_uuid), ) reset_token = token_str # Also create a session so the device is logged in diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 7d1b9af..6a71bec 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -54,7 +54,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") - db.update_user_display_name(s.user_uuid, new_name, actor=str(s.user_uuid)) + db.update_user_display_name(s.user_uuid, new_name) return {"status": "ok"} @@ -68,7 +68,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE) raise authz.AuthException( status_code=401, detail="Session expired", mode="login" ) - db.delete_sessions_for_user(s.user_uuid, actor=str(s.user_uuid)) + db.delete_sessions_for_user(s.user_uuid) session.clear_session_cookie(response) return {"message": "Logged out from all hosts"} @@ -95,7 +95,7 @@ async def api_delete_session( if not target_session or target_session.user_uuid != current_session.user_uuid: raise HTTPException(status_code=404, detail="Session not found") - db.delete_session(session_id, actor=str(current_session.user_uuid)) + db.delete_session(session_id) current_terminated = session_id == auth if current_terminated: session.clear_session_cookie(response) # explicit because 200 @@ -141,7 +141,6 @@ async def api_create_link( passphrase=token, expiry=expiry, token_type="device addition", - actor=str(s.user_uuid), ) url = hostutil.reset_link_url(token) return { diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index ccd3e9d..a1b2f0e 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -86,7 +86,6 @@ async def websocket_register_add( host=host, ip=metadata.get("ip"), user_agent=metadata.get("user_agent"), - actor=str(user_uuid), ) auth = token diff --git a/tests/conftest.py b/tests/conftest.py index ad98319..745221e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,10 +38,9 @@ from paskia.db import ( create_session, create_user, ) -from paskia.db.operations import DB +from paskia.db.operations import DB, _create_token from paskia.fastapi.session import AUTH_COOKIE_NAME from paskia.sansio import Passkey -from paskia.util.tokens import create_token, session_key @pytest.fixture(scope="session") @@ -220,11 +219,11 @@ 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() + token = _create_token() create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, - key=session_key(token), + key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", @@ -238,11 +237,11 @@ 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() + token = _create_token() create_session( user_uuid=regular_user.uuid, credential_uuid=regular_credential.uuid, - key=session_key(token), + key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", @@ -256,12 +255,11 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential) """Create a reset token for the test user.""" from paskia.authsession import reset_expires from paskia.util.passphrase import generate - from paskia.util.tokens import reset_key token = generate() create_reset_token( user_uuid=test_user.uuid, - key=reset_key(token), + passphrase=token, expiry=reset_expires(), token_type="reset", ) diff --git a/tests/test_admin.py b/tests/test_admin.py index b3efb5a..bb4611b 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -34,8 +34,7 @@ from paskia.db import ( create_session, create_user, ) -from paskia.db.operations import DB -from paskia.util.tokens import create_token, encode_session_key, session_key +from paskia.db.operations import DB, _create_token from tests.conftest import auth_headers # -------------------- Additional Fixtures -------------------- @@ -107,11 +106,11 @@ 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() + token = _create_token() create_session( user_uuid=second_org_user.uuid, credential_uuid=second_org_credential.uuid, - key=session_key(token), + key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", @@ -173,11 +172,11 @@ 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() + token = _create_token() create_session( user_uuid=org_admin_user.uuid, credential_uuid=org_admin_credential.uuid, - key=session_key(token), + key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", @@ -1195,21 +1194,19 @@ class TestAdminSessions: ): """Admin should be able to delete a user's session.""" # Create an additional session to delete - extra_token = create_token() - extra_key = session_key(extra_token) + extra_token = _create_token() create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, - key=extra_key, + key=extra_token, host="other.host:4401", ip="192.168.1.1", user_agent="other-agent", expiry=expires(), ) - encoded_key = encode_session_key(extra_key) response = await client.delete( - f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{encoded_key}", + f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{extra_token}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -1226,9 +1223,8 @@ class TestAdminSessions: test_user, ): """Admin can delete their own current session.""" - encoded_key = encode_session_key(session_key(session_token)) response = await client.delete( - f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{encoded_key}", + f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{session_token}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -1283,10 +1279,9 @@ class TestAdminSessions: ): """Deleting non-existent session should fail.""" # Use a valid format but non-existent key - fake_key = session_key(create_token()) - encoded_key = encode_session_key(fake_key) + fake_token = _create_token() response = await client.delete( - f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{encoded_key}", + f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{fake_token}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 404 diff --git a/tests/test_api.py b/tests/test_api.py index ce577b2..6078369 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -499,10 +499,10 @@ class TestValidateSessionRefresh: self, client: httpx.AsyncClient, test_db ): """Validate should handle session expiry during refresh attempt.""" - from paskia.util.tokens import create_token + from paskia.db.operations import _create_token # Create a token but don't create a session for it - token = create_token() + token = _create_token() response = await client.post( "/auth/api/validate", headers={**auth_headers(token), "Host": "localhost:4401"}, @@ -522,15 +522,15 @@ class TestValidateSessionRefresh: from datetime import timedelta from paskia.authsession import EXPIRES - from paskia.util.tokens import create_token, session_key + from paskia.db.operations import _create_token # Create a session with an old expiry time to trigger refresh - token = create_token() + token = _create_token() old_expiry = datetime.now(timezone.utc) + EXPIRES - timedelta(minutes=10) create_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, - key=session_key(token), + key=token, host="localhost:4401", ip="127.0.0.1", user_agent="pytest", @@ -538,7 +538,7 @@ class TestValidateSessionRefresh: ) # Delete the session right before validate tries to refresh - delete_session(session_key(token)) + delete_session(token) response = await client.post( "/auth/api/validate",