# /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.models.system.rbac import SystemRolePermission, SystemPermission logger = logging.getLogger(__name__) class ScopeType(str, Enum): """The type of scope assigned to an admin user.""" GLOBAL = "GLOBAL" REGION = "REGION" SEGMENT = "SEGMENT" # ────────────────────────────────────────────── # 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. P0 Phase 6: All permission checks are now DB-driven via system.role_permissions. The legacy AdminAction enum has been removed. Use get_role_permissions() for all role lookups. """ async def check_admin_access( self, db: AsyncSession, user: User, action: str, 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 (string code) target_org_id: Optional target organization ID for scope matching Returns: True if access is granted Raises: PermissionError: If access is denied (with details) """ # ── 1. SUPERADMIN bypass ── if user.role == UserRole.SUPERADMIN: return True # ── 2. Check if the role is permitted for this action ── permitted_actions = await self.get_permitted_actions(db, user.role) if action not in permitted_actions: raise PermissionError( f"Action '{action}' 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()] async def get_permitted_actions( self, db: AsyncSession, role: UserRole, ) -> Set[str]: """ Get the set of permission codes permitted for a given role. P0 Phase 6: Fully DB-driven via system.role_permissions. The role name is used to look up the system.roles.id, then all granted permission codes are returned. Args: db: Database session role: The UserRole enum value to look up Returns: Set of permission code strings (e.g., {"fleet:view", "user:create"}) """ # Look up the system.roles.id by name stmt = select(SystemRolePermission).limit(0) # just to get the table # Use raw SQL for the join to avoid circular import issues from sqlalchemy import text result = await db.execute( text(""" SELECT p.code FROM system.role_permissions rp JOIN system.permissions p ON p.id = rp.permission_id JOIN system.roles r ON r.id = rp.role_id WHERE r.name = :role_name AND rp.granted = TRUE """), {"role_name": role.value}, ) return {row[0] for row in result.all()} async def is_action_permitted( self, db: AsyncSession, role: UserRole, action: str, ) -> bool: """Check if a specific action is permitted for a given role.""" permitted = await self.get_permitted_actions(db, role) return action in permitted # ────────────────────────────────────────────── # RBAC Phase 2: DB-Driven Permission Code Lookup # ────────────────────────────────────────────── async def get_role_permissions( self, db: AsyncSession, role_id: int, ) -> Set[str]: """ Query the system.role_permissions table joined with system.permissions to retrieve all granted permission codes for a given role_id. Returns a set of permission code strings (e.g., {"fleet:view", "user:create"}). Only permissions where granted == True are included. Args: db: Database session role_id: The system.roles.id to look up Returns: Set of permission code strings """ stmt = ( select(SystemPermission.code) .join( SystemRolePermission, SystemRolePermission.permission_id == SystemPermission.id, ) .where( SystemRolePermission.role_id == role_id, SystemRolePermission.granted == True, ) ) result = await db.execute(stmt) return {row[0] for row in result.all()} def invalidate_role_cache(self, role_id: int) -> None: """ Placeholder for cache invalidation when Redis is available. Currently a no-op; structured so caching can be dropped in easily. Args: role_id: The role ID whose cache should be invalidated """ logger.debug(f"Cache invalidation requested for role_id={role_id} (no-op, Redis not configured)") pass # ────────────────────────────────────────────── # Singleton instance # ────────────────────────────────────────────── rbac_service = RBACService()