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))