Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

832 lines
28 KiB
Python

"""Auth router — login, logout, status, registration, profile, and user-management endpoints."""
from contextvars import Token as _CtxToken
import logging
import re
from fastapi import (
APIRouter,
Cookie,
Depends,
File,
Header,
HTTPException,
Response,
UploadFile,
WebSocket,
status,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel, field_validator
from deeptutor.services.config import load_auth_settings
# SameSite=None lets the cookie work when the browser accesses the frontend via
# 127.0.0.1 and the backend via localhost (different origins on the same machine).
# Browsers require Secure=True for SameSite=None, but that needs HTTPS — so in
# local dev we fall back to SameSite=Lax and tell users to use localhost:// URLs.
_SECURE = bool(load_auth_settings()["cookie_secure"])
_SAMESITE = "none" if _SECURE else "lax"
from deeptutor.multi_user.context import set_current_user, user_from_token_payload
from deeptutor.multi_user.paths import local_admin_user
from deeptutor.services.auth import (
AUTH_ENABLED,
POCKETBASE_ENABLED,
TOKEN_EXPIRE_HOURS,
TokenPayload,
add_user,
authenticate,
authenticate_pb,
create_token,
decode_token,
delete_user,
get_user_info,
is_first_user,
list_users,
register_pb,
set_avatar,
set_role,
)
logger = logging.getLogger(__name__)
router = APIRouter()
_COOKIE_NAME = "dt_token"
_COOKIE_MAX_AGE = TOKEN_EXPIRE_HOURS * 3600
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
"""Payload for the POST /login endpoint."""
username: str
password: str
class RegisterRequest(BaseModel):
"""Payload for the POST /register endpoint."""
username: str
password: str
@field_validator("username")
@classmethod
def username_valid(cls, v: str) -> str:
import re
v = v.strip()
if not v:
raise ValueError("Email cannot be empty")
# Accept standard email addresses (used by PocketBase mode) or plain
# usernames (used by the built-in SQLite/JSON auth mode).
email_re = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
plain_re = re.compile(r"^[A-Za-z0-9_\-.]{3,64}$")
if not email_re.match(v) and not plain_re.match(v):
raise ValueError("Enter a valid email address")
return v
@field_validator("password")
@classmethod
def password_valid(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
class SetRoleRequest(BaseModel):
"""Payload for the PUT /users/{username}/role endpoint."""
role: str
@field_validator("role")
@classmethod
def role_valid(cls, v: str) -> str:
if v not in ("admin", "user"):
raise ValueError("Role must be 'admin' or 'user'")
return v
class AuthStatusResponse(BaseModel):
"""Response body for the GET /status endpoint."""
enabled: bool
authenticated: bool
user_id: str | None = None
username: str | None = None
role: str | None = None
is_admin: bool = False
avatar: str = ""
class UserInfo(BaseModel):
"""Single user record returned by the GET /users and /profile endpoints."""
id: str = ""
username: str
role: str
created_at: str
disabled: bool = False
avatar: str = ""
# Markers settable through PUT /profile. Image markers ("img:<version>") are
# managed exclusively by the upload endpoint so users cannot point their
# avatar at a file that was never validated.
_ICON_MARKER_RE = re.compile(r"^icon:[a-z0-9-]{1,32}:[a-z0-9-]{1,32}$")
# User ids are generated as "u_<uuid hex>" (plus the "local-admin" /
# "env-admin" sentinels); reject anything else before it reaches the
# filesystem layer.
_USER_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
class UpdateProfileRequest(BaseModel):
"""Payload for the PUT /profile endpoint."""
avatar: str
@field_validator("avatar")
@classmethod
def avatar_valid(cls, v: str) -> str:
v = v.strip()
if v and not _ICON_MARKER_RE.match(v):
raise ValueError("Avatar must be empty or 'icon:<name>:<color>'")
return v
# ---------------------------------------------------------------------------
# Shared helper — extract token from cookie or Bearer header
# ---------------------------------------------------------------------------
def _bearer_token_from_header(authorization: str | None) -> str | None:
"""Parse ``Authorization: Bearer <token>`` without using ``HTTPBearer``.
``HTTPBearer`` is a class-based dependency whose ``__call__`` is annotated
``request: Request``. FastAPI doesn't inject a Request into WebSocket
dependency resolution, which makes ``HTTPBearer`` raise ``TypeError`` the
moment a router with this dep mounts a WS endpoint. Doing the parse by
hand keeps ``require_auth`` HTTP/WS-symmetric.
"""
if not authorization:
return None
parts = authorization.split(None, 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1].strip()
return token or None
return None
def _extract_token(authorization: str | None, dt_token: str | None) -> str | None:
return _bearer_token_from_header(authorization) or dt_token
# ---------------------------------------------------------------------------
# Dependencies — reusable auth guards for other routers
# ---------------------------------------------------------------------------
def _install_current_user(payload: TokenPayload | None) -> _CtxToken:
"""Install the request-local current-user ContextVar from an auth result.
Single point of truth for ``payload → CurrentUser`` so HTTP and WebSocket
entry points produce identical user objects. ``payload is None`` means
"no JWT was required" (AUTH_ENABLED=false) and resolves to the local
admin user; a non-None payload resolves through ``user_from_token_payload``.
Returns the ContextVar reset token. HTTP callers ignore it (the request
ends with the task, so the var is GC'd with the task context). WebSocket
callers keep it and call ``reset_current_user`` in their ``finally`` block,
because a WS connection outlives the dependency-resolution task.
⚠ Invariant: every authenticated entry point MUST call this before the
handler runs. Skipping it leaves ``get_current_path_service()`` falling
back to the admin workspace — the silent-routing root cause of #481.
"""
user = local_admin_user() if payload is None else user_from_token_payload(payload)
return set_current_user(user)
async def require_auth(
authorization: str | None = Header(default=None, alias="Authorization"),
dt_token: str | None = Cookie(default=None),
) -> TokenPayload | None:
"""
FastAPI dependency that enforces authentication when AUTH_ENABLED=true.
Accepts the JWT from either:
- Authorization: Bearer <token> header
- dt_token cookie
``Header`` and ``Cookie`` are kept here in place of ``HTTPBearer`` so the
function stays usable from WebSocket call sites that don't go through
FastAPI's standard HTTP request lifecycle.
Returns the authenticated TokenPayload, or None if auth is disabled.
Raises HTTP 401 if auth is enabled but the token is missing or invalid.
Declared ``async def`` so the ``set_current_user`` call runs in the same
asyncio context as the endpoint. A sync dependency is dispatched via
``anyio.to_thread.run_sync``, which executes the function in a worker
thread under a *copy* of the request context; any ``ContextVar.set``
inside that thread is discarded when the thread returns, leaving the
endpoint to read the unset default. That regression was the root cause
of #481.
"""
if not AUTH_ENABLED:
_install_current_user(None)
return None
token = _extract_token(authorization, dt_token)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
_install_current_user(payload)
return payload
class _WsAuthFailed:
"""Sentinel: ws_require_auth failed and closed the WebSocket."""
ws_auth_failed: _WsAuthFailed = _WsAuthFailed()
async def ws_require_auth(ws: WebSocket) -> _CtxToken | _WsAuthFailed:
"""Authenticate a WebSocket connection and set the user ContextVar.
Must be called **before** ``ws.accept()`` so the server can reject
unauthenticated upgrades cleanly.
Returns a ContextVar reset token on success, or ``ws_auth_failed``
on failure (the WebSocket is already closed — the caller should
``return`` immediately).
Usage::
user_token = await ws_require_auth(ws)
if user_token is ws_auth_failed:
return
await ws.accept()
try:
...
finally:
reset_current_user(user_token)
"""
if not AUTH_ENABLED:
return _install_current_user(None)
token = ws.query_params.get("token") or ws.cookies.get("dt_token")
payload = decode_token(token) if token else None
if not payload:
await ws.close(code=4001)
return ws_auth_failed
return _install_current_user(payload)
async def require_admin(
payload: TokenPayload | None = Depends(require_auth),
) -> TokenPayload:
"""
FastAPI dependency that requires the caller to be an admin.
Raises HTTP 403 if the authenticated user is not an admin.
When AUTH_ENABLED=false, all requests are treated as admin.
``async def`` mirrors ``require_auth`` so the dependency chain stays on
the event loop and the user ContextVar set by ``require_auth`` is visible
to the endpoint.
"""
if not AUTH_ENABLED:
return _local_admin_token_payload()
if payload is None or payload.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return payload
def _local_admin_token_payload() -> TokenPayload:
"""Synthetic admin payload used when AUTH_ENABLED=false.
Mirrors the local admin identity (LOCAL_ADMIN_USERNAME / LOCAL_ADMIN_ID)
so audit logs and self-reference checks behave the same as in multi-user
mode. Values are kept aligned with ``local_admin_user()`` in
``deeptutor/multi_user/paths.py``.
"""
from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME
return TokenPayload(
username=LOCAL_ADMIN_USERNAME,
role="admin",
user_id=LOCAL_ADMIN_ID,
)
# ---------------------------------------------------------------------------
# Public endpoints (no auth required)
# ---------------------------------------------------------------------------
@router.get("/status", response_model=AuthStatusResponse)
async def auth_status(
authorization: str | None = Header(default=None, alias="Authorization"),
dt_token: str | None = Cookie(default=None),
) -> AuthStatusResponse:
"""Return whether auth is enabled and whether the current request is authenticated."""
if not AUTH_ENABLED:
return AuthStatusResponse(
enabled=False,
authenticated=True,
user_id="local-admin",
username="local",
role="admin",
is_admin=True,
)
token = _extract_token(authorization, dt_token)
payload = decode_token(token) if token else None
avatar = ""
if payload is not None:
info = get_user_info(payload.username)
if info:
avatar = str(info.get("avatar") or "")
return AuthStatusResponse(
enabled=True,
authenticated=payload is not None,
user_id=payload.user_id if payload else None,
username=payload.username if payload else None,
role=payload.role if payload else None,
is_admin=payload.role == "admin" if payload else False,
avatar=avatar,
)
@router.post("/login")
async def login(body: LoginRequest, response: Response) -> dict:
"""Validate credentials and set a JWT cookie."""
if not AUTH_ENABLED:
return {"ok": True, "message": "Auth is disabled — no login required."}
if POCKETBASE_ENABLED:
# PocketBase mode: email = username field for backwards-compat with the
# existing LoginRequest schema; users can pass their email as "username".
pb_result = authenticate_pb(body.username, body.password)
if not pb_result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
payload, pb_token = pb_result
response.set_cookie(
key=_COOKIE_NAME,
value=pb_token,
httponly=True,
samesite=_SAMESITE,
max_age=_COOKIE_MAX_AGE,
secure=_SECURE,
)
logger.info(f"User '{payload.username}' logged in via PocketBase (role={payload.role!r})")
return {
"ok": True,
"user_id": payload.user_id,
"username": payload.username,
"role": payload.role,
"is_admin": payload.role == "admin",
}
# Standard JWT + bcrypt mode
result = authenticate(body.username, body.password)
if not result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
token = create_token(result.username, result.role, result.user_id)
response.set_cookie(
key=_COOKIE_NAME,
value=token,
httponly=True,
samesite=_SAMESITE,
max_age=_COOKIE_MAX_AGE,
secure=_SECURE,
)
logger.info(f"User '{result.username}' logged in (role={result.role!r})")
return {
"ok": True,
"user_id": result.user_id,
"username": result.username,
"role": result.role,
"is_admin": result.role == "admin",
}
@router.post("/logout")
async def logout(response: Response) -> dict:
"""Clear the JWT cookie."""
response.delete_cookie(key=_COOKIE_NAME, samesite=_SAMESITE)
return {"ok": True}
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest) -> dict:
"""
Bootstrap-only registration.
Public endpoint that creates the *first* admin account when the user store
is empty. Once an admin exists, this endpoint is closed; further accounts
must be created by an admin via ``POST /api/v1/auth/users``.
Only available when AUTH_ENABLED=true.
"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Auth is disabled — registration is not available.",
)
if POCKETBASE_ENABLED:
# PocketBase deployments are documented as single-user. Keep registration
# closed and require admins to provision users in the PocketBase admin UI.
if not is_first_user():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Self-registration is closed. Ask an administrator to create your account.",
)
result = register_pb(username=body.username, email=body.username, password=body.password)
if not result:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Registration failed — username or email may already be taken.",
)
logger.info(f"First user registered via PocketBase: '{body.username}'")
return {
"ok": True,
"user_id": result.get("id", ""),
"username": body.username,
"role": "user",
"is_first_user": True,
"is_admin": False,
}
# Standard mode — only allowed before the first admin exists.
if not is_first_user():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Self-registration is closed. Ask an administrator to create your account.",
)
existing = {u["username"] for u in list_users()}
if body.username in existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken",
)
add_user(body.username, body.password)
user_id = ""
role = "user"
for item in list_users():
if item.get("username") == body.username:
user_id = str(item.get("id") or "")
role = str(item.get("role") or "user")
break
logger.info(f"First user (admin) registered: '{body.username}'")
return {
"ok": True,
"user_id": user_id,
"username": body.username,
"role": role,
"is_first_user": True,
"is_admin": role == "admin",
}
@router.get("/is_first_user")
async def check_is_first_user() -> dict:
"""Return whether the user store is empty (used by the register UI)."""
return {"is_first_user": is_first_user() if AUTH_ENABLED else False}
# ---------------------------------------------------------------------------
# Profile endpoints (any authenticated user, self-service)
# ---------------------------------------------------------------------------
_AVATAR_MAX_BYTES = 1 * 1024 * 1024
_AVATAR_MEDIA_TYPES = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
def _sniff_image(data: bytes) -> str | None:
"""Detect a supported raster image format from its magic bytes.
The uploaded filename and Content-Type are attacker-controlled, so the
stored extension (and the media type served back) is derived from the
bytes alone. SVG is deliberately unsupported — serving user-supplied SVG
is a stored-XSS vector.
"""
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
if data[:3] == b"\xff\xd8\xff":
return "jpg"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
return None
def _require_profile_identity(payload: TokenPayload | None) -> TokenPayload:
"""Shared guard for the self-service profile endpoints."""
if not AUTH_ENABLED or payload is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Auth is disabled — profiles are not available.",
)
return payload
@router.get("/profile", response_model=UserInfo)
async def get_profile(
payload: TokenPayload | None = Depends(require_auth),
) -> UserInfo:
"""Return the current user's own account info."""
current = _require_profile_identity(payload)
info = get_user_info(current.username)
if info is None:
# PocketBase-backed identities have no local record; fall back to the
# token claims so the profile page still renders.
return UserInfo(
id=current.user_id,
username=current.username,
role=current.role,
created_at="",
)
return UserInfo(**info)
@router.put("/profile")
async def update_profile(
body: UpdateProfileRequest,
payload: TokenPayload | None = Depends(require_auth),
) -> dict:
"""Update the current user's own avatar marker (icon choice or reset).
Only the validated ``icon:<name>:<color>`` form (or empty string) is
accepted here; ``img:`` markers are owned by the upload endpoint.
"""
current = _require_profile_identity(payload)
if not set_avatar(current.username, body.avatar):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# The marker no longer references an uploaded image, so drop the file.
from deeptutor.multi_user.identity import delete_avatar_file
if current.user_id and _USER_ID_RE.match(current.user_id):
delete_avatar_file(current.user_id)
return {"ok": True, "avatar": body.avatar}
@router.put("/profile/avatar")
async def upload_avatar(
file: UploadFile = File(...),
payload: TokenPayload | None = Depends(require_auth),
) -> dict:
"""Upload an avatar image for the current user.
The client is expected to crop/resize before uploading; the server only
enforces a size cap and validates the format by magic bytes. Not available
in PocketBase mode (those identities have no local user record).
"""
current = _require_profile_identity(payload)
if POCKETBASE_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Avatar upload is not available in PocketBase mode.",
)
if not current.user_id or not _USER_ID_RE.match(current.user_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot store an avatar for this account.",
)
info = get_user_info(current.username)
if info is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
data = await file.read(_AVATAR_MAX_BYTES + 1)
if len(data) > _AVATAR_MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Avatar image is too large (max 1 MB).",
)
ext = _sniff_image(data)
if ext is None:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Avatar must be a PNG, JPEG or WebP image.",
)
from deeptutor.multi_user.identity import save_avatar_file
# Bump the version embedded in the marker so clients cache-bust the URL.
previous = str(info.get("avatar") or "")
version = 1
if previous.startswith("img:"):
try:
version = int(previous.split(":", 1)[1]) + 1
except ValueError:
version = 1
marker = f"img:{version}"
save_avatar_file(current.user_id, data, ext)
if not set_avatar(current.username, marker):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
logger.info(f"User '{current.username}' uploaded a new avatar ({ext}, {len(data)} bytes)")
return {"ok": True, "avatar": marker}
@router.delete("/profile/avatar")
async def remove_avatar(
payload: TokenPayload | None = Depends(require_auth),
) -> dict:
"""Remove the current user's uploaded avatar image and reset the marker."""
current = _require_profile_identity(payload)
from deeptutor.multi_user.identity import delete_avatar_file
if current.user_id and _USER_ID_RE.match(current.user_id):
delete_avatar_file(current.user_id)
set_avatar(current.username, "")
return {"ok": True, "avatar": ""}
@router.get("/avatar/{user_id}")
async def get_avatar_image(
user_id: str,
_: TokenPayload | None = Depends(require_auth),
) -> FileResponse:
"""Serve a stored avatar image. Any authenticated user may view avatars
(they appear in the admin table and next to the viewer's own profile)."""
if not _USER_ID_RE.match(user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
from deeptutor.multi_user.identity import get_avatar_file
target = get_avatar_file(user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
media_type = _AVATAR_MEDIA_TYPES.get(target.suffix.lstrip("."), "application/octet-stream")
headers = {
# Private user content; the marker version in the URL handles busting.
"Cache-Control": "private, max-age=86400",
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline",
}
return FileResponse(path=str(target), media_type=media_type, headers=headers)
# ---------------------------------------------------------------------------
# Admin-only endpoints
# ---------------------------------------------------------------------------
@router.get("/users", response_model=list[UserInfo])
async def get_users(_: TokenPayload = Depends(require_admin)) -> list[UserInfo]:
"""List all registered users. Requires admin role."""
return [UserInfo(**u) for u in list_users()]
@router.post("/users", status_code=status.HTTP_201_CREATED)
async def admin_create_user(
body: RegisterRequest,
current: TokenPayload = Depends(require_admin),
) -> dict:
"""Admin-only: create a new user account.
Replaces the public ``/register`` flow once the first admin exists. The
new account is always created with role=``user``; admins can promote
later via ``PUT /users/{username}/role``.
"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Auth is disabled — user creation is not available.",
)
if POCKETBASE_ENABLED:
result = register_pb(username=body.username, email=body.username, password=body.password)
if not result:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Failed to create user — username may already be taken.",
)
logger.info(
f"Admin '{current.username if current else 'local'}' created PocketBase user "
f"'{body.username}'"
)
return {
"ok": True,
"user_id": result.get("id", ""),
"username": body.username,
"role": "user",
"is_admin": False,
}
existing = {u["username"] for u in list_users()}
if body.username in existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken",
)
add_user(body.username, body.password)
user_id = ""
role = "user"
for item in list_users():
if item.get("username") == body.username:
user_id = str(item.get("id") or "")
role = str(item.get("role") or "user")
break
logger.info(
f"Admin '{current.username if current else 'local'}' created user '{body.username}' "
f"(role={role!r})"
)
return {
"ok": True,
"user_id": user_id,
"username": body.username,
"role": role,
"is_admin": role == "admin",
}
@router.delete("/users/{username}", status_code=status.HTTP_200_OK)
async def remove_user(
username: str,
current: TokenPayload = Depends(require_admin),
) -> dict:
"""Delete a user. Admins cannot delete their own account."""
if current and username == current.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete your own account",
)
# Capture the id before the record disappears so the avatar file can go too.
info = get_user_info(username)
removed = delete_user(username)
if not removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user_id = str(info.get("id") or "") if info else ""
if user_id and _USER_ID_RE.match(user_id):
from deeptutor.multi_user.identity import delete_avatar_file
delete_avatar_file(user_id)
logger.info(f"Admin '{current.username if current else 'local'}' deleted user '{username}'")
return {"ok": True}
@router.put("/users/{username}/role", status_code=status.HTTP_200_OK)
async def update_user_role(
username: str,
body: SetRoleRequest,
current: TokenPayload = Depends(require_admin),
) -> dict:
"""Change a user's role. Admins cannot change their own role."""
if current and username == current.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot change your own role",
)
updated = set_role(username, body.role)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
logger.info(
f"Admin '{current.username if current else 'local'}' set '{username}' role to {body.role!r}"
)
return {"ok": True, "username": username, "role": body.role}