chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)

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
This commit is contained in:
MMD
2026-10-07 15:14:20 +03:30
87 changed files with 23922 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
"""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