Files
plantillas-proyectos/backend/api/v1/modules/core/permissions
Kevin_Ramirez bdd089954b
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 3s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/plantillas-proyectos/pipeline/head There was a failure building this commit
feat: plantilla base workspace SaaS
2026-07-21 13:59:00 -05:00
..
2026-07-21 13:59:00 -05:00
2026-07-21 13:59:00 -05:00
2026-07-21 13:59:00 -05:00
2026-07-21 13:59:00 -05:00
2026-07-21 13:59:00 -05:00
2026-07-21 13:59:00 -05:00

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 cliente
  • RolePermission - Relación roles-permisos
  • UserClientRole - Asignación usuario-rol-cliente
  • UserClientPermission - Permisos directos por usuario

service.py

Contiene la clase PermissionService con métodos:

  • get_user_permissions() - Obtiene todos los permisos de un usuario
  • has_permission() - Verifica un permiso específico
  • has_all_permissions() - Verifica múltiples permisos (AND)
  • has_any_permission() - Verifica múltiples permisos (OR)
  • assign_role_to_user() - Asigna roles a usuarios
  • grant_direct_permission() - Concede permisos directos

dependencies.py

Dependencias para proteger rutas:

  • PermissionChecker - Clase para verificar múltiples permisos
  • RequirePermission - Clase para verificar un solo permiso
  • get_client_id() - Extrae el ID del cliente del header
  • get_permission_service() - Proporciona instancia del servicio
  • get_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 actual
  • GET /permissions/available - Lista todos los permisos
  • GET /permissions/roles - Lista roles del cliente
  • POST /permissions/roles - Crea un rol
  • POST /permissions/assign-role - Asigna rol a usuario
  • POST /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

  1. Usuario hace request con token JWT de Keycloak
  2. Header X-Client-ID indica el cliente/tenant
  3. Sistema extrae user_id del token
  4. Consulta permisos del usuario en ese cliente
  5. Valida si tiene el permiso requerido
  6. 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 sub del 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")