Files
notify-bridge/packages/server/src/notify_bridge_server/api/users.py
T
alexei.dolgolyov 920920bc67
Build and Test / test-frontend (push) Successful in 9m37s
Build and Test / test-backend (push) Successful in 10m53s
Build and Test / build-image (push) Failing after 14m52s
feat: production-readiness hardening across security, async, DB, ops
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.
2026-04-23 19:44:56 +03:00

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()