Saltar al contenido principal

Sistema de Permisos — Detalle Técnico

Modelo de Datos

Tablas

TablaDescripciónPK
PermissionsCatálogo de 42 permisos nombradosId (INT, identity)
RolePermissionsAsociación rol → permiso(RoleId, PermissionId)
UserPermissionOverridesOverride 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:

  1. Si existe un override grant para un permiso, siempre aparece en el resultado
  2. Si existe un override revoke para un permiso, nunca aparece en el resultado
  3. 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
}

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: 0 refresca 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 via if (!Array.isArray(token.permissions)) return null

API Endpoints de Gestión

MétodoRutaDescripciónProtección
GET/api/permissionsListar todos agrupados por categoríapage:roles
GET/api/permissions/rolesMatriz rol-permisos (IDs)page:roles
PUT/api/permissions/rolesActualizar permisos de un rolpage:roles
GET/api/permissions/users/[id]Permisos efectivos con fuentepage:roles
PUT/api/permissions/users/[id]/overridesGuardar overrides de usuariopage: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

ArchivoDescripción
src/lib/permissions/resolve.tsFunción pura de resolución
src/lib/permissions/check.tsrequirePermission() y hasPermission()
src/lib/permissions/route-map.tsMapa ruta → permiso
src/lib/permissions/sidebar-filter.tsFiltro de navegación
src/lib/auth.config.tsTipos de sesión con permissions: string[]
src/lib/auth.tsResolución de permisos al login
src/proxy.tsEnforcement en edge middleware
prisma/migrations/add-permissions-tables.sqlSchema SQL
prisma/migrations/seed-permissions-data.sqlSeed de datos iniciales