Módulo de Permisos Multi-Tenant
Sistema completo de permisos granulares para aplicaciones multi-tenant con FastAPI y SQLAlchemy.
📁 Estructura del Módulo
backend/api/v1/modules/core/permissions/
├── __init__.py # Exports del módulo
├── models.py # Modelos SQLAlchemy
├── service.py # Lógica de negocio
├── dependencies.py # Dependencias FastAPI
├── schemas.py # Modelos Pydantic (request/response)
└── routes.py # Endpoints de la API
🎯 Componentes
models.py
Define los modelos de base de datos:
Permission- Permisos del sistema (ej: "invoice.view", "invoice.edit")ClientRole- Roles personalizados por clienteRolePermission- Relación roles-permisosUserClientRole- Asignación usuario-rol-clienteUserClientPermission- Permisos directos por usuario
service.py
Contiene la clase PermissionService con métodos:
get_user_permissions()- Obtiene todos los permisos de un usuariohas_permission()- Verifica un permiso específicohas_all_permissions()- Verifica múltiples permisos (AND)has_any_permission()- Verifica múltiples permisos (OR)assign_role_to_user()- Asigna roles a usuariosgrant_direct_permission()- Concede permisos directos
dependencies.py
Dependencias para proteger rutas:
PermissionChecker- Clase para verificar múltiples permisosRequirePermission- Clase para verificar un solo permisoget_client_id()- Extrae el ID del cliente del headerget_permission_service()- Proporciona instancia del servicioget_current_user_permissions()- Devuelve permisos del usuario
schemas.py
Modelos Pydantic para request/response:
- Responses:
PermissionResponse,ClientRoleResponse,UserPermissionsResponse, etc. - Requests:
AssignRoleRequest,GrantPermissionRequest,CreateRoleRequest, etc.
routes.py
Endpoints de la API:
GET /permissions/me- Permisos del usuario actualGET /permissions/available- Lista todos los permisosGET /permissions/roles- Lista roles del clientePOST /permissions/roles- Crea un rolPOST /permissions/assign-role- Asigna rol a usuarioPOST /permissions/grant-permission- Concede permiso directo- Ejemplos de rutas protegidas
🚀 Uso Rápido
Importar el módulo
from api.v1.modules.core.permissions import (
Permission,
ClientRole,
PermissionService,
PermissionChecker,
RequirePermission,
router
)
Registrar las rutas
# En backend/api/v1/router.py
from api.v1.modules.core.permissions import router as permissions_router
api_router = APIRouter()
api_router.include_router(permissions_router)
Proteger una ruta con permiso único
from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions import RequirePermission
router = APIRouter()
@router.get("/invoices")
async def list_invoices(
_: None = Depends(RequirePermission("invoice.view"))
):
return {"invoices": [...]}
Proteger con múltiples permisos
from api.v1.modules.core.permissions import PermissionChecker
@router.post("/invoices")
async def create_invoice(
_: None = Depends(PermissionChecker(
["invoice.view", "invoice.create"],
require_all=True # Requiere TODOS
))
):
return {"created": True}
Usar permisos en la lógica
from api.v1.modules.core.permissions import get_current_user_permissions
@router.get("/dashboard")
async def dashboard(
permissions: set = Depends(get_current_user_permissions)
):
widgets = []
if "invoice.view" in permissions:
widgets.append({"type": "invoices", "data": [...]})
return {"widgets": widgets}
📊 Base de Datos
Ejecutar migración
cd backend
alembic upgrade head
Esto crea las tablas y permisos iniciales:
- invoice.* - view, create, edit, delete, approve
- user.* - view, create, edit, delete
- report.* - financial.view, admin.view, export
- roles.* - view, create, edit, delete, assign
- permissions.* - view, grant
🔐 Flujo de Autenticación
- Usuario hace request con token JWT de Keycloak
- Header
X-Client-IDindica el cliente/tenant - Sistema extrae
user_iddel token - Consulta permisos del usuario en ese cliente
- Valida si tiene el permiso requerido
- Devuelve 200 OK o 403 Forbidden
💡 Ejemplos Prácticos
Crear un rol personalizado
from api.v1.modules.core.permissions import PermissionService
from core.database import get_db
db = next(get_db())
service = PermissionService(db)
# Crear rol
role = ClientRole(
client_id=1,
name="Contador",
code="accountant",
description="Acceso a módulo contable"
)
db.add(role)
db.commit()
Asignar permisos a un rol
from api.v1.modules.core.permissions.models import RolePermission
# Obtener permisos de facturación
invoice_perms = db.query(Permission).filter(
Permission.module == "invoice"
).all()
# Asignar al rol
for perm in invoice_perms:
role_perm = RolePermission(
client_role_id=role.id,
permission_id=perm.id
)
db.add(role_perm)
db.commit()
Asignar rol a usuario
service.assign_role_to_user(
user_id="user-uuid-from-keycloak",
client_id=1,
role_id=role.id,
assigned_by="admin-uuid"
)
Conceder permiso temporal
from datetime import datetime, timedelta
service.grant_direct_permission(
user_id="user-uuid",
client_id=1,
permission_code="invoice.delete",
assigned_by="admin-uuid",
expires_at=datetime.utcnow() + timedelta(days=7)
)
⚡ Optimización de Rendimiento
1. Caché con Redis
import redis
from functools import lru_cache
redis_client = redis.Redis(host='localhost', port=6379)
def get_cached_permissions(user_id: str, client_id: int) -> set:
cache_key = f"perms:{user_id}:{client_id}"
cached = redis_client.get(cache_key)
if cached:
return set(cached.decode().split(','))
# Consultar DB
service = PermissionService(db)
permissions = service.get_user_permissions(user_id, client_id)
# Cachear por 5 minutos
redis_client.setex(cache_key, 300, ','.join(permissions))
return permissions
2. Índices de Base de Datos
Ya están definidos en los modelos:
- Índices compuestos para consultas eficientes
- Índices únicos para prevenir duplicados
- Índices en foreign keys
3. Query Optimization
El servicio usa JOINs eficientes en lugar de N+1 queries.
🧪 Testing
import pytest
from api.v1.modules.core.permissions import PermissionService
from api.v1.modules.core.permissions.models import Permission, ClientRole
def test_user_has_permission_from_role(db_session):
# Setup
perm = Permission(code="invoice.view", module="invoice", action="view")
db_session.add(perm)
role = ClientRole(client_id=1, code="viewer", name="Viewer")
db_session.add(role)
db_session.commit()
# Test
service = PermissionService(db_session)
assert service.has_permission("user-123", 1, "invoice.view")
📝 Notas Importantes
- Client ID: Por defecto se obtiene del header
X-Client-ID, pero puede adaptarse a subdominios o JWT - User ID: Se extrae del campo
subdel token JWT de Keycloak - Permisos Directos: Pueden revocar permisos heredados de roles (
is_granted=False) - Soft Delete: Los roles y permisos se desactivan (
is_active=False) en lugar de eliminarse
🔗 Integración con Keycloak
Los roles globales de Keycloak pueden coexistir con los roles locales:
@router.get("/protected")
async def protected_route(
current_user: dict = Depends(get_current_user),
permissions: set = Depends(get_current_user_permissions)
):
# Verificar rol global de Keycloak
keycloak_roles = current_user.get("realm_access", {}).get("roles", [])
if "super_admin" in keycloak_roles:
# Super admin tiene acceso total
return {"access": "granted", "level": "global"}
# Verificar permisos a nivel de cliente
if "invoice.view" in permissions:
return {"access": "granted", "level": "client"}
raise HTTPException(403, "No access")