Skip to main content

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)
Valoressuperadmin | admin | userslug libre del tenant (ej. supervisor)
Alcancetodo el ecosistema Ajolotesolo dentro del tenant aura
Para quéoperaciones del SSOroles 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:

RolQué ve
supervisorTodo lo del agente + Analíticas, Operación y Logs
agenteLlamadas (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 superadmin global y supervisor del 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 Authorization en 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 }. Lee roleTenant (rol de tenant) y role (rol global); superadmin global pasa siempre.
  • Nav (app-nav.tsx): cada item puede tener roles?: string[] (rol de tenant) o superadminOnly?: 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() en src/lib/roles.ts): muestra superadmin global si lo tenés; si no, el rol de tenant. Un user global sin role_tenant se 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(...) { ... }
  • superadmin global pasa siempre; el resto se valida contra role_tenant.
  • Sin el rol → 403. Solo autenticación (sin rol) → @UseGuards(SessionGuard).
El backend NO lee el rol del JWT

AppRoleGuard resuelve los roles en vivo contra ajolote (HttpAppRoleSourceGET /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 (donde superadmin es 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 superadmin hoy: /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

Logout debe recargar

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 front

El AUTH_SERVICE_URL del backend y el ajolote al que apunta el front tienen que ser el mismo, o los roles no matchean.