Modelo de Control de Acceso
NappAI Fluency usa un sistema de dos dimensiones para controlar el acceso:
- UserRole — qué puede hacer el usuario en la plataforma (permisos funcionales)
- AccessLevel — qué nivel de contenido puede ver (permisos de contenido)
Roles de usuario (UserRole)
Section titled “Roles de usuario (UserRole)”| Role | Descripción | Panel disponible |
|---|---|---|
FREE | Usuario registrado sin suscripción | /dashboard |
STARTER | Suscripción básica | /dashboard |
PRO | Suscripción profesional | /dashboard |
ELITE | Suscripción élite | /dashboard |
CORPORATE_USER | Usuario de una organización | /dashboard + features corp. |
CORPORATE_ADMIN | Administrador de organización | /corporate/* |
CONTENT_ADMIN | Instructor aprobado — crea y edita contenido | /creator/* |
SUPER_ADMIN | Super 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)”| AccessLevel | Quién puede ver | Uso típico |
|---|---|---|
PUBLIC | Cualquiera (sin auth) | Páginas de marketing, vista previa |
FREE_REGISTERED | Cualquier usuario logueado | Cursos de introducción |
PAID_STARTER | STARTER, PRO, ELITE, CORPORATE_* | Cursos básicos de pago |
PAID_PRO | PRO, ELITE, CORPORATE_* | Cursos avanzados |
PAID_ELITE | ELITE, CORPORATE_* | Contenido premium |
CORPORATE | CORPORATE_USER, CORPORATE_ADMIN | Contenido exclusivo corporativo |
Jerarquía numérica (ACCESS_HIERARCHY)
Section titled “Jerarquía numérica (ACCESS_HIERARCHY)”Internamente, cada UserRole tiene un valor numérico para comparación:
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}AccessLevelGuard
Section titled “AccessLevelGuard”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; }}Uso en controllers
Section titled “Uso en controllers”@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);}Herencia de AccessLevel en contenido
Section titled “Herencia de AccessLevel en contenido”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
Multi-tenancy corporativo
Section titled “Multi-tenancy corporativo”Las rutas con organizationId son privadas a esa organización. El OrgTenantMiddleware inyecta request.organizationId en todas las requests de usuarios con role CORPORATE_*.
// CORRECTOthis.prisma.path.findMany({ where: { organizationId: request.organizationId } })
// INCORRECTO — expone datos de otras organizacionesthis.prisma.path.findMany({ where: { isPrivate: true } })