Files
ERP/backend/app/authz.py
T
MMD 934b74ebbd Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
2026-10-07 15:14:20 +03:30

297 lines
9.8 KiB
Python

"""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]