Filtrado por fila (RLS)¶
Que cada usuario vea —y modifique— solo sus filas: las de su centro, su empresa o su cliente.
Declararlo¶
La regla dice qué columna acota qué tabla. El modelo es el __tablename__ y la columna, la física
(centro_id, no la relación centro); el schema tiene que estar entre los de source().
Dar a cada usuario sus valores¶
Cada regla es un atributo del usuario, rls.<schema>.<tabla>.<columna>, con la lista de valores
que puede ver:
POST /rbac/main/user
{ "username": "ana", "password": "…", "roles": ["operario"], "rls.public.pedido.centro_id": [1, 3] }
Cada regla tiene tres estados, y null y [] no son lo mismo:
| Valor | Qué significa | Qué ve |
|---|---|---|
null, o la regla omitida |
No aplica: el usuario no tiene el atributo | Todo, para esa regla |
[] |
Cero valores: la regla aplica y no le deja ver nada | Ninguna fila |
[1, 3] |
Sus valores | Las filas de esos valores |
GET /rbac/{realm}/user/{usuario} devuelve el mismo null o []. En un PATCH, una regla
omitida no se toca, null la quita —vuelve a «no aplica»— y [] o una lista la sustituyen.
Keycloak no guarda atributos vacíos: «cero valores» se guarda allí como __tai_api:sin_valores__,
que la API vuelve a leer como []. Si gestionas los atributos desde la consola de Keycloak, una
regla vacía es «no aplica», no «cero».
Si vienes de una versión anterior a la 0.5.8¶
Hasta la 0.5.7, /rbac no distinguía null de []. Al regenerar con la 0.5.8 o posterior, la
API se comporta así, y un frontend escrito para la anterior puede romperse o dar más o menos
acceso del que cree:
| Operación | Hasta la 0.5.7 | Desde la 0.5.8 |
|---|---|---|
GET de un usuario sin la regla |
[] |
null |
Alta (POST) con [] |
Se descartaba: lo veía todo | Se guarda: no ve nada |
Alta con null |
Error de validación (422) | No aplica: lo ve todo |
PATCH con la regla omitida |
No se tocaba | No se toca |
PATCH con null |
No se tocaba | Se quita: pasa a verlo todo |
PATCH con [] |
Cero valores en Database; en Keycloak se borraba el atributo (lo veía todo) | Cero valores en los dos |
Lo que ya está guardado no cambia de significado: un usuario dado de alta antes con [] no
tiene el atributo, y sigue sin restricción. La única excepción es un atributo guardado a mano con
valor null: antes llegaba al token como el texto "None" —no dejaba ver nada, o daba un 500 sobre
una columna numérica— y ahora es «no aplica».
Qué revisar en el frontend, buscando las claves rls. en su código:
- Al leer un usuario, el campo de una regla puede llegar
null. Unusuario['rls.public.linea.id'].map(…)revienta:(usuario['rls.public.linea.id'] ?? []).map(…), o mejor, tratarnullcomo «sin restricción» en la pantalla. - Al dar de alta, para «sin restricción» manda
nullu omite el campo. Un[]ahora deja al usuario sin ver nada:centro ? [centro.id] : null, nocentro ? [centro.id] : []. - Al modificar, omite las reglas que no cambian. Un formulario que manda todas con
nullen las que no tocó las quita, y el usuario pasa a verlo todo, sin error.
Lo que filtra¶
Los valores viajan en el token, y cada endpoint los aplica a la consulta:
- Lecturas:
GETde un registro o de una lista,count,existsy las agregaciones. El total de la paginación también: no cuenta filas que el usuario no ve. - Escrituras sobre filas que existen:
PATCHyDELETE, uno o muchos. UnDELETEsin filtros conconfirm_allborra las filas que el usuario alcanza, no la tabla. - Las escrituras anidadas: un
updateodeletede una fila hija dentro delPATCHdel padre ({"etiquetas": {"delete": [...]}}), o dentro de unupdatede_tx, tiene que alcanzarla con las mismas reglas; si no, 404 con elfielddel anidado y no se escribe nada. Ver Escrituras anidadas. - Una fila fuera de su ámbito es un 404, no un 403: para ese usuario no existe, y un 403 le confirmaría que está ahí.
Un usuario sin la regla (null) no está acotado por ella; uno con la regla vacía ([]) no
ve ninguna fila.
Lo que no filtra¶
Las altas no se validan
El RLS acota lo que se lee y lo que se modifica, no lo que se escribe: con permiso de
create, un usuario del centro 1 puede crear un pedido con centro_id=2, y con permiso de
update puede cambiar esa columna en un pedido suyo y sacarlo de su ámbito. Validar los
valores de una escritura contra las reglas del token es un mecanismo que está por construir.
Para las filas de un usuario sí lo hay: owner(), abajo.
Filas de cada usuario: owner()¶
Cuando una fila no es de un centro sino de una persona —sus conversaciones, sus borradores, sus pedidos— la regla es otra:
# rbac/main.py
from tai_api.rbac import source, role, owner
source(realm_name="main", dbschemas=["chatbot"])
owner("chatbot", "chat", "username")
owner("chatbot", "tokens_mensaje", "username")
Cada usuario ve, modifica y borra solo las suyas, y no puede crear filas a nombre de otro ni cambiar de quién son.
rls() |
owner() |
|
|---|---|---|
| De dónde sale el valor | Los atributos del usuario (rls.<schema>.<tabla>.<columna>) |
El usuario del token (preferred_username) |
Un usuario sin la regla (null) |
No está acotado | No existe: todo token tiene usuario |
Una regla vacía ([]) |
No ve nada | — |
Se lleva al backend con rbac push |
Sí | No: se hornea en la API al generar |
| Valida las altas y los cambios | No | Sí |
A qué alcanza¶
A la tabla declarada y a todo lo que cuelga de ella por relaciones many-to-one: con la regla
de chat, mensaje se filtra por mensaje.chat, y tool_call por tool_call.message.chat. No
hace falta una columna de dueño en cada tabla. Una vista no tiene relaciones, así que necesita
su propia regla sobre su propia columna (tokens_mensaje.username).
Las escrituras¶
| Operación | Qué se comprueba | Si no |
|---|---|---|
POST |
Que la fila nueva sea del usuario: su columna, o su padre (un mensaje en un chat ajeno) | 403, reason: "OWNER" |
PATCH /{id} |
Que la fila siga siendo del usuario después: no cambia de dueño ni de padre | 403, reason: "OWNER" |
PATCH masivo |
Que no toque la columna del dueño ni las claves foráneas por las que llega la regla | 403, reason: "OWNER" |
Lo anidado (POST, PATCH /{id}, _tx) |
Que lo creado o modificado en las tablas hijas quede del usuario | 403, reason: "OWNER", con el field del anidado |
GET, PATCH, DELETE de algo ajeno |
— | 404: para ese usuario no existe |
La comprobación va en la misma transacción que la escritura: si falla, no queda nada escrito, tampoco lo que hayan hecho los triggers.
Quién ve lo de todos¶
exempt lista los permisos que saltan la regla, al leer y al escribir:
Por defecto no la salta nadie, tampoco sudo: ver lo de todos se declara. Lo que no lleva
regla —una vista de consumo agregado— se lee entero con su permiso de lectura, así que un
administrador puede ver el consumo de todos y seguir teniendo solo sus conversaciones.
En un router propio¶
rls_for(token, "<schema>") da, con solo el token, las reglas que aplicaría la API generada —las
del token y las de propietario—, así que un endpoint propio filtra con el mismo DAO que los
generados:
from api.database.chatbot import chatbot_async_api
from api.routers.auth import AccessToken, requires_authentication, rls_for
@router.post("/chat/{chat_id}/send")
async def send(chat_id: int, token: AccessToken = Depends(requires_authentication())):
rls = rls_for(token, "chatbot")
chat = await chatbot_async_api.chat.find(id=chat_id, rls=rls)
if chat is None:
raise RecordNotFoundException("Chat") # ajeno o inexistente: lo mismo
No compares chat.username con el token a mano: la regla es la misma que en el CRUD, y si cambia,
cambia en los dos. Vale igual para lo que se llame desde ese endpoint —una herramienta que lee en
nombre del usuario recibe el token y pasa rls_for(token, ...) a cada consulta—.
Falla en cerrado: un schema sin cliente generado, o una regla del token sobre una tabla de ese schema que ya no existe, lanzan un 500 en vez de consultar sin filtro.
El permiso de la tabla: can y require_permission¶
rls_for acota qué filas; el permiso de la tabla —lo que un endpoint generado pide con
requires_permissions— se comprueba con can o require_permission, con la misma regla:
<tabla>-<acción>, o sudo, <tabla>-admin o allresources-<acción>.
from api.routers.auth import can, require_permission, rls_for
if can(token, "read", "chat"): # True o False
chats = await chatbot_async_api.chat.find_many(rls=rls_for(token, "chatbot"))
require_permission(token, "delete", "mensaje") # sin el permiso: 403 INSUFFICIENT_PERMISSIONS
Con los dos juntos, el código propio —y lo que llame con el token, como las herramientas de un
agente— hace lo mismo que el CRUD generado sin copiar la regla. Con la autorización apagada
(tai-api dev), can da True y require_permission no lanza, como las dependencias.
Con la autorización apagada (tai-api dev) las reglas de propietario no se aplican, como el resto.