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:
+45
-8
@@ -1,7 +1,14 @@
|
|||||||
"""Application configuration.
|
"""Application configuration.
|
||||||
|
|
||||||
All settings can be overridden with environment variables prefixed with
|
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
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
@@ -14,7 +21,10 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
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"
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
|
||||||
|
|
||||||
# Echo SQL statements to the console (dev aid).
|
# Echo SQL statements to the console (dev aid).
|
||||||
@@ -22,6 +32,8 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# MinIO object storage — invoice / proforma attachments (S3-compatible).
|
# MinIO object storage — invoice / proforma attachments (S3-compatible).
|
||||||
# Endpoint is the compose service name (`minio:9000`) inside Docker.
|
# 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_ENDPOINT: str = "localhost:9000"
|
||||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||||
MINIO_SECRET_KEY: str = "minioadmin"
|
MINIO_SECRET_KEY: str = "minioadmin"
|
||||||
@@ -36,27 +48,52 @@ class Settings(BaseSettings):
|
|||||||
DEFAULT_PAGE_SIZE: int = 20
|
DEFAULT_PAGE_SIZE: int = 20
|
||||||
MAX_PAGE_SIZE: int = 100
|
MAX_PAGE_SIZE: int = 100
|
||||||
|
|
||||||
# CORS (dev-friendly default; the frontend is a static site).
|
# CORS — comma-separated exact browser origins (NOT "*" with
|
||||||
CORS_ORIGINS: list[str] = ["*"]
|
# 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 (OIDC) — JWT verification against the issuer's JWKS.
|
||||||
CASDOOR_ENDPOINT: str = "http://localhost:8080"
|
CASDOOR_ENDPOINT: str = "http://localhost:8080"
|
||||||
CASDOOR_ISSUER: str = "" # expected token iss claim (default: CASDOOR_ENDPOINT)
|
CASDOOR_ISSUER: str = "" # expected token iss claim (default: CASDOOR_ENDPOINT)
|
||||||
CASDOOR_CLIENT_ID: str = "inventory-app-client"
|
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_ORGANIZATION: str = "inventory"
|
||||||
CASDOOR_APP_NAME: str = "inventory-app"
|
CASDOOR_APP_NAME: str = "inventory-app"
|
||||||
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
|
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
|
||||||
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
|
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
|
# 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_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_ROLE: str = "pm"
|
||||||
INVENTORY_DEFAULT_PROJECT_ID: str = ""
|
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()
|
settings = Settings()
|
||||||
|
|||||||
+40
-3
@@ -9,12 +9,13 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import AsyncSessionLocal, engine
|
from app.db.engine import AsyncSessionLocal, engine
|
||||||
from app.db.init_db import (
|
from app.db.init_db import (
|
||||||
@@ -45,8 +46,35 @@ async def _auto_expire_loop() -> None:
|
|||||||
logger.exception("Auto-expiry pass failed")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
|
if settings.is_prod:
|
||||||
|
_verify_production_settings()
|
||||||
await ensure_dashboard_tables(engine)
|
await ensure_dashboard_tables(engine)
|
||||||
await ensure_attachment_table(engine)
|
await ensure_attachment_table(engine)
|
||||||
await ensure_material_topic_column(engine)
|
await ensure_material_topic_column(engine)
|
||||||
@@ -74,7 +102,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS,
|
allow_origins=settings.cors_origin_list,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -158,9 +186,18 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# 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:
|
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"])
|
@app.get("/health", tags=["system"])
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from urllib.parse import quote
|
|||||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Quote
|
from app.models.procurement import Quote
|
||||||
from app.services import file_service
|
from app.services import file_service
|
||||||
@@ -27,6 +28,10 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||||
|
|
||||||
|
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
||||||
|
# برای هر کاربر واردشده آزاد است.
|
||||||
|
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||||
|
|
||||||
ALLOWED_MIME_TYPES = {
|
ALLOWED_MIME_TYPES = {
|
||||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
"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)):
|
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
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)):
|
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||||
|
|||||||
@@ -95,9 +95,11 @@ async def inventory_scope(
|
|||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
|
|
||||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
# 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.
|
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||||
if not request.headers.get("x-user-role"):
|
# Casdoor). Disabled by default: unauthenticated header identity in
|
||||||
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
|
# 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()
|
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
|
||||||
if role == "procurement_manager":
|
if role == "procurement_manager":
|
||||||
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقطخواندنی)
|
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقطخواندنی)
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ State machine (P1 cross-endpoint rules):
|
|||||||
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
|
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
|
||||||
pending -> rejected
|
pending -> rejected
|
||||||
chosen/rejected -> pending/anything-else = 409
|
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 fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
|
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
|
||||||
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
|
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
|
||||||
@@ -21,6 +26,9 @@ router = APIRouter(
|
|||||||
tags=["item-decisions"],
|
tags=["item-decisions"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# خرید/رد اقلام = تصمیم تدارکات؛ ادمین و مدیر تدارکات
|
||||||
|
_decision_writer = require_role("procurement_manager", "admin")
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
li = await db.scalar(
|
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)}
|
return {"data": decision_full(decision)}
|
||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("", dependencies=[Depends(_decision_writer)])
|
||||||
async def put_decision(
|
async def put_decision(
|
||||||
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
|
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}
|
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(
|
async def patch_decision_status(
|
||||||
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
|
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)}
|
return {"data": decision_full(decision)}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("")
|
@router.patch("", dependencies=[Depends(_decision_writer)])
|
||||||
async def patch_decision(
|
async def patch_decision(
|
||||||
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
|
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ Rules enforced here:
|
|||||||
the canonical unit for auto-posted purchases.
|
the canonical unit for auto-posted purchases.
|
||||||
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
||||||
|
|
||||||
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
|
Auth: Casdoor JWT (a global dependency in main.py) resolves the caller;
|
||||||
X-Project-Id for pm) when no Authorization header — same pattern as the
|
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.
|
inventory router.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
@@ -35,6 +36,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user
|
||||||
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
||||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
@@ -98,9 +100,12 @@ async def arrivals_scope(
|
|||||||
if ctx.role in ("admin", "procurement_manager"):
|
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)
|
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
||||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
|
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
|
||||||
if not request.headers.get("x-user-role"):
|
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
||||||
raise UnauthorizedError("احراز هویت لازم است — توکن 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()
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role == "procurement_manager":
|
if role == "procurement_manager":
|
||||||
role = "admin"
|
role = "admin"
|
||||||
|
|||||||
@@ -10,3 +10,10 @@ minio>=7.2.0
|
|||||||
# v2.6 — Casdoor JWT auth
|
# v2.6 — Casdoor JWT auth
|
||||||
pyjwt[crypto]>=2.8.0
|
pyjwt[crypto]>=2.8.0
|
||||||
httpx>=0.27.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
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ def check(name, ok, extra=""):
|
|||||||
|
|
||||||
c = httpx.Client(base_url=BASE, timeout=30)
|
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 ----------
|
# ---------- catalog ----------
|
||||||
r = c.get("/units", params={"page_size": 5})
|
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])
|
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ def check(name, ok, extra=""):
|
|||||||
|
|
||||||
c = httpx.Client(base_url=BASE, timeout=30)
|
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 ----------
|
# ---------- units ----------
|
||||||
r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0})
|
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])
|
check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, r.text[:200])
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ def check(name, ok, extra=""):
|
|||||||
|
|
||||||
c = httpx.Client(base_url=BASE, timeout=30)
|
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 ----------
|
# ---------- supplier + project for the test quote ----------
|
||||||
sup = c.post("/suppliers", json={"name": "تأمینکننده تست", "location": "تهران"}).json()["data"]
|
sup = c.post("/suppliers", json={"name": "تأمینکننده تست", "location": "تهران"}).json()["data"]
|
||||||
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
|
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export interface FileListProps {
|
|||||||
upload: (file: File) => Promise<Attachment>;
|
upload: (file: File) => Promise<Attachment>;
|
||||||
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده میشود)
|
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده میشود)
|
||||||
onDelete?: (a: Attachment) => void;
|
onDelete?: (a: Attachment) => void;
|
||||||
// لینک دانلود؛ null یعنی هنوز آپلود نشده
|
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
||||||
urlOf?: (a: Attachment) => string | null;
|
onDownload?: (a: Attachment) => void | Promise<void>;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
||||||
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const remove = (i: number) => {
|
const remove = (i: number) => {
|
||||||
@@ -45,10 +45,19 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
|
|||||||
{files.map((inv, i) => (
|
{files.map((inv, i) => (
|
||||||
<div key={i} className="inv-row">
|
<div key={i} className="inv-row">
|
||||||
<span className="inv-name">📎 {inv.name}</span>
|
<span className="inv-name">📎 {inv.name}</span>
|
||||||
{urlOf?.(inv) && (
|
{onDownload && (
|
||||||
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inv-open"
|
||||||
|
title="دانلود"
|
||||||
|
onClick={() => {
|
||||||
|
Promise.resolve(onDownload(inv)).catch((err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
↓
|
↓
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
||||||
×
|
×
|
||||||
|
|||||||
+25
-2
@@ -1282,8 +1282,31 @@ export const api = {
|
|||||||
remove: async (quoteId: string, kind: FileKind, id: string): Promise<void> => {
|
remove: async (quoteId: string, kind: FileKind, id: string): Promise<void> => {
|
||||||
await deleteData(`/quotes/${quoteId}/${kind}/${id}`);
|
await deleteData(`/quotes/${quoteId}/${kind}/${id}`);
|
||||||
},
|
},
|
||||||
downloadUrl: (quoteId: string, kind: FileKind, id: string): string =>
|
// دانلود احراز هویتشده: <a href> توکن Authorization نمیفرستد، پس بایتها
|
||||||
`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`,
|
// را با توکن میگیریم و به شکل blob در مرورگر ذخیره میکنیم.
|
||||||
|
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName || "attachment";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- برندها (از بکاند) ----------
|
// ---------- برندها (از بکاند) ----------
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
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)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
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="افزودن پیشفاکتور"
|
label="افزودن پیشفاکتور"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
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)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -144,19 +144,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
attachments.proformas.forEach((pf, i) => {
|
attachments.proformas.forEach((pf, i) => {
|
||||||
if (!pf.id) return;
|
if (!pf.id) return;
|
||||||
attachParts.push(
|
attachParts.push(
|
||||||
<a key={"pf" + i} className="detail-link" href={api.files.downloadUrl(q.id, "proforma", pf.id)} download={pf.name}>
|
<button
|
||||||
|
key={"pf" + i}
|
||||||
|
type="button"
|
||||||
|
className="detail-link"
|
||||||
|
title="دانلود پیشفاکتور"
|
||||||
|
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
||||||
|
>
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
پیشفاکتور: {pf.name}
|
پیشفاکتور: {pf.name}
|
||||||
</a>,
|
</button>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
attachments.invoices.forEach((inv, i) => {
|
attachments.invoices.forEach((inv, i) => {
|
||||||
if (!inv.id) return;
|
if (!inv.id) return;
|
||||||
attachParts.push(
|
attachParts.push(
|
||||||
<a key={"inv" + i} className="detail-link" href={api.files.downloadUrl(q.id, "invoice", inv.id)} download={inv.name}>
|
<button
|
||||||
|
key={"inv" + i}
|
||||||
|
type="button"
|
||||||
|
className="detail-link"
|
||||||
|
title="دانلود فاکتور"
|
||||||
|
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
||||||
|
>
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
فاکتور اصلی: {inv.name}
|
فاکتور اصلی: {inv.name}
|
||||||
</a>,
|
</button>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,28 +112,28 @@ export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistory
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{profs.map((pf) => (
|
{profs.map((pf) => (
|
||||||
<a
|
<button
|
||||||
key={"pf-" + pf.id}
|
key={"pf-" + pf.id}
|
||||||
|
type="button"
|
||||||
className="detail-link"
|
className="detail-link"
|
||||||
href={api.files.downloadUrl(q.id, "proforma", pf.id!)}
|
|
||||||
download={pf.name}
|
|
||||||
title="دانلود پیشفاکتور"
|
title="دانلود پیشفاکتور"
|
||||||
|
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
||||||
>
|
>
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
پیشفاکتور: {pf.name}
|
پیشفاکتور: {pf.name}
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
{invs.map((inv) => (
|
{invs.map((inv) => (
|
||||||
<a
|
<button
|
||||||
key={"inv-" + inv.id}
|
key={"inv-" + inv.id}
|
||||||
|
type="button"
|
||||||
className="detail-link"
|
className="detail-link"
|
||||||
href={api.files.downloadUrl(q.id, "invoice", inv.id!)}
|
|
||||||
download={inv.name}
|
|
||||||
title="دانلود فاکتور"
|
title="دانلود فاکتور"
|
||||||
|
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
||||||
>
|
>
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
فاکتور: {inv.name}
|
فاکتور: {inv.name}
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!loading && !profs.length && !invs.length && (
|
{!loading && !profs.length && !invs.length && (
|
||||||
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user