Saltar a contenido

La sesión y los permisos

El paso 5 del protocolo: la sesión dentro de cada pantalla.

La sesión desde un componente

Con el cliente puesto en la raíz (ver Montar la aplicación), cualquier componente tiene la sesión como estado: se vuelve a pintar cuando cambia.

import { useSession } from '@/lib/api/react'

const { user, loading, isAuthenticated, login, logout, changePassword, updateProfile, me, hasPermission, can } = useSession()
<script lang="ts">
  import { getSession } from '$lib/api/svelte'
  const session = getSession()
</script>

{#if $session.loading}Cargando…{:else if $session.user}Hola, {$session.user.name}{/if}

getSession() se llama al inicializar el componente. Fuera de uno —en un módulo, en un load—, sessionStore(api) da el mismo store sin contexto.

import { sessionView } from './api'

const vista = sessionView(api.session)                         // la de ahora
api.session.subscribe((estado) => pintar(sessionView(api.session, estado)))

Las dos dan lo mismo: una SessionView, con estos campos:

Campo Qué es
user El usuario: preferred_username, name, email, permissions… o null
loading true mientras se recupera la sesión al cargar la página
isAuthenticated Si hay sesión
status unknown, restoring, anonymous o authenticated
login(realm, usuario, contraseña) Inicia sesión. Unas credenciales malas son un TaiApiError 401. realm es de tipo Realm: ver abajo
logout() La cierra, en todas las pestañas
changePassword(actual, nueva) Cambia la contraseña: ver abajo
updateProfile({ first_name, last_name, email }) Cambia el perfil propio: ver abajo
me() Vuelve a leer el usuario de la API —sus roles y permisos de ahora— y actualiza user
hasPermission(p) Si tiene un permiso: hasPermission(PERMISSIONS.GESTION_USUARIOS.ACCESS)
can(tabla, acción) Si puede hacer algo en una tabla: can('lote', 'create')
authDisabled Si la API va sin autorización (tai-api dev): ver abajo

user está siempre al día, sin leer nada de localStorage.

Recuperar la sesión al recargar

<TaiProvider> (React) y setTaiClient (Svelte) lo hacen solos al montar: piden un access token nuevo con la cookie y, si la hay, el usuario sigue dentro. Mientras tanto loading es true, y la guarda de las pantallas espera.

Sin adaptador, a mano: await api.session.restore() al arrancar.

Los permisos

Para Usa
Que una pantalla pida un permiso permission en la declaración de pantallas
Ocultar un botón <Can permission={…}> en React; {#if $session.hasPermission(…)} en Svelte
Decidirlo en código hasPermission(p) o can('tabla', 'acción') de la sesión
Decidirlo sin framework hasPermission(user, p) y can(user, 'tabla', 'acción'), de @/lib/api

PERMISSIONS sale de los rbac/<realm>.py de la API (ver de dónde salen) e incluye:

  • los de aplicación, los que declaráis con app_permission(): deciden qué pantallas y qué botones ve cada usuario, y la API no los comprueba;
  • los de API (lote-read, lote-create…): los que exige cada endpoint.

Todas las comprobaciones siguen la regla de la API: sudo lo tiene todo, y <tabla>-admin vale por cualquier acción sobre su tabla.

Para ocultar, nunca para proteger

Un permiso en el frontend decide qué se ve. Quien protege los datos es la API.

Contra tai-api dev

tai-api dev arranca la API con la autorización apagada: no comprueba permisos ni RLS. Con tai-api dev --auth —y en producción— la comprueba. El cliente es el mismo contra las dos, y lo que cambia en el frontend es poco y conviene tenerlo claro:

tai-api dev tai-api dev --auth y producción
El login Hace falta, con un usuario de verdad Igual
Los permisos en la interfaz (hasPermission, <Can>, las pantallas) Los del usuario Los del usuario
Los permisos y el RLS en la API Ninguno: nunca hay 403 y se ve todo Los del usuario
authDisabled true false

Es decir: tai-api dev sirve para desarrollar pantallas con un usuario real, y la interfaz se comporta como en producción, pero lo que la API deja hacer no. Una pantalla que pinta un botón sin comprobar el permiso funciona aquí y da un 403 en producción; una lista que confía en el RLS enseña filas de más. Para probar permisos y RLS, tai-api dev --auth.

Hace falta que el backend tenga usuarios: en modo Database, el schema auth con su feed (tai-sql push -s auth --with-feed, que crea el admin) y los roles (tai-api rbac push).

La API lo dice en cada respuesta (X-Tai-Auth: off), y el cliente:

  • lo pone en authDisabled de la sesión —useSession(), $session, api.session.state—, para pintar un aviso en el layout;
  • avisa una vez por consola. createTaiClient({ onAuthDisabled: false }) calla el aviso (no el estado), y una función lo sustituye.
const { authDisabled } = useSession()

return (
  <>
    {authDisabled && <Banner tone="warning">Autorización desactivada (tai-api dev)</Banner>}
    <Outlet />
  </>
)
{#if $session.authDisabled}
  <Banner tone="warning">Autorización desactivada (tai-api dev)</Banner>
{/if}
api.session.subscribe((estado) => aviso.hidden = !estado.authDisabled)

authDisabled es false hasta la primera respuesta de la API —restore() ya la trae— y sigue a la API: si vuelve a arrancar con --auth, pasa a false en la respuesta siguiente. No cambia nada más: ni el login, ni las guardas, ni la sesión.

Cuando la sesión termina

No hace falta hacer nada para volver al login: la guarda de las pantallas lo hace sola. Si queréis avisar del motivo:

api.session.onSessionEnd((motivo) => {
  if (motivo === 'replaced') toast('Has iniciado sesión en otro dispositivo')
  if (motivo === 'expired') toast('La sesión ha caducado')
})
Motivo Qué ha pasado
logout La cerró el usuario, en esta pestaña o en otra
expired Pasó el tiempo de inactividad o el límite absoluto de la sesión
replaced Alguien entró con la misma cuenta en otro dispositivo: hay una sesión por usuario
revoked La API la cerró por seguridad: se presentó un refresh token que ya se había usado

Y vaciad la caché de TanStack, para que el siguiente usuario no vea los datos del anterior: connectQueryClient(api, queryClient), que hace queryClient.clear() al terminar la sesión (ver Montar la aplicación).

Cambiar la contraseña

await changePassword(actual, nueva)              // de la sesión, o api.session.changePassword(actual, nueva)

Comprueba la actual —en una tablet con la sesión abierta, nadie la cambia sin saberla— y el usuario sigue dentro, en todas las pestañas: la API abre una sesión nueva y el cliente la adopta. Es la única forma de cambiar la propia; el PATCH /auth/me es solo el perfil.

Los errores son un TaiApiError 422 con su field, para ponerlo junto a su campo del formulario:

field Por qué
current_password La actual no es la correcta
new_password La nueva tiene menos de 8 caracteres, o es igual a la actual

El perfil propio

const info = await updateProfile({ first_name: 'Ana', email: 'ana@example.com' })   // PATCH /auth/me
const yo = await me()                                                             // GET /auth/me

updateProfile cambia el nombre, los apellidos o el email de quien tiene la sesión —solo lo que se envía; null no vacía un campo— y me() vuelve a leer el usuario tal como lo ve ahora la API. Los dos devuelven un UserInfoData (con roles, permissions y password_expiration) y actualizan user, en esta pestaña y en las demás: un menú que pinta el nombre se vuelve a pintar.

me() sirve para lo que antes exigía volver a entrar: un administrador le da un rol a alguien con la sesión abierta, y con me() su interfaz ya enseña lo que el rol abre. Los permisos que comprueba la API son los del token, que se renuevan en el siguiente refresco (minutos).

La contraseña no va por aquí, sino por changePassword, que pide la actual; los roles y la activación, por la gestión de usuarios.

El realm

login(realm, …), loginWithRedirect(realm, …) y api.rbac(realm) reciben un Realm: la unión de los realms del proyecto. login('mian', …) no compila.

Modo De dónde salen
Keycloak auth.config.realms del .taiapiproject: son los únicos cuyos tokens acepta la API
Database Los rbac/<realm>.py del proyecto: son los que tai-api rbac push crea en el schema auth

Sin ninguno declarado, Realm es string. Un realm nuevo llega al tipo con tai-api generate.

Keycloak: el login por redirección

En modo Keycloak, en vez de un formulario propio se puede usar el de Keycloak (SSO, MFA, login social):

api.session.loginWithRedirect('main', '/inicio')   // vuelve a /inicio con la sesión abierta

WebSocket

Un WebSocket autenticado se abre desde la sesión: await api.session.openWebSocket('/ws/avisos'). Ver WebSocket.

Lo que pasa por debajo

Lo que en un frontend escrito a mano hacen un servicio de autenticación y su fetch envuelto, ya resuelto, y el mismo en todos los frameworks:

  1. El access token vive en memoria, nunca en localStorage. El refresh no pasa por el JavaScript: está en una cookie httpOnly. Por eso recargar no cierra la sesión: se pide un access nuevo con la cookie.
  2. Un solo refresco en vuelo, aunque diez peticiones reciban un 401 a la vez: todas esperan al mismo y se reintentan. Y uno solo por navegador: las pestañas se coordinan, y la que refresca reparte el token a las demás. Sin eso, dos pestañas presentarían el mismo refresh y la API cerraría la sesión.
  3. Se refresca antes de caducar, sin depender del reloj del equipo.
  4. Al volver a la pestaña o recuperar la red se comprueba si hace falta refrescar.
  5. Un 401 que no se arregla refrescando termina la sesión con su motivo. Un error de red, no.