Files
2026-06-20 15:01:44 +05:30

228 lines
7.4 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, Tenant
ROLE_SYSTEM_ADMIN = "System Admin"
ROLE_FIRM_ADMIN = "Firm Admin"
ROLE_PARTNER = "Partner"
ROLE_BRANCH_MANAGER = "Branch Manager"
ROLE_STAFF = "Staff"
ROLE_CLIENT = "Client"
ROLE_CONSULTANT = "Consultant"
# Matrix: only System Admin and Firm Admin manage users.
MANAGEABLE_ROLES_BY_ACTOR = {
ROLE_SYSTEM_ADMIN: {
ROLE_SYSTEM_ADMIN,
ROLE_FIRM_ADMIN,
ROLE_PARTNER,
ROLE_BRANCH_MANAGER,
ROLE_STAFF,
ROLE_CLIENT,
ROLE_CONSULTANT,
},
ROLE_FIRM_ADMIN: {
ROLE_PARTNER,
ROLE_BRANCH_MANAGER,
ROLE_STAFF,
ROLE_CLIENT,
ROLE_CONSULTANT,
},
}
@dataclass
class UserScope:
actor: User
role_names: list[str]
is_system_admin: bool
is_firm_admin: bool
is_partner: bool
is_branch_manager: bool
@property
def tenant_scoped(self) -> bool:
return self.is_firm_admin or self.is_partner or self.is_branch_manager
@property
def branch_scoped(self) -> bool:
return self.is_branch_manager
class ScopeError(Exception):
pass
def get_role_names(db: Session, user_id: int) -> list[str]:
q = (
select(Role.name)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user_id, Role.is_active.is_(True))
.order_by(Role.name)
)
return [name for (name,) in db.execute(q).all()]
def build_scope(db: Session, actor: User) -> UserScope:
role_names = get_role_names(db, actor.id)
return UserScope(
actor=actor,
role_names=role_names,
is_system_admin=ROLE_SYSTEM_ADMIN in role_names,
is_firm_admin=ROLE_FIRM_ADMIN in role_names,
is_partner=ROLE_PARTNER in role_names,
is_branch_manager=ROLE_BRANCH_MANAGER in role_names,
)
def ensure_users_view_scope(scope: UserScope) -> None:
if scope.is_system_admin or scope.is_firm_admin or scope.is_partner or scope.is_branch_manager:
return
raise ScopeError("You are not allowed to view users.")
def ensure_users_manage_scope(scope: UserScope) -> None:
if scope.is_system_admin or scope.is_firm_admin:
return
raise ScopeError("Only System Admin and Firm Admin can manage users.")
def list_visible_tenants(db: Session, scope: UserScope) -> list[Tenant]:
if scope.is_system_admin:
return db.execute(
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name)
).scalars().all()
tenant = db.execute(select(Tenant).where(Tenant.id == scope.actor.tenant_id)).scalar_one_or_none()
return [tenant] if tenant else []
def list_visible_branches(db: Session, scope: UserScope, tenant_id: int | None = None) -> list[Branch]:
effective_tenant_id = tenant_id or scope.actor.tenant_id
q = select(Branch).where(Branch.is_active.is_(True), Branch.tenant_id == effective_tenant_id)
if scope.branch_scoped:
q = q.where(Branch.id == scope.actor.branch_id)
return db.execute(q.order_by(Branch.name)).scalars().all()
def list_scoped_users(db: Session, scope: UserScope) -> list[User]:
q = select(User)
if not scope.is_system_admin:
q = q.where(User.tenant_id == scope.actor.tenant_id)
if scope.branch_scoped:
q = q.where(User.branch_id == scope.actor.branch_id)
return db.execute(q.order_by(User.id)).scalars().all()
def get_manageable_roles(db: Session, scope: UserScope) -> list[Role]:
if scope.is_system_admin:
return db.execute(
select(Role).where(Role.is_active.is_(True)).order_by(Role.name)
).scalars().all()
allowed_names: set[str] = set()
for role_name in scope.role_names:
allowed_names.update(MANAGEABLE_ROLES_BY_ACTOR.get(role_name, set()))
if not allowed_names:
return []
return db.execute(
select(Role).where(Role.is_active.is_(True), Role.name.in_(sorted(allowed_names))).order_by(Role.name)
).scalars().all()
def get_user_role_names(db: Session, user_id: int) -> list[str]:
return get_role_names(db, user_id)
def get_user_role_ids(db: Session, user_id: int) -> list[int]:
return db.execute(select(UserRole.role_id).where(UserRole.user_id == user_id)).scalars().all()
def can_manage_role_names(scope: UserScope, role_names: list[str]) -> bool:
if scope.is_system_admin:
return True
allowed: set[str] = set()
for actor_role in scope.role_names:
allowed.update(MANAGEABLE_ROLES_BY_ACTOR.get(actor_role, set()))
return set(role_names).issubset(allowed)
def validate_branch_matches_tenant(db: Session, tenant_id: int, branch_id: int) -> Branch:
branch = db.execute(
select(Branch).where(
Branch.id == branch_id,
Branch.tenant_id == tenant_id,
Branch.is_active.is_(True),
)
).scalar_one_or_none()
if not branch:
raise ScopeError("Selected branch does not belong to the selected tenant.")
return branch
def resolve_target_tenant_branch(
db: Session,
scope: UserScope,
tenant_id: int | None,
branch_id: int | None,
) -> tuple[int, int]:
if scope.is_system_admin:
if tenant_id is None or branch_id is None:
raise ScopeError("Tenant and branch are required.")
validate_branch_matches_tenant(db, tenant_id, branch_id)
return tenant_id, branch_id
effective_tenant_id = scope.actor.tenant_id
effective_branch_id = branch_id
if tenant_id is not None and tenant_id != scope.actor.tenant_id:
raise ScopeError("Cross-tenant user creation is not allowed.")
if effective_branch_id is None:
raise ScopeError("Branch is required.")
validate_branch_matches_tenant(db, effective_tenant_id, effective_branch_id)
return effective_tenant_id, effective_branch_id
def ensure_manageable_existing_user(db: Session, scope: UserScope, target_user: User) -> None:
if scope.is_system_admin:
return
if not scope.is_firm_admin:
raise ScopeError("Only System Admin and Firm Admin can manage users.")
if target_user.tenant_id != scope.actor.tenant_id:
raise ScopeError("You cannot manage users of another tenant.")
target_roles = get_user_role_names(db, target_user.id)
if target_roles and not can_manage_role_names(scope, target_roles):
raise ScopeError("You cannot manage the selected user's role level.")
def ensure_assignable_roles(db: Session, scope: UserScope, role_ids: list[int]) -> list[Role]:
if not role_ids:
return []
roles = db.execute(
select(Role).where(Role.id.in_(role_ids), Role.is_active.is_(True)).order_by(Role.name)
).scalars().all()
if len(roles) != len(set(role_ids)):
raise ScopeError("One or more selected roles are invalid.")
if not can_manage_role_names(scope, [r.name for r in roles]):
raise ScopeError("You cannot assign one or more selected roles.")
return roles
def assert_can_manage_role_object(scope: UserScope, role: Role) -> None:
if scope.is_system_admin:
return
raise ScopeError("Only System Admin can manage RBAC roles.")
def scope_to_http(exc: ScopeError) -> HTTPException:
return HTTPException(status_code=403, detail=str(exc))