Files
service-finder/backend/app/services/rbac_service.py

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()