920920bc67
Security - SSRF: async DNS resolver; allow_redirects=False on all outbound clients; matrix homeserver_url validated on create/update/test; update_provider and email_bot merge incoming config and reject ***-masked secrets. - Auth: bcrypt offloaded to asyncio.to_thread; JWT now carries iss/aud + leeway and rejects missing claims; setup TOCTOU closed inside a transaction; rate limits extended (default 600/min, 10/min on password change, 30/min on needs-setup); constant-time login to prevent username enumeration. - Config: rejects known dev secret keys; validates CORS origin schemes, port range, token lifetimes. - Webhook handlers stream-read body with a 1 MiB cap; Discord 429 retries bounded (3 attempts, Retry-After capped at 60 s). - CSP + HSTS added to SecurityHeadersMiddleware. Async / runtime - SQLite engine: WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout, pool_pre_ping, dispose on shutdown. - Lifespan shutdown now stops scheduler before closing HTTP session and disposing the engine. - Shared aiohttp session locked against concurrent first-caller races; core NotificationDispatcher accepts and reuses it. - Storage and scheduled backup writes wrapped in asyncio.to_thread. - NUT client writes bounded by asyncio.wait_for. - Telegram poller switched from 3 s short-poll to 30 s interval + 25 s long-poll (~10x fewer API calls). Database - New performance-indexes migration covers every FK/owner column and hot-path composite (notification_tracker(provider_id, enabled); event_log(user_id, created_at DESC); webhook_payload_log(provider_id, created_at DESC); action_execution(action_id, started_at DESC)). - New schema_version table for future upgrade gating. - __system__ placeholder user (id=0) seeded so user_id=0 system defaults satisfy the newly enforced FK; filtered out of /auth/needs-setup, /api/users, and setup. - list_notification_trackers rewritten to batched loads (was 1+N+N*M). - Retention job extended to event_log, webhook_payload_log, and action_execution; retention days exposed as a setting. Scheduler - AsyncIOScheduler job_defaults: coalesce, misfire_grace_time=300, max_instances=1. Ops - uvicorn runs with proxy_headers, forwarded_allow_ips, timeout_graceful_shutdown; access log suppressed in non-debug. - FastAPI version string now reads from importlib.metadata. - New /api/ready endpoint separate from /api/health. - docker-compose drops the ALLOW_PRIVATE_URLS=1 default, adds mem/cpu/pid limits, read_only + tmpfs, cap_drop:ALL, no-new-privileges; healthcheck targets /api/ready. - CI now runs on push/PR with backend pytest, frontend svelte-check + build, and a non-push image build; release workflow gated on tests, publishes immutable sha-<commit> image tag, adds Trivy scan. Tests - New packages/server/tests/ with 29 passing tests: config validation, JWT round-trip + aud/alg=none rejection, SSRF scheme and private-range enforcement (sync + async), Discord bounded retry, and a lifespan-level /api/health + /api/ready smoke check. - Renamed the misnamed services/test_dispatch.py to manual_dispatch.py so pytest never auto-collects production code. Frontend - /login now redirects already-authenticated users to /, shows a distinct 'backend unreachable' banner (en/ru) when /auth/needs-setup fails.
202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
"""User management API routes (admin only)."""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func
|
|
from sqlmodel import select
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
import bcrypt
|
|
|
|
from ..auth.dependencies import require_admin
|
|
from ..database.engine import get_session
|
|
from ..database.models import User
|
|
|
|
|
|
async def _hash_password(password: str) -> str:
|
|
"""Run bcrypt off the event loop. Matches the helper in auth/routes.py."""
|
|
|
|
def _work() -> str:
|
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
|
|
return await asyncio.to_thread(_work)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
username: str
|
|
password: str
|
|
role: str = "user"
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
username: str | None = None
|
|
password: str | None = None
|
|
role: str | None = None
|
|
|
|
|
|
@router.get("")
|
|
async def list_users(
|
|
admin: User = Depends(require_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""List all users (admin only).
|
|
|
|
Excludes the internal ``__system__`` placeholder (id=0) used as the
|
|
owner of default templates/configs — it is never a real account.
|
|
"""
|
|
result = await session.exec(select(User).where(User.id != 0))
|
|
return [
|
|
{"id": u.id, "username": u.username, "role": u.role, "created_at": u.created_at.isoformat()}
|
|
for u in result.all()
|
|
]
|
|
|
|
|
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
async def create_user(
|
|
body: UserCreate,
|
|
admin: User = Depends(require_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Create a new user (admin only)."""
|
|
# Check for duplicate username
|
|
result = await session.exec(select(User).where(User.username == body.username))
|
|
if result.first():
|
|
raise HTTPException(status_code=409, detail="Username already exists")
|
|
|
|
if len(body.password) < 8:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
|
|
user = User(
|
|
username=body.username,
|
|
hashed_password=await _hash_password(body.password),
|
|
role=body.role if body.role in ("admin", "user") else "user",
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return {"id": user.id, "username": user.username, "role": user.role}
|
|
|
|
|
|
@router.patch("/{user_id}")
|
|
async def update_user(
|
|
user_id: int,
|
|
body: UserUpdate,
|
|
admin: User = Depends(require_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Update username and/or role for a user (admin only)."""
|
|
user = await session.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Track whether the identity that JWTs encode has changed. Any such change
|
|
# must bump ``token_version`` so already-issued tokens are rejected — a
|
|
# user demoted admin→user must not keep admin in their cached JWT until
|
|
# expiry, and a rename should invalidate prior sessions too.
|
|
identity_changed = False
|
|
|
|
if body.username is not None and body.username != user.username:
|
|
new_username = body.username.strip()
|
|
if not new_username:
|
|
raise HTTPException(status_code=400, detail="Username cannot be empty")
|
|
dup = await session.exec(select(User).where(User.username == new_username))
|
|
if dup.first():
|
|
raise HTTPException(status_code=409, detail="Username already exists")
|
|
user.username = new_username
|
|
identity_changed = True
|
|
|
|
if body.role is not None and body.role != user.role:
|
|
if body.role not in ("admin", "user"):
|
|
raise HTTPException(status_code=400, detail="Invalid role")
|
|
# Prevent demoting the last admin. Done via a COUNT to avoid loading
|
|
# every admin row; more importantly, re-checked *after* the role
|
|
# change is staged (TOCTOU guard — two concurrent demotes can each
|
|
# see admin_count=2 and both proceed, dropping to 0).
|
|
if user.role == "admin" and body.role != "admin":
|
|
admin_count = (await session.exec(
|
|
select(func.count(User.id)).where(User.role == "admin")
|
|
)).one()
|
|
if isinstance(admin_count, tuple):
|
|
admin_count = admin_count[0]
|
|
if (admin_count or 0) <= 1:
|
|
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
|
user.role = body.role
|
|
identity_changed = True
|
|
|
|
if identity_changed:
|
|
user.token_version = (user.token_version or 1) + 1
|
|
|
|
session.add(user)
|
|
try:
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
# Final defense against admin-count race: if we just demoted the last admin
|
|
# due to a concurrent demote landing between our check and commit, undo.
|
|
if body.role is not None and body.role != "admin":
|
|
admin_count_after = (await session.exec(
|
|
select(func.count(User.id)).where(User.role == "admin")
|
|
)).one()
|
|
if isinstance(admin_count_after, tuple):
|
|
admin_count_after = admin_count_after[0]
|
|
if (admin_count_after or 0) < 1:
|
|
# Roll the user back to admin and re-commit.
|
|
user.role = "admin"
|
|
session.add(user)
|
|
await session.commit()
|
|
raise HTTPException(status_code=409, detail="Refused: would remove the last admin")
|
|
|
|
await session.refresh(user)
|
|
return {"id": user.id, "username": user.username, "role": user.role}
|
|
|
|
|
|
class ResetPasswordRequest(BaseModel):
|
|
new_password: str
|
|
|
|
|
|
@router.put("/{user_id}/password")
|
|
async def reset_user_password(
|
|
user_id: int,
|
|
body: ResetPasswordRequest,
|
|
admin: User = Depends(require_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Reset a user's password (admin only)."""
|
|
user = await session.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if len(body.new_password) < 8:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
user.hashed_password = await _hash_password(body.new_password)
|
|
# Invalidate all prior JWTs issued for this user — matches the self-serve
|
|
# password-change path in auth/routes.py.
|
|
user.token_version = (user.token_version or 1) + 1
|
|
session.add(user)
|
|
await session.commit()
|
|
return {"success": True}
|
|
|
|
|
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_user(
|
|
user_id: int,
|
|
admin: User = Depends(require_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Delete a user (admin only, cannot delete self)."""
|
|
if user_id == admin.id:
|
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
|
user = await session.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
await session.delete(user)
|
|
await session.commit()
|