Sistema de Permisos — Detalle Técnico
Modelo de Datos
Tablas
| Tabla | Descripción | PK |
|---|---|---|
Permissions | Catálogo de 42 permisos nombrados | Id (INT, identity) |
RolePermissions | Asociación rol → permiso | (RoleId, PermissionId) |
UserPermissionOverrides | Override por usuario (grant/revoke) | (UserId, PermissionId) |
Campos de Permissions
Id INT IDENTITY(1,1) PK
Name NVARCHAR(100) UNIQUE -- ej: "page:users"
DisplayLabel NVARCHAR(255) NULL -- ej: "Usuarios"
Category NVARCHAR(50) -- "page" | "api"
Description NVARCHAR(500) NULL
CreatedAt DATETIME DEFAULT GETDATE()
Campos de UserPermissionOverrides
UserId NVARCHAR(128) FK → AspNetUsers.Id
PermissionId INT FK → Permissions.Id
Type NVARCHAR(10) CHECK IN ('grant', 'revoke')
GrantedBy NVARCHAR(255) -- username del admin que lo configuró
GrantedAt DATETIME DEFAULT GETDATE()
Algoritmo de Resolución
Implementado en src/lib/permissions/resolve.ts:
function resolveEffectivePermissions(
rolePermissions: string[],
userOverrides: UserOverride[]
): string[] {
const effective = new Set<string>(rolePermissions)
for (const override of userOverrides) {
if (override.type === 'grant') effective.add(override.permissionName)
else if (override.type === 'revoke') effective.delete(override.permissionName)
}
return Array.from(effective).sort()
}
Propiedades formales:
- Si existe un override
grantpara un permiso, siempre aparece en el resultado - Si existe un override
revokepara un permiso, nunca aparece en el resultado - El resultado es determinístico (sorted)
Enforcement por Capa
Edge Proxy (src/proxy.ts)
// Lee permissions del JWT (sin DB calls)
const userPermissions: string[] = session.user.permissions ?? []
for (const [routePrefix, requiredPermission] of Object.entries(ROUTE_PERMISSIONS)) {
if (pathname.startsWith(routePrefix)) {
if (!userPermissions.includes(requiredPermission)) {
return NextResponse.redirect(new URL('/dashboard', req.url))
}
}
}
API Routes
import { requirePermission } from '@/lib/permissions/check'
export async function GET() {
const result = await requirePermission('api:users:manage')
if (result instanceof NextResponse) return result // 403
const { session } = result
// ... lógica del endpoint
}
Sidebar (src/components/layout/sidebar.tsx)
Cada item de navegación tiene un campo permission: string. El sidebar filtra items basándose en session.user.permissions.includes(item.permission).
Cache y JWT
- Los permisos se resuelven una vez al login y se incrustan en el JWT como
string[] updateAge: 0refresca el cookie en cada request (para el timer de inactividad), pero NO re-resuelve permisos- Cambios de permisos requieren que el usuario vuelva a iniciar sesión
- Sesiones sin
permissions(legacy) se invalidan automáticamente viaif (!Array.isArray(token.permissions)) return null
API Endpoints de Gestión
| Método | Ruta | Descripción | Protección |
|---|---|---|---|
| GET | /api/permissions | Listar todos agrupados por categoría | page:roles |
| GET | /api/permissions/roles | Matriz rol-permisos (IDs) | page:roles |
| PUT | /api/permissions/roles | Actualizar permisos de un rol | page:roles |
| GET | /api/permissions/users/[id] | Permisos efectivos con fuente | page:roles |
| PUT | /api/permissions/users/[id]/overrides | Guardar overrides de usuario | page:roles |
Mapa de Rutas
Definido en src/lib/permissions/route-map.ts:
export const ROUTE_PERMISSIONS: Record<string, string> = {
'/users': 'page:users',
'/customers': 'page:customers',
'/asignaciones/campos': 'page:asignaciones:campos',
'/asignaciones/plantillas': 'page:asignaciones:plantillas',
'/asignaciones/importar': 'page:asignaciones:importar',
'/accounts/import': 'page:accounts:import',
'/integrantes': 'page:integrantes',
'/gestiones/import': 'page:gestiones:import',
'/gestiones/export': 'page:gestiones:export',
'/gestiones/promesas': 'page:gestiones:promesas',
'/gestiones/pagos': 'page:gestiones:pagos',
'/gestiones/carga-pagos': 'page:gestiones:carga-pagos',
'/admin/goals': 'page:admin:goals',
'/actions': 'page:actions',
'/results': 'page:results',
'/roles': 'page:roles',
}
Seed Data
Script: prisma/migrations/seed-permissions-data.sql
Ejecutar después de add-permissions-tables.sql. Usa MERGE para permisos e INSERT ... WHERE NOT EXISTS para las asociaciones, siendo idempotente.
Archivos Clave
| Archivo | Descripción |
|---|---|
src/lib/permissions/resolve.ts | Función pura de resolución |
src/lib/permissions/check.ts | requirePermission() y hasPermission() |
src/lib/permissions/route-map.ts | Mapa ruta → permiso |
src/lib/permissions/sidebar-filter.ts | Filtro de navegación |
src/lib/auth.config.ts | Tipos de sesión con permissions: string[] |
src/lib/auth.ts | Resolución de permisos al login |
src/proxy.ts | Enforcement en edge middleware |
prisma/migrations/add-permissions-tables.sql | Schema SQL |
prisma/migrations/seed-permissions-data.sql | Seed de datos iniciales |