"""Casdoor SSO for PM — one identity source for the whole suite. The Casdoor JWT issued by the ERP login flow is accepted on every PM endpoint: - The token is verified against Casdoor's JWKS (same as the ERP backend): signature (RS256 family), issuer = ``PM_CASDOOR_ISSUER`` (the browser-facing origin Casdoor advertises — ``casdoor.origin`` in the compose env) and audience = ``PM_CASDOOR_AUDIENCE`` (the inventory-app client). - The caller's system-wide role lives in the ERP (``users.role``) and is resolved by calling ``GET {ERP_BASE_URL}/api/v1/users/me`` with the SAME Bearer token (the ERP verifies the identical JWT). Result is cached briefly per casdoor id — one upstream call per role change window, not per request. - Mapping to the PM world (single source of truth stays the ERP): admin -> project_manager (full PM access) pm (مدیر پروژه) -> project_manager (full PM access) supervisor -> project_supervisor (project-scoped, as today) procurement_manager -> 403 — مدیر تدارکات به پنل PM دسترسی ندارد - The PMUser row is auto-provisioned / role-synced by username on first use, so تخصیص سرپرست‌ها به پروژه‌ها (ProjectAssignment) بلافاصله بعد از اولین ورود قابل انجام است. Legacy HMAC session tokens (POST /auth/login, dev role switcher, standalone :8083 deployments) keep working: ``get_current_user`` dispatches on token shape (two dot-separated segments = legacy, three = Casdoor JWT). """ import logging import time import httpx import jwt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.errors import AuthError, ForbiddenError, UpstreamError from app.models import PMUser # نقش‌های PM به‌صورت متن (PMUser.role ستون متنی است) — import از authz اینجا # چرخه‌ی import می‌سازد (authz → security → casdoor_auth → authz) _PM_PROJECT_MANAGER = "project_manager" _PM_PROJECT_SUPERVISOR = "project_supervisor" logger = logging.getLogger("pm.casdoor") _JWKS_CACHE: dict | None = None # casdoor_id -> (erp_role, expiry monotonic seconds) _ROLE_CACHE: dict[str, tuple[str, float]] = {} _ROLE_CACHE_TTL_SECONDS = 30 def _jwks_url() -> str: return f"{settings.ERP_CASDOOR_ENDPOINT.rstrip('/')}/.well-known/jwks" async def _get_jwks() -> dict: global _JWKS_CACHE if _JWKS_CACHE is not None: return _JWKS_CACHE 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("Casdoor JWKS fetch failed (%s): %s", _jwks_url(), exc) raise UpstreamError("سرویس احراز هویت (Casdoor) در دسترس نیست") from exc return _JWKS_CACHE def _key_from_jwk(jwk: dict): 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_casdoor_token(token: str) -> dict: """Validate the Casdoor JWT and return its claims (AuthError on failure).""" try: kid = jwt.get_unverified_header(token).get("kid") except jwt.PyJWTError as exc: logger.warning("JWT unreadable: %s", exc) raise AuthError("توکن نامعتبر است") from exc 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 AuthError("امضای توکن نامعتبر است") key = _key_from_jwk(entry) try: return jwt.decode( token, key, algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"], issuer=settings.CASDOOR_ISSUER, audience=settings.CASDOOR_AUDIENCE, ) except jwt.PyJWTError as exc: logger.warning("JWT verification failed: %s", exc) raise AuthError("توکن نامعتبر یا منقضی شده است") from exc async def _erp_identity_for_token(token: str, casdoor_id: str) -> dict: """نقش + پنل‌های کاربر از ERP (users/me) — کش کوتاه.""" cached = _ROLE_CACHE.get(casdoor_id) if cached and cached[1] > time.monotonic(): return cached[0] url = f"{settings.ERP_BASE_URL.rstrip('/')}/api/v1/users/me" try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(url, headers={"Authorization": f"Bearer {token}"}) except httpx.HTTPError as exc: logger.error("ERP /users/me unreachable (%s): %s", url, exc) raise UpstreamError("سامانه تدارکات (ERP) برای تعیین دسترسی در دسترس نیست") from exc if resp.status_code == 401: raise AuthError("توکن نامعتبر یا منقضی شده است") if resp.status_code != 200: logger.error("ERP /users/me failed (%s): HTTP %s", url, resp.status_code) raise UpstreamError("تعیین دسترسی کاربر از ERP ناموفق بود") data = resp.json().get("data") or {} identity = {"role": data.get("role"), "panels": data.get("panels") or {}} _ROLE_CACHE[casdoor_id] = (identity, time.monotonic() + _ROLE_CACHE_TTL_SECONDS) return identity # سطح پنل PM -> نقش PM: full = همه بخش‌ها با اختیار کامل تغییر؛ # limited = پاسخ QC + درصد پیشرفت + مشاهده جزئیات (محدود به پروژه‌های تخصیصی). # مدیر تدارکات به PM دسترسی ندارد. async def casdoor_user_from_token(token: str, db: AsyncSession) -> PMUser: """Verify the JWT, resolve the system role from ERP and return the provisioned PMUser (role synced). 403 for roles without PM access.""" claims = await verify_casdoor_token(token) casdoor_id = str(claims.get("sub") or claims.get("id") or "") username = str(claims.get("name") or claims.get("preferred_username") or "") if not casdoor_id or not username: raise AuthError("توکن فاقد شناسه کاربر است") identity = await _erp_identity_for_token(token, casdoor_id) panels = identity.get("panels") or {} erp_role = identity.get("role") pm_level = panels.get("pm") if erp_role == "admin": pm_role = _PM_PROJECT_MANAGER elif pm_level == "full": pm_role = _PM_PROJECT_MANAGER elif pm_level == "limited": pm_role = _PM_PROJECT_SUPERVISOR elif not panels and erp_role in ("admin", "pm"): # میراث — دوره مهاجرت فقط در صورت عدم تعریف پنل‌ها pm_role = _PM_PROJECT_MANAGER elif not panels and erp_role == "supervisor": # میراث — فقط در صورت عدم تعریف پنل‌ها pm_role = _PM_PROJECT_SUPERVISOR else: raise ForbiddenError("به پنل مدیریت پروژه دسترسی ندارید") user = await db.scalar(select(PMUser).where(PMUser.username == username)) if user is None: display_name = str( claims.get("displayName") or claims.get("firstName") or username ) user = PMUser( username=username, password_hash="", # SSO identities never authenticate locally display_name=display_name, role=pm_role, ) db.add(user) await db.commit() await db.refresh(user) elif user.role != pm_role: # نقش سراسری عوض شده — با PM همگام می‌شود user.role = pm_role await db.commit() await db.refresh(user) return user def is_casdoor_token(token: str) -> bool: """Legacy session tokens are ``body.signature`` (one dot); Casdoor JWTs have the standard three header/payload/signature segments.""" return token.count(".") == 2