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.
<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.
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
authDisabledde 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.
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¶
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):
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:
- 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. - 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.
- Se refresca antes de caducar, sin depender del reloj del equipo.
- Al volver a la pestaña o recuperar la red se comprueba si hace falta refrescar.
- Un 401 que no se arregla refrescando termina la sesión con su motivo. Un error de red, no.