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:
@@ -0,0 +1,296 @@
|
||||
"""Central authorization (RBAC) — roles, permissions and per-project scoping.
|
||||
|
||||
Everything authorization-related lives in this module so that adding a role is
|
||||
one entry in `ROLE_PERMISSIONS` (plus a nav entry on the frontend), not a hunt
|
||||
through the routers.
|
||||
|
||||
Two independent questions, answered separately:
|
||||
|
||||
* **Role → permissions** (`Perm`, `ROLE_PERMISSIONS`) decides *which operations*
|
||||
a user may call at all.
|
||||
* **Project scope** decides *which projects* those operations may touch. A role
|
||||
listed in `SCOPED_ROLES` is limited to the rows in `project_assignments`
|
||||
(an empty set means "no project at all"); every other role is unrestricted.
|
||||
|
||||
Routers must go through the dependencies and helpers below — hiding a button in
|
||||
the UI is a convenience, never the enforcement.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import Select, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.errors import ForbiddenError, NotFoundError
|
||||
from app.models import PMUser, ProjectAssignment, Task
|
||||
from app.security import get_current_user
|
||||
|
||||
logger = logging.getLogger("pm.authz")
|
||||
|
||||
|
||||
# --- roles --------------------------------------------------------------------
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
project_manager = "project_manager"
|
||||
project_supervisor = "project_supervisor"
|
||||
|
||||
|
||||
ROLE_LABELS: dict[str, str] = {
|
||||
Role.project_manager.value: "مدیر پروژه",
|
||||
Role.project_supervisor.value: "سرپرست پروژه",
|
||||
}
|
||||
|
||||
# Users created before RBAC existed keep their previous (full) access.
|
||||
DEFAULT_ROLE = Role.project_manager
|
||||
|
||||
# Roles whose access is limited to their assigned projects.
|
||||
SCOPED_ROLES: frozenset[Role] = frozenset({Role.project_supervisor})
|
||||
|
||||
|
||||
# --- permissions --------------------------------------------------------------
|
||||
|
||||
|
||||
class Perm(str, enum.Enum):
|
||||
"""One value per authorizable operation — keep it coarse and stable."""
|
||||
|
||||
dashboard_view = "dashboard.view"
|
||||
projects_view = "projects.view"
|
||||
projects_create = "projects.create"
|
||||
projects_update = "projects.update"
|
||||
tasks_view = "tasks.view"
|
||||
tasks_create = "tasks.create"
|
||||
tasks_update = "tasks.update"
|
||||
tasks_delete = "tasks.delete"
|
||||
tasks_progress = "tasks.progress"
|
||||
tasks_dates = "tasks.dates"
|
||||
qc_view = "qc.view"
|
||||
# executing QC: recording answers and raising/resolving defects
|
||||
qc_execute = "qc.execute"
|
||||
# QC structure: global templates + per-task definitions
|
||||
qc_define = "qc.define"
|
||||
materials_view = "materials.view"
|
||||
materials_manage = "materials.manage"
|
||||
users_manage = "users.manage"
|
||||
files_upload = "files.upload"
|
||||
|
||||
|
||||
# Project manager: full access to everything, across every project.
|
||||
_MANAGER_PERMISSIONS: frozenset[str] = frozenset(p.value for p in Perm)
|
||||
|
||||
# Project supervisor: executes the assigned projects — reads the plan, records
|
||||
# progress and QC results, needs the material list; never edits the QC
|
||||
# structure, templates, procurement or integration settings.
|
||||
_SUPERVISOR_PERMISSIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
Perm.dashboard_view.value,
|
||||
Perm.projects_view.value,
|
||||
Perm.tasks_view.value,
|
||||
Perm.tasks_progress.value,
|
||||
Perm.qc_view.value,
|
||||
Perm.qc_execute.value,
|
||||
Perm.materials_view.value,
|
||||
Perm.files_upload.value,
|
||||
}
|
||||
)
|
||||
|
||||
ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
|
||||
Role.project_manager: _MANAGER_PERMISSIONS,
|
||||
Role.project_supervisor: _SUPERVISOR_PERMISSIONS,
|
||||
}
|
||||
|
||||
|
||||
def permissions_for(role: Role) -> frozenset[str]:
|
||||
return ROLE_PERMISSIONS.get(role, frozenset())
|
||||
|
||||
|
||||
def role_of(user: PMUser) -> Role:
|
||||
try:
|
||||
return Role(user.role)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"unknown role %r on user %s — falling back to %s", user.role, user.username, DEFAULT_ROLE.value
|
||||
)
|
||||
return DEFAULT_ROLE
|
||||
|
||||
|
||||
# --- request context ----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
"""Who is calling, what they may do, and which projects they may touch.
|
||||
|
||||
`project_uuids` — native projects (`projects.id`).
|
||||
None for an unscoped role (project manager). For a scoped role a set — possibly empty — means "only these".
|
||||
"""
|
||||
|
||||
user: PMUser
|
||||
role: Role
|
||||
project_uuids: frozenset[uuid.UUID] | None = None
|
||||
|
||||
@property
|
||||
def permissions(self) -> frozenset[str]:
|
||||
return permissions_for(self.role)
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
return ROLE_LABELS.get(self.role.value, self.role.value)
|
||||
|
||||
@property
|
||||
def all_projects(self) -> bool:
|
||||
return self.project_uuids is None
|
||||
|
||||
def can(self, permission: Perm | str) -> bool:
|
||||
value = permission.value if isinstance(permission, Perm) else permission
|
||||
return value in self.permissions
|
||||
|
||||
def require(self, permission: Perm | str) -> None:
|
||||
if not self.can(permission):
|
||||
raise ForbiddenError(f"نقش «{self.role_label}» به این عملیات دسترسی ندارد")
|
||||
|
||||
def allows_project(self, project_id) -> bool:
|
||||
"""Check whether the user is allowed to access the specified project."""
|
||||
if self.project_uuids is None:
|
||||
return True
|
||||
try:
|
||||
pid = uuid.UUID(str(project_id))
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
return pid in self.project_uuids
|
||||
|
||||
def ensure_project(self, project_id) -> None:
|
||||
"""Raise 403 when the project is outside this user's scope."""
|
||||
if not self.allows_project(project_id):
|
||||
raise ForbiddenError("این پروژه به شما اختصاص داده نشده است")
|
||||
|
||||
allows_native_project = allows_project
|
||||
ensure_native_project = ensure_project
|
||||
|
||||
def scope(self, stmt: Select, column) -> Select:
|
||||
"""Restrict a query over project UUIDs."""
|
||||
if self.project_uuids is None:
|
||||
return stmt
|
||||
return stmt.where(column.in_(self.project_uuids))
|
||||
|
||||
scope_native = scope
|
||||
|
||||
@property
|
||||
def scoped_project_count(self) -> int:
|
||||
"""How many projects a scoped user may see (0 when nothing is assigned)."""
|
||||
return len(self.project_uuids or ())
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"role": self.role.value,
|
||||
"role_label": self.role_label,
|
||||
"permissions": sorted(self.permissions),
|
||||
"scope": {
|
||||
"all_projects": self.all_projects,
|
||||
"project_count": None if self.all_projects else self.scoped_project_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def assigned_scope(db: AsyncSession, user_id) -> frozenset[uuid.UUID]:
|
||||
"""Load assigned project UUIDs for a scoped user."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProjectAssignment.project_id).where(
|
||||
ProjectAssignment.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assigned = set(r for r in rows if r is not None)
|
||||
|
||||
# Also resolve projects assigned via projects.pm_user_id or user_project_members in the shared DB
|
||||
pm_user = await db.get(PMUser, user_id)
|
||||
if pm_user and pm_user.username:
|
||||
erp_rows = (
|
||||
await db.execute(
|
||||
text("""
|
||||
SELECT p.id FROM projects p JOIN users u ON p.pm_user_id = u.id WHERE u.username = :username
|
||||
UNION
|
||||
SELECT upm.project_id FROM user_project_members upm JOIN users u ON upm.user_id = u.id WHERE u.username = :username
|
||||
"""),
|
||||
{"username": pm_user.username},
|
||||
)
|
||||
).scalars().all()
|
||||
for r in erp_rows:
|
||||
if r:
|
||||
try:
|
||||
assigned.add(uuid.UUID(str(r)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return frozenset(assigned)
|
||||
|
||||
|
||||
async def build_context(db: AsyncSession, user: PMUser) -> AuthContext:
|
||||
"""Resolve role + project scope for a user."""
|
||||
role = role_of(user)
|
||||
if role not in SCOPED_ROLES:
|
||||
return AuthContext(user=user, role=role)
|
||||
native = await assigned_scope(db, user.id)
|
||||
return AuthContext(user=user, role=role, project_uuids=native)
|
||||
|
||||
|
||||
async def get_auth_context(
|
||||
user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
) -> AuthContext:
|
||||
return await build_context(db, user)
|
||||
|
||||
|
||||
def require_permission(permission: Perm) -> Callable[..., Awaitable[AuthContext]]:
|
||||
"""Dependency: authenticated **and** allowed to perform `permission`."""
|
||||
|
||||
async def _guard(ctx: AuthContext = Depends(get_auth_context)) -> AuthContext:
|
||||
ctx.require(permission)
|
||||
return ctx
|
||||
|
||||
return _guard
|
||||
|
||||
|
||||
# --- data-access guards -------------------------------------------------------
|
||||
|
||||
|
||||
async def resolve_task(db: AsyncSession, task_ref: str | uuid.UUID) -> Task | None:
|
||||
"""Find a native task by UUID."""
|
||||
task_ref = task_ref.strip() if isinstance(task_ref, str) else task_ref
|
||||
try:
|
||||
task_uuid = uuid.UUID(str(task_ref))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
return await db.scalar(select(Task).where(Task.id == task_uuid))
|
||||
|
||||
|
||||
async def scoped_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> Task:
|
||||
"""Load a native task: 404 unknown, 403 out of scope."""
|
||||
task = await resolve_task(db, task_ref)
|
||||
if task is None:
|
||||
raise NotFoundError("تسک یافت نشد")
|
||||
ctx.ensure_project(task.project_id)
|
||||
return task
|
||||
|
||||
|
||||
scoped_native_task = scoped_task
|
||||
resolve_native_task = resolve_task
|
||||
|
||||
|
||||
# --- development role switching ----------------------------------------------
|
||||
|
||||
def dev_switch_enabled() -> bool:
|
||||
return bool(settings.DEV_ROLE_SWITCH)
|
||||
|
||||
|
||||
def dev_login_usernames() -> list[str]:
|
||||
"""Demo accounts the dev switcher may log in as (never arbitrary users)."""
|
||||
return [name for name in settings.dev_demo_usernames() if name]
|
||||
Reference in New Issue
Block a user