Files
notify-bridge/packages/server/src/notify_bridge_server/api/users.py
T
alexei.dolgolyov 56993d2ca3 fix(security,perf): harden restore, CSRF, token_version + perf pass
Security
- Sign pending_restore.json (SHA256 stored in AppSetting, verified on
  startup apply) + refuse path outside data_dir, tighten to 0600.
- Require same-origin Origin/Referer on POST /api/backup/apply-restart —
  Bearer-in-localStorage is CSRF-reachable from any XSS'd admin tab.
- Bump token_version on role/username change and admin password reset so
  demoted admins lose admin in already-issued JWTs.  Guard last-admin
  TOCTOU via COUNT + post-commit re-check that rolls back a race.
- SSRF guard (validate_outbound_url) in ImmichClient.__init__ and the
  external_domain setter — admin-mutable URLs were bypassing the check
  that webhook/slack/discord paths already used.  Dev restart script now
  sets NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 so homelab Immich still works.
- Redact + cap Immich error bodies to ~120 chars before they flow into
  ActionExecution.error / EventLog.details (both UI-visible).
- Deny-list sensitive keys (api_key / token / secret / password /
  authorization / cookie / ...) in template-context merges so a rogue
  template can't exfiltrate provider creds via {{ api_key }}.
- Cap user-controlled Immich search params (query ≤256, person_ids ≤50,
  size ≤100) so a Telegram listener can't DoS upstream.
- Stream upload reads with running byte counter + content-length precheck
  instead of buffering the full body and then rejecting.
- Log Telegram parse_mode fallbacks instead of swallowing silently;
  template escape bugs now surface in server logs.
- Rollback partial imports on pending-restore failure (error recorded on
  a fresh session).

Performance
- Fix N+1 in _refresh_telegram_chat_titles: single IN query instead of
  session.get per chat.
- Parallelize album + shared-link fetches in test_dispatch (asyncio.gather)
  and per-receiver Telegram test sends in notifier (semaphore 5).
- Early-exit collect_scheduled_assets(limit=0) so the periodic-summary
  test path skips full per-album filter/sample (was O(album_assets)).
- Emit explicit CREATE INDEX IF NOT EXISTS for event_log user_id /
  action_id / provider_id so the first boot after upgrade isn't left
  unindexed for the dashboard query.
- Add AbortController timeout (120s) to fetchAuth so uploads/downloads
  don't hang indefinitely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:28:55 +03:00

188 lines
6.6 KiB
Python

"""User management API routes (admin only)."""
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
_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)."""
result = await session.exec(select(User))
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=bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode(),
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 = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
# 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()