admin felület fejlesztése garázs, előfizeztési csomagok
This commit is contained in:
321
backend/app/services/rbac_service.py
Normal file
321
backend/app/services/rbac_service.py
Normal file
@@ -0,0 +1,321 @@
|
||||
# /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()
|
||||
Reference in New Issue
Block a user