322 lines
11 KiB
Python
322 lines
11 KiB
Python
# /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()
|