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
+7 -2
View File
@@ -20,6 +20,7 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import Quote
from app.services import file_service
@@ -27,6 +28,10 @@ from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(prefix="/quotes", tags=["files"])
# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
# برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
}
@@ -120,7 +125,7 @@ async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: As
)
@router.delete("/{quote_id}/invoice/{attachment_id}")
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
@@ -165,7 +170,7 @@ async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: A
)
@router.delete("/{quote_id}/proforma/{attachment_id}")
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
+5 -3
View File
@@ -95,9 +95,11 @@ async def inventory_scope(
return Scope(role="admin", project_id=project_id, actor=user.display_name)
# Dev fallback: header-based identity (fork's placeholder mode) — only
# when an explicit X-User-Role header is sent. No auth info at all → 401.
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
# Casdoor). Disabled by default: unauthenticated header identity in
# production would hand admin access to anyone who can reach the API.
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
if role == "procurement_manager":
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی)
+11 -3
View File
@@ -4,11 +4,16 @@ State machine (P1 cross-endpoint rules):
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
pending -> rejected
chosen/rejected -> pending/anything-else = 409
Auth: JWT required globally (main.py). The state-changing endpoints are a
purchase decision — restricted to procurement_manager / admin via
require_role; reads stay open to every authenticated role.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
@@ -21,6 +26,9 @@ router = APIRouter(
tags=["item-decisions"],
)
# خرید/رد اقلام = تصمیم تدارکات؛ ادمین و مدیر تدارکات
_decision_writer = require_role("procurement_manager", "admin")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
@@ -59,7 +67,7 @@ async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db
return {"data": decision_full(decision)}
@router.put("")
@router.put("", dependencies=[Depends(_decision_writer)])
async def put_decision(
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
):
@@ -93,7 +101,7 @@ async def put_decision(
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
@router.patch("/status")
@router.patch("/status", dependencies=[Depends(_decision_writer)])
async def patch_decision_status(
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
):
@@ -118,7 +126,7 @@ async def patch_decision_status(
return {"data": decision_full(decision)}
@router.patch("")
@router.patch("", dependencies=[Depends(_decision_writer)])
async def patch_decision(
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
):
+10 -5
View File
@@ -19,8 +19,9 @@ Rules enforced here:
the canonical unit for auto-posted purchases.
- quote.status flips to 'arrived' when ALL chosen items are fully received.
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
X-Project-Id for pm) when no Authorization header — same pattern as the
Auth: Casdoor JWT (a global dependency in main.py) resolves the caller;
optional dev-header fallback (X-User-Role: pm|admin + X-Project-Id for pm)
exists only when PROCUREMENT_DEV_LOGIN_ENABLED=true — same pattern as the
inventory router.
"""
import base64
@@ -35,6 +36,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
@@ -98,9 +100,12 @@ async def arrivals_scope(
if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM.
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager":
role = "admin"