228 lines
7.4 KiB
Python
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)) |