Skip to content

Modelo de Control de Acceso

NappAI Fluency usa un sistema de dos dimensiones para controlar el acceso:

  1. UserRole — qué puede hacer el usuario en la plataforma (permisos funcionales)
  2. AccessLevel — qué nivel de contenido puede ver (permisos de contenido)
RoleDescripciónPanel disponible
FREEUsuario registrado sin suscripción/dashboard
STARTERSuscripción básica/dashboard
PROSuscripción profesional/dashboard
ELITESuscripción élite/dashboard
CORPORATE_USERUsuario de una organización/dashboard + features corp.
CORPORATE_ADMINAdministrador de organización/corporate/*
CONTENT_ADMINInstructor aprobado — crea y edita contenido/creator/*
SUPER_ADMINSuper administrador — gestiona plataforma/admin/* + /creator/*

El rol CONTENT_ADMIN se asigna únicamente en InstructorApplicationsService.approve(), llamado desde PATCH /admin/applications/:id/approve (solo SUPER_ADMIN). No existe ningún endpoint público que eleve roles. Ver ADR-012.

Niveles de acceso a contenido (AccessLevel)

Section titled “Niveles de acceso a contenido (AccessLevel)”
AccessLevelQuién puede verUso típico
PUBLICCualquiera (sin auth)Páginas de marketing, vista previa
FREE_REGISTEREDCualquier usuario logueadoCursos de introducción
PAID_STARTERSTARTER, PRO, ELITE, CORPORATE_*Cursos básicos de pago
PAID_PROPRO, ELITE, CORPORATE_*Cursos avanzados
PAID_ELITEELITE, CORPORATE_*Contenido premium
CORPORATECORPORATE_USER, CORPORATE_ADMINContenido exclusivo corporativo

Internamente, cada UserRole tiene un valor numérico para comparación:

packages/shared-types/src/access.types.ts
export const ACCESS_HIERARCHY: Record<UserRole, number> = {
FREE: 0,
STARTER: 1,
PRO: 2,
ELITE: 3,
CORPORATE_USER: 2, // Equivale a PRO en acceso de contenido
CORPORATE_ADMIN: 3, // Equivale a ELITE
CONTENT_ADMIN: 10, // Admin ve todo el contenido
SUPER_ADMIN: 10,
}
export const CONTENT_LEVEL_REQUIREMENTS: Record<AccessLevel, number> = {
PUBLIC: 0,
FREE_REGISTERED: 0, // Solo requiere estar logueado
PAID_STARTER: 1,
PAID_PRO: 2,
PAID_ELITE: 3,
CORPORATE: 2, // Requiere ser CORPORATE_USER o superior
}

Ubicación: apps/api/src/common/guards/access-level.guard.ts

@Injectable()
export class AccessLevelGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<AccessLevel>(
REQUIRED_ACCESS_LEVEL_KEY,
[context.getHandler(), context.getClass()],
);
if (!required || required === AccessLevel.PUBLIC) return true;
const user = context.switchToHttp().getRequest().user;
if (!user) throw new ForbiddenException({ code: 'ACCESS_DENIED', requiredLevel: required });
const requiredNumeric = CONTENT_LEVEL_REQUIREMENTS[required];
const userNumeric = ACCESS_HIERARCHY[user.role as UserRole] ?? 0;
if (userNumeric < requiredNumeric) {
throw new ForbiddenException({
code: 'INSUFFICIENT_ACCESS_LEVEL',
requiredLevel: required,
userLevel: user.role,
});
}
return true;
}
}
@Get(':id')
@RequireAccessLevel(AccessLevel.PAID_PRO)
@UseGuards(JwtAuthGuard, AccessLevelGuard)
async getLesson(@Param('id') id: string, @CurrentUser() user: JwtUser) {
return this.coursesService.getLesson(id, user.id);
}
Path.accessLevel → Module.accessLevel → Lesson.accessLevel
  • Un módulo puede ser más restrictivo que su path (para contenido bonus)
  • Una lección puede ser más abierta que su módulo (para previews)
  • El guard evalúa el nivel de la lección específica que se solicita

Las rutas con organizationId son privadas a esa organización. El OrgTenantMiddleware inyecta request.organizationId en todas las requests de usuarios con role CORPORATE_*.

// CORRECTO
this.prisma.path.findMany({ where: { organizationId: request.organizationId } })
// INCORRECTO — expone datos de otras organizaciones
this.prisma.path.findMany({ where: { isPrivate: true } })