feat(security): JWT required on every API route, gated dev fallback, role checks

- main.py: global get_current_user dependency on all routers except
  /auth/login and /health; prod fail-fast startup guard rejects wildcard
  CORS, default credentials, DEV_LOGIN_ENABLED
- inventory/pm: X-User-Role header identity fallback now only active when
  PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production)
- item_decisions writes + attachment deletes: require_role(
  procurement_manager, admin) — require_role was defined but never used
- config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default
  Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false
- frontend: authenticated file downloads (Bearer + blob) — <a href> links
  cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/
  PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false
- smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD)
- traefik: security headers, API rate limit, 12MB body cap (prod overlay)
- nginx client_max_body_size aligned to 12MB
This commit is contained in:
MMD
2026-09-18 22:02:09 +03:30
parent 43e317eeba
commit f7b1f7f751
16 changed files with 225 additions and 47 deletions
+40 -3
View File
@@ -9,12 +9,13 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from app.auth import get_current_user
from app.config import settings
from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import (
@@ -45,8 +46,35 @@ async def _auto_expire_loop() -> None:
logger.exception("Auto-expiry pass failed")
def _verify_production_settings() -> None:
"""Fail-fast guard: refuse to boot in prod with dev defaults.
These exact misconfigurations are what previously shipped (wildcard CORS
with credentials, default MinIO/Postgres/Casdoor credentials, and the
always-on X-User-Role identity fallback). Ops fix: set the real values
in the server-side .env — see docs/SECRET-MANAGEMENT.md.
"""
problems: list[str] = []
if not settings.cors_origin_list or "*" in settings.cors_origin_list:
problems.append("PROCUREMENT_CORS_ORIGINS must list exact origins (no '*')")
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
problems.append("MinIO credentials are still the default minioadmin")
if not settings.CASDOOR_CLIENT_SECRET:
problems.append("PROCUREMENT_CASDOOR_CLIENT_SECRET is not set")
if not settings.CASDOOR_ADMIN_PASSWORD:
problems.append("PROCUREMENT_CASDOOR_ADMIN_PASSWORD is not set")
if settings.DEV_LOGIN_ENABLED:
problems.append("PROCUREMENT_DEV_LOGIN_ENABLED must be false in production")
if problems:
raise RuntimeError(
"Refusing to start in production — fix the environment:\n - " + "\n - ".join(problems)
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
if settings.is_prod:
_verify_production_settings()
await ensure_dashboard_tables(engine)
await ensure_attachment_table(engine)
await ensure_material_topic_column(engine)
@@ -74,7 +102,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -158,9 +186,18 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
# JWT auth is required on EVERY router; the auth router stays public
# (POST /auth/login issues the tokens) and /health lives below, outside the
# /api/v1 prefix. Routers with their own finer-grained scopes (inventory,
# pm, users) keep them — the global dependency guarantees nothing is open
# by omission.
from app.routers.auth import router as _auth_router # noqa: E402
for router in ROUTERS:
app.include_router(router, prefix=API_V1_PREFIX)
if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX)
else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
@app.get("/health", tags=["system"])