Ajolote y roles
Ajolote es el servicio de autenticación/sesión + roles de Aura. Los paquetes
(@cuatro-quinas/ajolote-*) son ESM-only; como el backend compila a CommonJS, Aura los
carga con import() dinámico y usa guards propios (SessionGuard, AppRoleGuard).
Modelo de roles
role (global) | role_tenant (por-tenant) | |
|---|---|---|
| Valores | superadmin | admin | user | slug libre del tenant (ej. supervisor) |
| Alcance | todo el ecosistema Ajolote | solo dentro del tenant aura |
| Para qué | operaciones del SSO | roles de negocio de Aura |
Aura usa role_tenant (ej. supervisor) para la mayoría del gating. superadmin global
bypassa cualquier chequeo de role_tenant. Los roles viven en Ajolote; el backend los hace
cumplir; el front solo los lee para mostrar/ocultar UI.
El catálogo del tenant aura (fuente única: Aura-reloaded/src/auth/roles.catalog.ts, espejado en
aura-front/src/lib/roles.ts) tiene hoy dos roles:
| Rol | Qué ve |
|---|---|
supervisor | Todo lo del agente + Analíticas, Operación y Logs |
agente | Llamadas (reclamar), Monitor e Historial |
agente no gatea nada: es el piso: todo lo que no lleva roles? en la nav ya lo ve cualquier
usuario autenticado. Existe para que el rol sea explícito y aparezca como tal en el badge del
header. Un user global sin role_tenant se comporta —y se muestra— igual que un agente.
Las dos dimensiones son independientes: un mismo usuario puede ser
superadminglobal ysupervisordel tenant. Para features solo-superadmin (ej. editar el prompt del agente) se gatea por el rol global, no por el de tenant.
Flujo de sesión
WebSocket del copiloto: los browsers no pueden setear headers
Authorizationen WS, así que la conexión usa un ticket UUID de un solo uso (TTL 30s); el JWT nunca va en la URL.
Bearer y no cookie: la cookie la setea ajolote en su dominio; el browser nunca la
manda al backend de Aura (dominio distinto). Por eso toda llamada autenticada usa
fetchAuthed (Bearer), que sí cruza dominios.
Gating en el frontend
useRole()(src/lib/use-role.ts) →{ role, globalRole, hasRole, hasAnyRole }. LeeroleTenant(rol de tenant) yrole(rol global);superadminglobal pasa siempre.- Nav (
app-nav.tsx): cada item puede tenerroles?: string[](rol de tenant) osuperadminOnly?: boolean(rol global superadmin). Un no-supervisor no ve Analíticas / Operación / Logs; solo un superadmin ve el item Prompt. - Rutas:
<RequireRole roles={[...]}>para tenant;<RequireSuperadmin>para superadmin. Oculta/redirige la vista si no cumple. - Catálogo (
src/lib/roles.ts,AURA_ROLES): espejo de los keys de Ajolote. - Label del rol (
call-header.tsx+roleLabel()ensrc/lib/roles.ts): muestrasuperadminglobal si lo tenés; si no, el rol de tenant. Unuserglobal sinrole_tenantse muestra como Agente, no como "User".
El front solo oculta UI. La autoridad real es el backend.
Gating en el backend
@RequireAppRole({ any: [...] }) combina SessionGuard + AppRoleGuard.
@Get('operational')
@RequireAppRole({ any: ['supervisor'] })
getOperational(...) { ... }
superadminglobal pasa siempre; el resto se valida contrarole_tenant.- Sin el rol → 403. Solo autenticación (sin rol) →
@UseGuards(SessionGuard).
AppRoleGuard resuelve los roles en vivo contra ajolote (HttpAppRoleSource →
GET /api/me/app-roles, cacheado 30s) usando el Bearer del request. Consecuencias:
- Si le pegás al endpoint directo en el browser (sin
Authorization: Bearer) → "App roles [ninguno]" → 403. Probá siempre desde la app. - Puede haber desajuste con el claim del JWT: el front muestra un rol (del claim) pero el endpoint de ajolote devuelve otra cosa. Si pasa, verificá que el rol esté creado en el catálogo del tenant y asignado al usuario (abajo).
Endpoints gateados a supervisor hoy: GET /metrics/operational, GET /feedback/stats,
GET /audit/logs, PATCH /feedback/:id + DELETE /feedback/:id (editar/eliminar una
marcación desde el modal de /logs) y DELETE /audio-fork/sessions/:id/review (reabrir una
llamada ya revisada). POST /feedback NO, ni POST /audio-fork/sessions/:id/review —
los agentes normales deben poder marcar sugerencias y dar por revisada una llamada (solo
autenticación); editar/borrar marcaciones y reabrir revisiones, en cambio, es supervisión y sí
requiere rol.
Gating por rol global (@RequireGlobalRole)
Para endpoints que deben restringirse a un rol global explícito (no de tenant), está
@RequireGlobalRole('superadmin') (src/auth/global-role.guard.ts): combina SessionGuard +
GlobalRoleGuard, que chequea req.user.role (del JWT) contra los roles requeridos.
@Patch(':key')
@RequireGlobalRole('superadmin')
save(...) { ... }
- A diferencia de
AppRoleGuard(dondesuperadmines solo un bypass implícito), acá el rol global se exige de forma explícita. Se lee del claim del JWT, sin ir a ajolote. - Endpoints gateados a
superadminhoy:/anfibios/prompts/*(ver Prompts del agente) y/anfibios/settings/*(ver Ajustes runtime).
Sembrar el catálogo de roles (Ajolote)
El catálogo de roles de tenant (role_tenant) vive en una fuente única en el backend:
Aura-reloaded/src/auth/roles.catalog.ts (espejo de AURA_ROLES del front). Para
sembrarlo en Ajolote hay un script idempotente (un 409 = ya existe → skip):
# En Aura-reloaded, con AUTH_SERVICE_URL / AUTH_APP_ID en el .env, más:
# AJOLOTE_SUPERADMIN_TOKEN=<jwt> (o AJOLOTE_SUPERADMIN_EMAIL + AJOLOTE_SUPERADMIN_PASSWORD)
pnpm seed:ajolote-roles
El script hace POST /api/admin/roles con X-App-Id: aura por cada rol del catálogo.
Para agregar un rol: sumalo a roles.catalog.ts, reflejalo en AURA_ROLES del front y
volvé a correr el seed.
Asignar el rol supervisor (Ajolote)
Se administra contra ajolote con un JWT de superadmin (no desde Aura):
$AUTH = "http://100.64.188.115:3010"
# 1. Crear el rol en el catálogo del tenant (una vez) — o usar `pnpm seed:ajolote-roles`
POST $AUTH/api/admin/roles Body: {"key":"supervisor","label":"Supervisor"}
# 2. Asignarlo a un usuario
PATCH $AUTH/api/admin/users/USER_ID/role-tenant Body: {"roleKey":"supervisor"}
# 3. Verificar
GET $AUTH/api/admin/users/USER_ID/role-tenant?appId=aura
- Backend: al resolver en vivo (cache 30s), toma el rol en ≤30s sin redeploy.
- Frontend: la nav lee el claim del JWT → necesita re-login para verlo.
Notas de sesión
El botón Salir hace signOut() y un full reload (window.location.href = '/').
Sin el reload, el token en memoria del refresher, la caché de React Query y el WebSocket
sobreviven → al re-loguear con otro usuario, fetchAuthed sigue mandando el JWT viejo
(otro usuario, role_tenant: null) → 403. Si ves un token de una sesión anterior, es esto:
logout limpio + re-login.
AUTH_SERVICE_URL == ajolote del frontEl AUTH_SERVICE_URL del backend y el ajolote al que apunta el front tienen que ser el
mismo, o los roles no matchean.