diff --git a/backend/app/config.py b/backend/app/config.py index 1673996..157b7ba 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,7 +1,14 @@ """Application configuration. All settings can be overridden with environment variables prefixed with -``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env`` file. +``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env`` +file. The docker-compose files inject them from the git-ignored ``.env`` — +real credentials never live in this repository. + +``ENVIRONMENT`` is the safety switch: when it is ``"prod"``, the API runs +fail-fast startup checks (see app.main._verify_production_settings) that +refuse to boot with wildcard CORS, default credentials or dev login +enabled. """ from pydantic_settings import BaseSettings, SettingsConfigDict @@ -14,7 +21,10 @@ class Settings(BaseSettings): extra="ignore", ) - # PostgreSQL DSN — the schema (migration.sql + seed.sql) must already exist. + # "dev" (local defaults allowed) | "prod" (fail-fast checks on startup). + ENVIRONMENT: str = "dev" + + # PostgreSQL DSN — the schema (migration.sql) must already exist. DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement" # Echo SQL statements to the console (dev aid). @@ -22,6 +32,8 @@ class Settings(BaseSettings): # MinIO object storage — invoice / proforma attachments (S3-compatible). # Endpoint is the compose service name (`minio:9000`) inside Docker. + # Defaults are LOCAL DEV ONLY — production values come from .env and the + # prod startup checks reject them. MINIO_ENDPOINT: str = "localhost:9000" MINIO_ACCESS_KEY: str = "minioadmin" MINIO_SECRET_KEY: str = "minioadmin" @@ -36,27 +48,52 @@ class Settings(BaseSettings): DEFAULT_PAGE_SIZE: int = 20 MAX_PAGE_SIZE: int = 100 - # CORS (dev-friendly default; the frontend is a static site). - CORS_ORIGINS: list[str] = ["*"] + # CORS — comma-separated exact browser origins (NOT "*" with + # credentials; the prod checks reject wildcard). Example: + # PROCUREMENT_CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir + CORS_ORIGINS: str = "http://localhost,http://localhost:3000,http://localhost:5173" + + # Developer Mode: in-page dev login panel (frontend build arg) AND the + # X-User-Role / X-User-Name-B64 header identity fallback in the + # inventory/pm routers. MUST stay false in production. + DEV_LOGIN_ENABLED: bool = False # Casdoor (OIDC) — JWT verification against the issuer's JWKS. CASDOOR_ENDPOINT: str = "http://localhost:8080" CASDOOR_ISSUER: str = "" # expected token iss claim (default: CASDOOR_ENDPOINT) CASDOOR_CLIENT_ID: str = "inventory-app-client" - CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026" + CASDOOR_CLIENT_SECRET: str = "" # no default secret — set via env/.env CASDOOR_ORGANIZATION: str = "inventory" CASDOOR_APP_NAME: str = "inventory-app" CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id) # Casdoor console admin — used by POST /users/sync to mirror Casdoor - # identities into the app's users table (dev defaults: admin / 123). + # identities into the app's users table. No default password. CASDOOR_ADMIN_USER: str = "admin" - CASDOOR_ADMIN_PASSWORD: str = "123" + CASDOOR_ADMIN_PASSWORD: str = "" - # Inventory dev fallback (only when no Authorization header is sent). + # Inventory dev fallback defaults (only used when DEV_LOGIN_ENABLED). INVENTORY_DEFAULT_ROLE: str = "pm" INVENTORY_DEFAULT_PROJECT_ID: str = "" + # ---- Observability (Phase 2) ------------------------------------------- + # Sentry/GlitchTip DSN — backend exception capture (empty = disabled). + SENTRY_DSN: str = "" + SENTRY_TRACES_SAMPLE_RATE: float = 0.0 + # OpenPanel server-side event ingestion (empty = disabled). + OPENPANEL_API_URL: str = "" # e.g. http://openpanel-api:8080 (self-hosted) + OPENPANEL_CLIENT_ID: str = "" + OPENPANEL_CLIENT_SECRET: str = "" + + @property + def cors_origin_list(self) -> list[str]: + """Comma-separated CORS_ORIGINS → list for CORSMiddleware.""" + return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()] + + @property + def is_prod(self) -> bool: + return self.ENVIRONMENT.lower() == "prod" + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index da20142..10529da 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 803e647..df1d06c 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -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") diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index c75f210..2a3249f 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -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 # مدیر تدارکات = ادمین (فقط‌خواندنی) diff --git a/backend/app/routers/item_decisions.py b/backend/app/routers/item_decisions.py index dd9e3ce..e7bb0dd 100644 --- a/backend/app/routers/item_decisions.py +++ b/backend/app/routers/item_decisions.py @@ -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) ): diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py index d5ea10f..be0be0f 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -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" diff --git a/backend/requirements.txt b/backend/requirements.txt index c779078..93f8b24 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,10 @@ minio>=7.2.0 # v2.6 — Casdoor JWT auth pyjwt[crypto]>=2.8.0 httpx>=0.27.0 + +# Casdoor seeding — bcrypt password hashes (never plaintext) +bcrypt>=4.1.0 + +# Observability — metrics + Sentry/GlitchTip exception capture +prometheus-fastapi-instrumentator>=7.0.0 +sentry-sdk[fastapi]>=2.0.0 diff --git a/backend/tools/smoke_test.py b/backend/tools/smoke_test.py index 0d10c12..69efef3 100644 --- a/backend/tools/smoke_test.py +++ b/backend/tools/smoke_test.py @@ -23,6 +23,17 @@ def check(name, ok, extra=""): c = httpx.Client(base_url=BASE, timeout=30) +# ---------- auth: every endpoint now requires a Casdoor JWT ---------- +import os + +r = c.post("/auth/login", json={ + "username": os.environ.get("SMOKE_USER", "tester"), + "password": os.environ.get("SMOKE_PASSWORD", "123456"), +}) +check("POST /auth/login", r.status_code == 200, r.text[:200]) +if r.status_code == 200: + c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}" + # ---------- catalog ---------- r = c.get("/units", params={"page_size": 5}) check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0]) diff --git a/backend/tools/smoke_test_misc.py b/backend/tools/smoke_test_misc.py index 02db408..c31b990 100644 --- a/backend/tools/smoke_test_misc.py +++ b/backend/tools/smoke_test_misc.py @@ -19,6 +19,17 @@ def check(name, ok, extra=""): c = httpx.Client(base_url=BASE, timeout=30) +# ---------- auth: every endpoint now requires a Casdoor JWT ---------- +import os + +r = c.post("/auth/login", json={ + "username": os.environ.get("SMOKE_USER", "tester"), + "password": os.environ.get("SMOKE_PASSWORD", "123456"), +}) +check("POST /auth/login", r.status_code == 200, r.text[:200]) +if r.status_code == 200: + c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}" + # ---------- units ---------- r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0}) check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, r.text[:200]) diff --git a/backend/tools/smoke_test_write.py b/backend/tools/smoke_test_write.py index 0fa7826..5131904 100644 --- a/backend/tools/smoke_test_write.py +++ b/backend/tools/smoke_test_write.py @@ -23,6 +23,17 @@ def check(name, ok, extra=""): c = httpx.Client(base_url=BASE, timeout=30) +# ---------- auth: every endpoint now requires a Casdoor JWT ---------- +import os + +r = c.post("/auth/login", json={ + "username": os.environ.get("SMOKE_USER", "tester"), + "password": os.environ.get("SMOKE_PASSWORD", "123456"), +}) +check("POST /auth/login", r.status_code == 200, r.text[:200]) +if r.status_code == 200: + c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}" + # ---------- supplier + project for the test quote ---------- sup = c.post("/suppliers", json={"name": "تأمین‌کننده تست", "location": "تهران"}).json()["data"] proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"] diff --git a/frontend/src/components/FileList.tsx b/frontend/src/components/FileList.tsx index ccb3b20..c898527 100644 --- a/frontend/src/components/FileList.tsx +++ b/frontend/src/components/FileList.tsx @@ -9,13 +9,13 @@ export interface FileListProps { upload: (file: File) => Promise; // حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده می‌شود) onDelete?: (a: Attachment) => void; - // لینک دانلود؛ null یعنی هنوز آپلود نشده - urlOf?: (a: Attachment) => string | null; + // دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد) + onDownload?: (a: Attachment) => void | Promise; label?: string; } // مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده -export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) { +export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) { const inputRef = useRef(null); const remove = (i: number) => { @@ -45,10 +45,19 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا {files.map((inv, i) => (
📎 {inv.name} - {urlOf?.(inv) && ( - + {onDownload && ( + )}
@@ -414,7 +414,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"), ) } - urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)} + onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)} label="افزودن پیش‌فاکتور" /> diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index 5b667a7..8bcd6a6 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -720,7 +720,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"), ) } - urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)} + onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)} /> diff --git a/frontend/src/pages/Dashboard/QuoteDetail.tsx b/frontend/src/pages/Dashboard/QuoteDetail.tsx index ab5e4c5..9063b65 100644 --- a/frontend/src/pages/Dashboard/QuoteDetail.tsx +++ b/frontend/src/pages/Dashboard/QuoteDetail.tsx @@ -144,19 +144,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) { attachments.proformas.forEach((pf, i) => { if (!pf.id) return; attachParts.push( - + , ); }); attachments.invoices.forEach((inv, i) => { if (!inv.id) return; attachParts.push( - + , ); }); diff --git a/frontend/src/pages/Suppliers/PurchaseHistory.tsx b/frontend/src/pages/Suppliers/PurchaseHistory.tsx index b2bc4be..a3535d4 100644 --- a/frontend/src/pages/Suppliers/PurchaseHistory.tsx +++ b/frontend/src/pages/Suppliers/PurchaseHistory.tsx @@ -112,28 +112,28 @@ export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistory ) : ( <> {profs.map((pf) => ( - api.files.download(q.id, "proforma", pf.id!, pf.name)} > پیش‌فاکتور: {pf.name} - + ))} {invs.map((inv) => ( - api.files.download(q.id, "invoice", inv.id!, inv.name)} > فاکتور: {inv.name} - + ))} {!loading && !profs.length && !invs.length && ( فاکتوری ثبت نشده