# /opt/docker/dev/service_finder/backend/app/services/rbac_service.py """ 🎯 Scope-Based Admin RBAC Service (P0) Implements scope-based access control for admin/staff roles. Instead of direct organization membership, admins are assigned a scope (e.g., REGION → "Pest_County") that determines which organizations they can manage. Architecture: - SUPERADMIN: Unrestricted access (bypasses all scope checks) - ADMIN/MODERATOR: Full access within their assigned scope - SALES_REP/SERVICE_MGR: Scoped access with action-level filtering Scope Types: - REGION: Geographic region (e.g., "Pest_County", "Budapest") - SEGMENT: Business segment (e.g., "Fleet", "Dealer", "Service") - GLOBAL: Unrestricted (equivalent to SUPERADMIN for scope purposes) Usage: rbac = RBACService() await rbac.check_admin_access(db, user, "EDIT_DATA", target_org_id=42) """ from __future__ import annotations import logging from enum import Enum from typing import Optional, Dict, Set, List, Union from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.models.identity import User, UserRole from app.models.marketplace.organization import Organization from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability logger = logging.getLogger(__name__) class ScopeType(str, Enum): """The type of scope assigned to an admin user.""" GLOBAL = "GLOBAL" REGION = "REGION" SEGMENT = "SEGMENT" class AdminAction(str, Enum): """Admin actions that can be checked against scope.""" # ── Data Management ── EDIT_DATA = "EDIT_DATA" VIEW_DATA = "VIEW_DATA" DELETE_DATA = "DELETE_DATA" EXPORT_DATA = "EXPORT_DATA" # ── Subscription Management ── VIEW_SUBSCRIPTION = "VIEW_SUBSCRIPTION" EDIT_SUBSCRIPTION = "EDIT_SUBSCRIPTION" CANCEL_SUBSCRIPTION = "CANCEL_SUBSCRIPTION" # ── User Management ── MANAGE_USERS = "MANAGE_USERS" SUSPEND_USERS = "SUSPEND_USERS" BAN_USERS = "BAN_USERS" # ── Financial ── VIEW_FINANCIALS = "VIEW_FINANCIALS" MANAGE_BILLING = "MANAGE_BILLING" ISSUE_REFUNDS = "ISSUE_REFUNDS" # ── Moderation ── APPROVE_CONTENT = "APPROVE_CONTENT" MODERATE_REVIEWS = "MODERATE_REVIEWS" VERIFY_PROVIDERS = "VERIFY_PROVIDERS" # ────────────────────────────────────────────── # ACTION-TO-ROLE MAPPING # Which roles are permitted to perform which actions # ────────────────────────────────────────────── # Actions that SUPERADMIN can always do (implicitly all) # Actions that ADMIN can do within their scope ADMIN_SCOPE_ACTIONS: Set[str] = { AdminAction.EDIT_DATA, AdminAction.VIEW_DATA, AdminAction.DELETE_DATA, AdminAction.EXPORT_DATA, AdminAction.VIEW_SUBSCRIPTION, AdminAction.EDIT_SUBSCRIPTION, AdminAction.MANAGE_USERS, AdminAction.SUSPEND_USERS, AdminAction.BAN_USERS, AdminAction.VIEW_FINANCIALS, AdminAction.MANAGE_BILLING, AdminAction.APPROVE_CONTENT, AdminAction.MODERATE_REVIEWS, AdminAction.VERIFY_PROVIDERS, } # MODERATOR can do within their scope MODERATOR_SCOPE_ACTIONS: Set[str] = { AdminAction.VIEW_DATA, AdminAction.VIEW_SUBSCRIPTION, AdminAction.APPROVE_CONTENT, AdminAction.MODERATE_REVIEWS, AdminAction.VERIFY_PROVIDERS, AdminAction.SUSPEND_USERS, } # SALES_REP can do within their scope SALES_REP_SCOPE_ACTIONS: Set[str] = { AdminAction.VIEW_DATA, AdminAction.VIEW_SUBSCRIPTION, AdminAction.EDIT_SUBSCRIPTION, AdminAction.VIEW_FINANCIALS, AdminAction.EXPORT_DATA, } # SERVICE_MGR can do within their scope SERVICE_MGR_SCOPE_ACTIONS: Set[str] = { AdminAction.VIEW_DATA, AdminAction.EDIT_DATA, AdminAction.APPROVE_CONTENT, AdminAction.VERIFY_PROVIDERS, AdminAction.MODERATE_REVIEWS, } # ────────────────────────────────────────────── # RBAC SERVICE # ────────────────────────────────────────────── class RBACService: """ Scope-Based Admin RBAC Service. Provides methods to check whether a staff user has access to perform a specific action on a target organization, based on the user's role and assigned scope. """ # Mapping of role → set of permitted actions ROLE_ACTIONS: Dict[UserRole, Set[str]] = { UserRole.SUPERADMIN: set(AdminAction), # All actions UserRole.ADMIN: ADMIN_SCOPE_ACTIONS, UserRole.MODERATOR: MODERATOR_SCOPE_ACTIONS, UserRole.SALES_REP: SALES_REP_SCOPE_ACTIONS, UserRole.SERVICE_MGR: SERVICE_MGR_SCOPE_ACTIONS, } async def check_admin_access( self, db: AsyncSession, user: User, action: Union[str, AdminAction], target_org_id: Optional[int] = None, ) -> bool: """ Check if a user has scope-based access to perform an action. Args: db: Database session user: The staff user to check action: The action being attempted (AdminAction enum or string) target_org_id: Optional target organization ID for scope matching Returns: True if access is granted Raises: PermissionError: If access is denied (with details) """ action_str = action.value if isinstance(action, AdminAction) else action # ── 1. SUPERADMIN bypass ── if user.role == UserRole.SUPERADMIN: return True # ── 2. Check if the role is permitted for this action ── permitted_actions = self.ROLE_ACTIONS.get(user.role, set()) if action_str not in permitted_actions: raise PermissionError( f"Action '{action_str}' is not permitted for role '{user.role.value}'." ) # ── 3. If no target_org_id, just check action permission ── if target_org_id is None: return True # ── 4. Scope-based organization access check ── return await self._check_org_scope_access(db, user, target_org_id) async def _check_org_scope_access( self, db: AsyncSession, user: User, target_org_id: int, ) -> bool: """ Check if the user's scope covers the target organization. The user's scope is defined by: - scope_type: "REGION", "SEGMENT", or "GLOBAL" - scope_value: The specific value (e.g., "Pest_County", "Fleet") The organization's scope attributes: - region: Geographic region (e.g., "Pest_County") - segment: Business segment (e.g., "Fleet") """ # Get user scope info scope_type = getattr(user, 'scope_type', None) scope_value = getattr(user, 'scope_value', None) # GLOBAL scope → access to all organizations if scope_type == ScopeType.GLOBAL.value: return True # No scope assigned → deny if not scope_type or not scope_value: raise PermissionError( f"User {user.id} has no scope assigned. " f"Contact a SUPERADMIN to assign a scope." ) # Fetch the target organization stmt = select(Organization).where(Organization.id == target_org_id) result = await db.execute(stmt) org = result.scalar_one_or_none() if not org: raise PermissionError(f"Organization {target_org_id} not found.") # ── Scope matching logic ── if scope_type == ScopeType.REGION.value: # Check if org's region matches user's scope_value org_region = getattr(org, 'region', None) if not org_region: raise PermissionError( f"Organization {target_org_id} has no region assigned. " f"Cannot match against REGION scope '{scope_value}'." ) if org_region != scope_value: raise PermissionError( f"REGION scope mismatch: user scope='{scope_value}', " f"org region='{org_region}'." ) return True elif scope_type == ScopeType.SEGMENT.value: # Check if org's segment matches user's scope_value org_segment = getattr(org, 'segment', None) if not org_segment: raise PermissionError( f"Organization {target_org_id} has no segment assigned. " f"Cannot match against SEGMENT scope '{scope_value}'." ) if org_segment != scope_value: raise PermissionError( f"SEGMENT scope mismatch: user scope='{scope_value}', " f"org segment='{org_segment}'." ) return True else: raise PermissionError(f"Unknown scope type: '{scope_type}'.") async def get_accessible_org_ids( self, db: AsyncSession, user: User, ) -> Optional[List[int]]: """ Get list of organization IDs accessible by this user based on their scope. Returns None for SUPERADMIN/GLOBAL scope (unrestricted). Returns a list of matching org IDs for REGION/SEGMENT scope. """ # SUPERADMIN and GLOBAL scope → unrestricted if user.role == UserRole.SUPERADMIN: return None scope_type = getattr(user, 'scope_type', None) scope_value = getattr(user, 'scope_value', None) if not scope_type or not scope_value: return [] # No scope → no orgs if scope_type == ScopeType.GLOBAL.value: return None # Build query based on scope type if scope_type == ScopeType.REGION.value: stmt = select(Organization.id).where( Organization.region == scope_value, Organization.is_deleted == False ) elif scope_type == ScopeType.SEGMENT.value: stmt = select(Organization.id).where( Organization.segment == scope_value, Organization.is_deleted == False ) else: return [] result = await db.execute(stmt) return [row[0] for row in result.all()] def get_permitted_actions(self, role: UserRole) -> Set[str]: """Get the set of actions permitted for a given role.""" return self.ROLE_ACTIONS.get(role, set()) def is_action_permitted(self, role: UserRole, action: Union[str, AdminAction]) -> bool: """Check if a specific action is permitted for a given role.""" action_str = action.value if isinstance(action, AdminAction) else action return action_str in self.ROLE_ACTIONS.get(role, set()) # ────────────────────────────────────────────── # Singleton instance # ────────────────────────────────────────────── rbac_service = RBACService()