317 lines
11 KiB
Python
317 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.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()
|