Files
service-finder/backend/app/api/v1/endpoints/admin_permissions.py

497 lines
16 KiB
Python

# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_permissions.py
"""
🎯 RBAC Permission Matrix & Override Endpoints (P0)
Provides admin-facing endpoints for:
- GET /admin/permissions/matrix — Returns the full permission matrix
showing which roles can perform which actions.
- PATCH /admin/permissions/override/{org_id} — Updates custom permissions
for a specific organization (scope-based override).
- GET /admin/permissions/roles — Returns all SystemRole records.
- GET /admin/permissions — Returns all SystemPermission records.
- PUT /admin/permissions/roles/{role_id}/permissions — Updates the granted
permissions for a role (bulk toggle).
All endpoints are protected by Depends(RequirePermission(...)) so only
authorized staff with the appropriate DB-driven permissions can access them.
P0 Phase 6: Removed AdminAction enum dependency. All permission lookups
are now fully DB-driven via system.role_permissions.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from app.api import deps
from app.api.deps import get_db, get_current_user
from app.models.identity import User, UserRole
from app.models.marketplace.organization import Organization
from app.models.system.rbac import SystemRole, SystemPermission, SystemRolePermission
from app.services.rbac_service import (
rbac_service,
ScopeType,
)
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter()
# ──────────────────────────────────────────────
# Pydantic Schemas
# ──────────────────────────────────────────────
class PermissionMatrixEntry(BaseModel):
"""A single entry in the permission matrix."""
role: str
actions: List[str]
class PermissionMatrixResponse(BaseModel):
"""Full permission matrix response."""
matrix: List[PermissionMatrixEntry]
all_actions: List[str]
class PermissionOverrideRequest(BaseModel):
"""Request body for overriding organization permissions."""
action: str = Field(..., description="The action to override (e.g., EDIT_DATA)")
granted: bool = Field(..., description="Whether to grant or revoke this action")
class PermissionOverrideResponse(BaseModel):
"""Response after updating custom permissions."""
status: str
org_id: int
action: str
granted: bool
message: str
# ── RBAC Phase 4: DB-Driven Role & Permission Schemas ──
class SystemRoleOut(BaseModel):
"""Output schema for a SystemRole."""
id: int
name: str
description: Optional[str] = None
rank: int
is_system: bool
is_active: bool
permission_ids: List[int] = []
class Config:
from_attributes = True
class SystemPermissionOut(BaseModel):
"""Output schema for a SystemPermission."""
id: int
code: str
domain: str
action: str
description: Optional[str] = None
is_system: bool
class Config:
from_attributes = True
class RolePermissionsUpdateRequest(BaseModel):
"""Request body for updating a role's granted permissions."""
permission_ids: List[int] = Field(
...,
description="List of permission IDs that should be granted to this role",
)
class RolePermissionsUpdateResponse(BaseModel):
"""Response after updating role permissions."""
status: str
role_id: int
role_name: str
granted_permission_ids: List[int]
message: str
# ──────────────────────────────────────────────
# Endpoints
# ──────────────────────────────────────────────
@router.get(
"/admin/permissions/matrix",
response_model=PermissionMatrixResponse,
tags=["Admin Permissions"],
summary="Get the full RBAC permission matrix",
)
async def get_permission_matrix(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(deps.RequirePermission("permissions:view")),
):
"""
🔐 Returns the complete RBAC permission matrix.
Shows which roles are permitted to perform which admin actions.
Protected by RequirePermission("permissions:view").
P0 Phase 6: Fully DB-driven. All permission codes are fetched
from system.permissions and role mappings from system.role_permissions.
Returns:
- matrix: List of {role, actions[]} entries
- all_actions: Complete list of all possible permission codes
"""
# Fetch all permission codes from the DB
all_perms_result = await db.execute(
select(SystemPermission.code).order_by(SystemPermission.code)
)
all_actions = [row[0] for row in all_perms_result.all()]
# Fetch role permissions for each role
matrix = []
for role in [UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.MODERATOR,
UserRole.SALES_REP, UserRole.SERVICE_MGR]:
actions = await rbac_service.get_permitted_actions(db, role)
matrix.append(PermissionMatrixEntry(
role=role.value,
actions=sorted(actions),
))
return PermissionMatrixResponse(
matrix=matrix,
all_actions=sorted(all_actions),
)
@router.patch(
"/admin/permissions/override/{org_id}",
response_model=PermissionOverrideResponse,
tags=["Admin Permissions"],
summary="Override custom permissions for an organization",
)
async def override_org_permission(
org_id: int,
payload: PermissionOverrideRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(deps.RequirePermission("permissions:edit")),
):
"""
🔐 Updates custom permissions for a specific organization.
This endpoint allows SUPERADMIN and ADMIN to grant or revoke
specific actions for an organization, overriding the default
role-based permissions.
The RBACService.check_admin_access() is invoked to verify that
the requesting admin has scope-based access to the target
organization before applying the override.
P0 Phase 6: Action validation is now DB-driven via system.permissions.
Args:
org_id: The target organization ID.
payload.action: The action to override (e.g., "EDIT_DATA").
payload.granted: True to grant, False to revoke.
Returns:
Confirmation of the override operation.
"""
# 1. Validate that the action exists in the DB
perm_stmt = select(SystemPermission.code).where(
SystemPermission.code == payload.action
)
perm_result = await db.execute(perm_stmt)
if not perm_result.scalar_one_or_none():
# Fetch all valid codes for the error message
all_codes_result = await db.execute(
select(SystemPermission.code).order_by(SystemPermission.code)
)
valid_actions = [row[0] for row in all_codes_result.all()]
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid action '{payload.action}'. Valid actions: {sorted(valid_actions)}",
)
# 2. Verify scope-based access to the target organization
try:
await rbac_service.check_admin_access(
db=db,
user=current_user,
action="permissions:edit",
target_org_id=org_id,
)
except PermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
# 3. Fetch the organization
stmt = select(Organization).where(Organization.id == org_id)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Organization {org_id} not found.",
)
# 4. Update custom permissions via the settings JSONB field
if not isinstance(org.settings, dict):
org.settings = {}
if "permission_overrides" not in org.settings:
org.settings["permission_overrides"] = {}
org.settings["permission_overrides"][payload.action] = payload.granted
# 5. Persist
await db.commit()
await db.refresh(org)
logger.info(
f"Permission override applied: org={org_id}, "
f"action={payload.action}, granted={payload.granted}, "
f"by_user={current_user.id}"
)
return PermissionOverrideResponse(
status="success",
org_id=org_id,
action=payload.action,
granted=payload.granted,
message=f"Permission '{payload.action}' {'granted' if payload.granted else 'revoked'} for organization {org_id}.",
)
# ── RBAC Phase 4: DB-Driven Role & Permission Endpoints ──
@router.get(
"/admin/permissions/roles",
response_model=List[SystemRoleOut],
tags=["Admin Permissions"],
summary="Get all system roles with their granted permission IDs",
)
async def get_all_roles(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(deps.RequirePermission("permissions:view")),
):
"""
🔐 Returns all SystemRole records with their associated permission IDs.
Each role includes:
- id, name, description, rank, is_system, is_active
- permission_ids: List of permission IDs granted to this role
Protected by RequirePermission("permissions:view").
"""
stmt = (
select(SystemRole)
.options(selectinload(SystemRole.permissions))
.order_by(SystemRole.rank.desc())
)
result = await db.execute(stmt)
roles = result.unique().scalars().all()
role_list = []
for role in roles:
permission_ids = [
rp.permission_id for rp in role.permissions if rp.granted
]
role_list.append(SystemRoleOut(
id=role.id,
name=role.name,
description=role.description,
rank=role.rank,
is_system=role.is_system,
is_active=role.is_active,
permission_ids=permission_ids,
))
return role_list
@router.get(
"/admin/permissions",
response_model=List[SystemPermissionOut],
tags=["Admin Permissions"],
summary="Get all system permissions",
)
async def get_all_permissions(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(deps.RequirePermission("permissions:view")),
):
"""
🔐 Returns all SystemPermission records.
Each permission includes:
- id, code, domain, action, description, is_system
Protected by RequirePermission("permissions:view").
"""
stmt = select(SystemPermission).order_by(SystemPermission.domain, SystemPermission.action)
result = await db.execute(stmt)
permissions = result.scalars().all()
return [
SystemPermissionOut(
id=p.id,
code=p.code,
domain=p.domain,
action=p.action,
description=p.description,
is_system=p.is_system,
)
for p in permissions
]
@router.put(
"/admin/permissions/roles/{role_id}/permissions",
response_model=RolePermissionsUpdateResponse,
tags=["Admin Permissions"],
summary="Update granted permissions for a role",
)
async def update_role_permissions(
role_id: int,
payload: RolePermissionsUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(deps.RequirePermission("permissions:edit")),
):
"""
🔐 Updates the granted permissions for a specific role.
This endpoint performs a full replacement of the role's granted
permissions. The provided permission_ids list becomes the new set
of granted permissions. Any existing mappings not in the list
are removed.
SUPERADMIN roles (is_system=True, rank=100) are protected:
- Their permissions cannot be modified through this endpoint
to prevent accidental lockout.
Args:
role_id: The target role ID.
payload.permission_ids: The complete list of permission IDs to grant.
Returns:
Confirmation with the updated permission IDs.
"""
# 1. Fetch the role
stmt = select(SystemRole).where(SystemRole.id == role_id)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id={role_id} not found.",
)
# 2. Protect SUPERADMIN system roles
if role.is_system and role.rank >= 100:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify permissions for the SUPERADMIN system role. "
"This would risk locking administrators out of the system.",
)
# 3. Validate that all permission_ids exist
if payload.permission_ids:
perm_stmt = select(SystemPermission.id).where(
SystemPermission.id.in_(payload.permission_ids)
)
perm_result = await db.execute(perm_stmt)
existing_ids = {row[0] for row in perm_result.all()}
missing_ids = set(payload.permission_ids) - existing_ids
if missing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid permission IDs: {sorted(missing_ids)}. These permissions do not exist.",
)
# 4. Remove existing role-permission mappings
delete_stmt = delete(SystemRolePermission).where(
SystemRolePermission.role_id == role_id
)
await db.execute(delete_stmt)
# 5. Insert new mappings
for perm_id in payload.permission_ids:
db.add(SystemRolePermission(
role_id=role_id,
permission_id=perm_id,
granted=True,
))
await db.commit()
# 6. Invalidate cache
rbac_service.invalidate_role_cache(role_id)
logger.info(
f"Role permissions updated: role_id={role_id}, "
f"permissions={payload.permission_ids}, "
f"by_user={current_user.id}"
)
return RolePermissionsUpdateResponse(
status="success",
role_id=role_id,
role_name=role.name,
granted_permission_ids=payload.permission_ids,
message=f"Permissions updated for role '{role.name}' ({len(payload.permission_ids)} granted).",
)
# ──────────────────────────────────────────────
# RBAC Phase 2: Test Endpoint (Verification)
# ──────────────────────────────────────────────
@router.get(
"/admin/rbac-test",
tags=["Admin Permissions (RBAC)"],
summary="RBAC Phase 2 verification endpoint",
)
async def rbac_test_endpoint(
current_user: User = Depends(get_current_user),
_ = Depends(deps.RequirePermission("fleet:view")),
):
"""
🔐 RBAC Phase 2 verification endpoint.
Protected by Depends(RequirePermission("fleet:view")).
- SUPERADMIN: Always passes (bypass).
- Users with fleet:view permission: Passes.
- Users without fleet:view permission: 403 Forbidden.
Returns the current user info if permission is granted.
"""
return {
"status": "ok",
"message": "RBAC Phase 2 permission check passed.",
"user_id": current_user.id,
"user_role": current_user.role.value,
"role_id": current_user.role_id,
"permission_checked": "fleet:view",
}