Files
whqreza 1f16e44874 feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly

Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)

Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
2026-09-15 19:23:50 +03:30

206 lines
7.5 KiB
Python

"""Casdoor JWT authentication — the v2.6 identity source.
- Verifies the Bearer token against Casdoor's JWKS (/.well-known/jwks).
The expected iss claim is CASDOOR_ISSUER (defaults to CASDOOR_ENDPOINT),
so the endpoint may be the in-network service URL while the issuer is the
browser-facing origin Casdoor advertises.
- Auto-provisions the `users` row from the token claims on first login
(role stays NULL until assigned by an admin / seed).
- Resolves the caller's role (users.role) and, for PMs, their single
project (projects.pm_user_id).
- 401 for missing/invalid/expired tokens, 403 when the user has no role yet,
503 when Casdoor itself is unreachable.
- Dev fallback: when no Authorization header is present, endpoints that
use `get_optional_user` degrade gracefully; the inventory router keeps
its header-based placeholder mode for local testing.
Dependencies (pyjwt[crypto]):
pip install pyjwt[crypto]
"""
import logging
from dataclasses import dataclass
import jwt
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.utils.errors import (
ForbiddenError,
ServiceUnavailableError,
UnauthorizedError,
)
logger = logging.getLogger("procurement.auth")
_JWKS_CACHE: dict | None = None
def _jwks_url() -> str:
return settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/.well-known/jwks"
async def _get_jwks() -> dict:
"""Fetch (and cache) Casdoor's public JWKS."""
global _JWKS_CACHE
if _JWKS_CACHE is not None:
return _JWKS_CACHE
import httpx
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(_jwks_url())
resp.raise_for_status()
_JWKS_CACHE = resp.json()
except httpx.HTTPError as exc:
logger.error("JWKS fetch failed (%s): %s", _jwks_url(), exc)
raise ServiceUnavailableError("سرویس احراز هویت (Casdoor) در دسترس نیست")
return _JWKS_CACHE
def _key_from_jwk(jwk: dict):
"""Build a crypto key object from a JWKS entry (pyjwt 2.13 no longer
accepts raw JWK dicts in jwt.decode)."""
kty = jwk.get("kty")
if kty == "RSA":
return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
if kty == "EC":
return jwt.algorithms.ECAlgorithm.from_jwk(jwk)
return jwk
async def _verify_token(token: str) -> dict:
"""Validate the Casdoor JWT and return its claims (401 on any failure)."""
try:
kid = jwt.get_unverified_header(token).get("kid")
except jwt.PyJWTError as exc:
logger.warning("JWT unreadable: %s", exc)
raise UnauthorizedError("توکن نامعتبر است", field="authorization")
jwks = await _get_jwks()
entry = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
if entry is None:
logger.warning("JWT kid %r not found in JWKS", kid)
raise UnauthorizedError("امضای توکن نامعتبر است", field="authorization")
key = _key_from_jwk(entry)
issuer = settings.CASDOOR_ISSUER or settings.CASDOOR_ENDPOINT
audience = settings.CASDOOR_AUDIENCE or settings.CASDOOR_CLIENT_ID
try:
return jwt.decode(
token,
key,
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
issuer=issuer,
audience=audience,
)
except jwt.PyJWTError as exc:
logger.warning("JWT verification failed: %s", exc)
raise UnauthorizedError("توکن نامعتبر یا منقضی شده است", field="authorization")
@dataclass
class UserContext:
user: User
role: str | None
project_id: object | None # uuid.UUID | None — default (first) project of a PM
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
"""Auto-provision the `users` row from token claims; resolve role + PM projects.
First login: creates the row with role NULL (an admin assigns it later).
PMs: resolves EVERY project they manage (projects.pm_user_id = user);
`project_id` stays the first one (oldest) for backward compatibility.
"""
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
if not casdoor_id:
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None:
user = User(
casdoor_id=casdoor_id,
first_name=str(
claims.get("firstName")
or claims.get("firstname")
or claims.get("displayName")
or claims.get("name")
or "کاربر"
),
last_name=str(claims.get("lastName") or claims.get("lastname") or ""),
phone=str(claims.get("phone") or "") or None,
)
db.add(user)
await db.flush()
project_ids: list = []
if user.role == "pm":
projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
project_ids = [p.id for p in projects]
project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(
user=user,
role=user.role,
project_id=project_id,
project_ids=project_ids,
display_name=display_name,
)
async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM user (oldest first)."""
rows = (
await db.execute(
select(Project.id)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
return list(rows)
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
"""Resolve the caller from the Casdoor Bearer token, or None when absent.
Raises UnauthorizedError for a present-but-invalid token; callers decide
their own fallback only for the no-token case (e.g. dev headers).
"""
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return None
token = auth.split(" ", 1)[1].strip()
if not token:
return None
claims = await _verify_token(token)
return await context_from_claims(claims, db)
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned)."""
ctx = await get_optional_user(request, db)
if ctx is None:
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
return ctx
def require_role(*roles: str):
"""Dependency factory: require the caller to hold one of the given roles."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role not in roles:
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
return _dep