- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
stock change); 'limited' -> supervisor scope (own memberships, stock change
allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
procurement_manager, or procurement 'full') across all 18 write routers;
quote CREATE additionally allows 'limited' users restricted to their member
projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
limited -> minimal RequesterHome), wizard project options restricted for
limited users, change-inventory button/route gated to inventory 'limited',
organization panel gains the panels & access-levels management section
279 lines
11 KiB
Python
279 lines
11 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 AsyncSessionLocal
|
|
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
|
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
|
|
# لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', 'inventory': ...}
|
|
panels: dict | None = None
|
|
# عضویت M:N در پروژهها — محدوده داده سطح limited (جدا از مسئولیت PM)
|
|
member_project_ids: list | None = None
|
|
|
|
|
|
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")
|
|
|
|
username = str(claims.get("name") or claims.get("preferred_username") or "")
|
|
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
|
if user is None:
|
|
user = User(
|
|
casdoor_id=casdoor_id,
|
|
username=username or None,
|
|
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()
|
|
elif username and not user.username:
|
|
user.username = username
|
|
await db.flush()
|
|
|
|
project_ids: list = []
|
|
if user.role in ("supervisor", "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
|
|
|
|
# لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای (جدا از نقش)
|
|
panels: dict = {}
|
|
panel_rows = (
|
|
await db.execute(
|
|
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
|
|
)
|
|
).scalars().all()
|
|
for row in panel_rows:
|
|
panels[row.panel] = row.level
|
|
member_project_ids: list = [
|
|
row.project_id
|
|
for row in (
|
|
await db.execute(
|
|
select(UserProjectMembership)
|
|
.where(UserProjectMembership.user_id == user.id)
|
|
.order_by(UserProjectMembership.created_at)
|
|
)
|
|
).scalars().all()
|
|
]
|
|
|
|
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,
|
|
panels=panels,
|
|
member_project_ids=member_project_ids,
|
|
)
|
|
|
|
|
|
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
|
"""Every project id managed by the given PM/supervisor 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 | None = None) -> 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).
|
|
|
|
IMPORTANT: when ``db`` is omitted (pure auth-dependency usage), a
|
|
short-lived session of its own is used — querying the REQUEST session
|
|
here would auto-begin its transaction and break endpoints that open
|
|
explicit ``async with db.begin()`` blocks afterwards.
|
|
"""
|
|
auth = request.headers.get("authorization", "")
|
|
if not auth.lower().startswith("bearer "):
|
|
return None
|
|
token = auth.split(" ", 1)[1].strip()
|
|
if not token:
|
|
return None
|
|
if db is None:
|
|
async with AsyncSessionLocal() as own_session:
|
|
claims = await _verify_token(token)
|
|
return await context_from_claims(claims, own_session)
|
|
claims = await _verify_token(token)
|
|
return await context_from_claims(claims, db)
|
|
|
|
|
|
async def get_current_user(request: Request) -> UserContext:
|
|
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned).
|
|
|
|
Resolves the user in its own session (see get_optional_user) so the
|
|
global auth dependency never interferes with an endpoint's explicit
|
|
transaction handling.
|
|
"""
|
|
ctx = await get_optional_user(request)
|
|
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
|
|
|
|
|
|
def require_procurement_writer():
|
|
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
|
|
(دوره مهاجرت از مدل نقش به مدل پنل)."""
|
|
|
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
|
if ctx.role in ("admin", "procurement_manager"):
|
|
return ctx
|
|
if (ctx.panels or {}).get("procurement") == "full":
|
|
return ctx
|
|
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
|
|
|
|
return _dep
|
|
|
|
|
|
def allow_quote_create():
|
|
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
|
|
(محدود به پروژههای عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام میشود)."""
|
|
|
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
|
if ctx.role in ("admin", "procurement_manager"):
|
|
return ctx
|
|
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
|
|
return ctx
|
|
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
|
|
|
|
return _dep
|