fischer-agentkit/src/agentkit/server/admin/user_service.py

528 lines
20 KiB
Python

"""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