"""UserService — business logic for ``users`` + multi-department assignment (U3). This module is the single owner of user CRUD operations that span the ``users`` and ``user_departments`` tables. Web UI routes (``/api/v1/admin/users/*``) and the CLI ``agentkit admin user`` sub-app both call into :class:`UserService` rather than touching the tables directly, keeping the validation rules (duplicate-username, department existence) in one place. The service is a module-level singleton (see :func:`get_user_service`) so tests can inject a custom instance via :func:`set_user_service`. Password hashing uses bcrypt (cost factor 12) via :func:`agentkit.server.auth.password.hash_password`. Password resets also revoke all of the user's active sessions via :class:`SessionService`. """ from __future__ import annotations import logging from datetime import datetime, timezone from pathlib import Path from typing import Any import aiosqlite from agentkit.server.auth.models import user_row_to_dict from agentkit.server.auth.password import hash_password from agentkit.server.auth.providers.local import LocalAuthProvider from agentkit.server.auth.session_service import ( REVOKE_REASON_PASSWORD_CHANGED, get_session_service, ) logger = logging.getLogger(__name__) def _now_iso() -> str: """Return current UTC time as ISO 8601 string.""" return datetime.now(timezone.utc).isoformat() class UserService: """CRUD + multi-department assignment operations for users. All async methods take ``db_path: Path`` as the first argument (after ``self``). Each method opens its own short-lived :class:`aiosqlite.Connection` — there is no shared connection state, which keeps the service safe to call from any async context. The :meth:`create_user` method delegates the actual user-row insert to :class:`LocalAuthProvider` so that the password-hashing and IntegrityError-handling logic stays in one place. The other methods (list/get/update/delete/reset_password/department assignment) operate directly on the DB. """ # ------------------------------------------------------------------ # User CRUD # ------------------------------------------------------------------ async def create_user( self, db_path: Path, username: str, email: str, password: str, role: str = "member", department_ids: list[str] | None = None, created_by: str | None = None, ) -> dict[str, Any]: """Create a new user, optionally assigning to departments. Args: db_path: Path to the auth SQLite DB. username: Unique username. email: Unique email address. password: Plain-text password (bcrypt-hashed before storage). role: Role name (``member`` / ``operator`` / ``admin``). Defaults to ``member``. department_ids: Optional list of department ids to assign the user to. Each id must exist in the ``departments`` table; duplicate or non-existent ids raise ``ValueError``. created_by: Optional user id of the admin who created this user (audit trail). Returns: The newly-created user dict, including a ``departments`` list (each item is ``{id, name}``). Raises: ValueError: If a user with the same username or email already exists, or if any of the ``department_ids`` does not exist or is already assigned. """ provider = LocalAuthProvider(db_path=db_path) user = await provider.create_user( username=username, email=email, password=password, role=role, created_by=created_by, ) user_id = user["id"] if department_ids: for dept_id in department_ids: # assign_department validates department existence and # duplicate-assignment; raise on the first failure. await self.assign_department(db_path, user_id, dept_id) # Re-fetch with departments populated so the caller sees the # full picture (matches the shape returned by get_user). result = await self.get_user(db_path, user_id) assert result is not None # we just created it return result async def list_users( self, db_path: Path, department_id: str | None = None, include_inactive: bool = True, ) -> list[dict[str, Any]]: """List users, optionally filtered by department. Args: db_path: Path to the auth SQLite DB. department_id: When provided, only users assigned to this department are returned (via ``user_departments`` join). include_inactive: When ``False``, only users with ``is_active=1`` are returned. Returns: List of user dicts, each including a ``departments`` list (each item is ``{id, name}``). Ordered by ``created_at``. """ async with aiosqlite.connect(str(db_path)) as db: db.row_factory = aiosqlite.Row if department_id is not None: # JOIN through user_departments to filter. DISTINCT # avoids duplicates when a user is in the department # (the join would otherwise produce one row per # user_department pair, but we filter to a single # department here so each user appears at most once). if include_inactive: sql = ( "SELECT DISTINCT u.id, u.username, u.email, u.password_hash, " "u.role, u.is_active, u.is_terminal_authorized, " "u.is_server_terminal_authorized, u.created_at, u.updated_at, " "u.last_login_at, u.created_by " "FROM users u " "INNER JOIN user_departments ud ON ud.user_id = u.id " "WHERE ud.department_id = ? " "ORDER BY u.created_at ASC" ) args: tuple[Any, ...] = (department_id,) else: sql = ( "SELECT DISTINCT u.id, u.username, u.email, u.password_hash, " "u.role, u.is_active, u.is_terminal_authorized, " "u.is_server_terminal_authorized, u.created_at, u.updated_at, " "u.last_login_at, u.created_by " "FROM users u " "INNER JOIN user_departments ud ON ud.user_id = u.id " "WHERE ud.department_id = ? AND u.is_active = 1 " "ORDER BY u.created_at ASC" ) args = (department_id,) elif include_inactive: sql = ( "SELECT id, username, email, password_hash, role, is_active, " "is_terminal_authorized, is_server_terminal_authorized, " "created_at, updated_at, last_login_at, created_by " "FROM users ORDER BY created_at ASC" ) args = () else: sql = ( "SELECT id, username, email, password_hash, role, is_active, " "is_terminal_authorized, is_server_terminal_authorized, " "created_at, updated_at, last_login_at, created_by " "FROM users WHERE is_active = 1 ORDER BY created_at ASC" ) args = () cursor = await db.execute(sql, args) rows = await cursor.fetchall() users = [user_row_to_dict(row) for row in rows] # Batch-fetch departments for each user in the same # connection to avoid N+1 round-trips. for user in users: user["departments"] = await self._fetch_departments(db, user["id"]) return users async def get_user( self, db_path: Path, user_id: str, ) -> dict[str, Any] | None: """Return a single user by id, or ``None`` if not found. The returned dict includes a ``departments`` list (each item is ``{id, name}``). """ async with aiosqlite.connect(str(db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT id, username, email, password_hash, role, is_active, " "is_terminal_authorized, is_server_terminal_authorized, " "created_at, updated_at, last_login_at, created_by " "FROM users WHERE id = ?", (user_id,), ) row = await cursor.fetchone() if row is None: return None user = user_row_to_dict(row) user["departments"] = await self._fetch_departments(db, user_id) return user async def update_user( self, db_path: Path, user_id: str, role: str | None = None, is_active: bool | None = None, is_terminal_authorized: bool | None = None, is_server_terminal_authorized: bool | None = None, ) -> dict[str, Any]: """Partially update a user. Only the provided fields are updated. ``password_hash`` is not touched here — use :meth:`reset_password` for that. Args: db_path: Path to the auth SQLite DB. user_id: User id to update. role: New role, or ``None`` to skip. is_active: New active flag, or ``None`` to skip. is_terminal_authorized: New terminal-authorized flag, or ``None`` to skip. is_server_terminal_authorized: New server-terminal-authorized flag, or ``None`` to skip. Returns: The updated user dict (including ``departments``). Raises: ValueError: If the user does not exist. """ existing = await self.get_user(db_path, user_id) if existing is None: raise ValueError(f"User {user_id!r} not found") # Build the SET clause dynamically based on which fields were # provided. This avoids overwriting columns with NULL when the # caller only wants to update a subset. set_parts: list[str] = [] args: list[Any] = [] if role is not None: set_parts.append("role = ?") args.append(role) if is_active is not None: set_parts.append("is_active = ?") args.append(1 if is_active else 0) if is_terminal_authorized is not None: set_parts.append("is_terminal_authorized = ?") args.append(1 if is_terminal_authorized else 0) if is_server_terminal_authorized is not None: set_parts.append("is_server_terminal_authorized = ?") args.append(1 if is_server_terminal_authorized else 0) if set_parts: set_parts.append("updated_at = ?") args.append(_now_iso()) args.append(user_id) sql = f"UPDATE users SET {', '.join(set_parts)} WHERE id = ?" async with aiosqlite.connect(str(db_path)) as db: await db.execute(sql, tuple(args)) await db.commit() updated = await self.get_user(db_path, user_id) assert updated is not None # we checked existence above return updated async def delete_user( self, db_path: Path, user_id: str, ) -> bool: """Soft-delete a user (set ``is_active = 0``). The row is NOT actually deleted — this preserves audit trails and foreign-key references. Use :meth:`update_user` with ``is_active=True`` to re-enable. Args: db_path: Path to the auth SQLite DB. user_id: User id to soft-delete. Returns: ``True`` if the user was updated, ``False`` if the user did not exist (or was already inactive). """ async with aiosqlite.connect(str(db_path)) as db: cursor = await db.execute( "UPDATE users SET is_active = 0, updated_at = ? WHERE id = ? AND is_active = 1", (_now_iso(), user_id), ) await db.commit() return cursor.rowcount > 0 async def reset_password( self, db_path: Path, user_id: str, new_password: str, ) -> bool: """Reset a user's password and revoke all their sessions. The new password is bcrypt-hashed (cost factor 12) before storage. All active sessions for the user are then revoked (via :class:`SessionService`) so that any stolen refresh tokens become invalid immediately. Args: db_path: Path to the auth SQLite DB. user_id: User id whose password should be reset. new_password: New plain-text password. Returns: ``True`` if the password was updated, ``False`` if the user did not exist. """ # Use the shared hash_password helper so the cost factor (12) # is configured in one place (auth.password). password_hash = hash_password(new_password) async with aiosqlite.connect(str(db_path)) as db: cursor = await db.execute( "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", (password_hash, _now_iso(), user_id), ) await db.commit() updated = cursor.rowcount > 0 if not updated: return False # Revoke all active sessions for this user. We use the # process-wide SessionService singleton (which reads the same # auth DB) so that the revocation takes effect immediately for # any subsequent request that checks session validity. try: session_svc = get_session_service() await session_svc.revoke_all_for_user(user_id, reason=REVOKE_REASON_PASSWORD_CHANGED) except Exception: # noqa: BLE001 — never block password reset on session revocation logger.exception( "Failed to revoke sessions for user %s after password reset", user_id, ) logger.info("Reset password for user %s and revoked active sessions", user_id) return True # ------------------------------------------------------------------ # Department assignment # ------------------------------------------------------------------ async def assign_department( self, db_path: Path, user_id: str, department_id: str, ) -> dict[str, Any]: """Assign a user to a department. Args: db_path: Path to the auth SQLite DB. user_id: User id to assign. department_id: Department id to assign the user to. Returns: Dict with ``{user_id, department_id, created_at}``. Raises: ValueError: If the department does not exist, or if the user is already assigned to this department (duplicate ``user_departments`` row). """ # Verify the department exists. We don't verify the user exists # here — the INSERT below will fail with a row-constraint # violation if the user_id is bogus, and the caller is expected # to have created/fetched the user before calling this method. async with aiosqlite.connect(str(db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT id FROM departments WHERE id = ?", (department_id,), ) dept_row = await cursor.fetchone() if dept_row is None: raise ValueError(f"Department {department_id!r} not found") now = _now_iso() try: await db.execute( "INSERT INTO user_departments (user_id, department_id, created_at) " "VALUES (?, ?, ?)", (user_id, department_id, now), ) await db.commit() except aiosqlite.IntegrityError as exc: # The composite PK (user_id, department_id) is the only # UNIQUE constraint on this table, so an IntegrityError # here means the assignment already exists. raise ValueError( f"User {user_id!r} is already assigned to department {department_id!r}" ) from exc return { "user_id": user_id, "department_id": department_id, "created_at": now, } async def remove_department( self, db_path: Path, user_id: str, department_id: str, ) -> bool: """Remove a user's department assignment. Args: db_path: Path to the auth SQLite DB. user_id: User id. department_id: Department id to remove. Returns: ``True`` if a row was deleted, ``False`` if the assignment did not exist. """ async with aiosqlite.connect(str(db_path)) as db: cursor = await db.execute( "DELETE FROM user_departments WHERE user_id = ? AND department_id = ?", (user_id, department_id), ) await db.commit() return cursor.rowcount > 0 async def list_user_departments( self, db_path: Path, user_id: str, ) -> list[dict[str, Any]]: """Return the list of departments a user is assigned to. Each item is ``{id, name, is_active}`` — the ``is_active`` flag reflects the department's active state (not the user's). """ async with aiosqlite.connect(str(db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT d.id, d.name, d.is_active " "FROM departments d " "INNER JOIN user_departments ud ON ud.department_id = d.id " "WHERE ud.user_id = ? " "ORDER BY d.name ASC", (user_id,), ) rows = await cursor.fetchall() return [ { "id": row["id"], "name": row["name"], "is_active": bool(row["is_active"]), } for row in rows ] # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ async def _fetch_departments( self, db: aiosqlite.Connection, user_id: str, ) -> list[dict[str, Any]]: """Fetch the departments for a user (helper for list/get). Reuses the caller's open connection to avoid an extra round-trip. Returns a list of ``{id, name}`` dicts. """ db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT d.id, d.name " "FROM departments d " "INNER JOIN user_departments ud ON ud.department_id = d.id " "WHERE ud.user_id = ? " "ORDER BY d.name ASC", (user_id,), ) rows = await cursor.fetchall() return [{"id": row["id"], "name": row["name"]} for row in rows] # --------------------------------------------------------------------------- # Module-level singleton (overridable in tests via set_user_service) # --------------------------------------------------------------------------- _user_service: UserService | None = None def get_user_service() -> UserService: """Return the process-wide :class:`UserService` (lazy singleton).""" global _user_service if _user_service is None: _user_service = UserService() return _user_service def set_user_service(service: UserService | None) -> None: """Inject a custom :class:`UserService` (used by tests).""" global _user_service _user_service = service