The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
185 lines
8.0 KiB
Python
185 lines
8.0 KiB
Python
"""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
|