Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user