528 lines
20 KiB
Python
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
|