Saltar a contenido

Filtrado por fila (RLS)

Que cada usuario vea —y modifique— solo sus filas: las de su centro, su empresa o su cliente.

Declararlo

# rbac/main.py
rls(target_schema="public", target_model="pedido", target_field="centro_id")

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. Un usuario['rls.public.linea.id'].map(…) revienta: (usuario['rls.public.linea.id'] ?? []).map(…), o mejor, tratar null como «sin restricción» en la pantalla.
  • Al dar de alta, para «sin restricción» manda null u omite el campo. Un [] ahora deja al usuario sin ver nada: centro ? [centro.id] : null, no centro ? [centro.id] : [].
  • Al modificar, omite las reglas que no cambian. Un formulario que manda todas con null en 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: GET de un registro o de una lista, count, exists y las agregaciones. El total de la paginación también: no cuenta filas que el usuario no ve.
  • Escrituras sobre filas que existen: PATCH y DELETE, uno o muchos. Un DELETE sin filtros con confirm_all borra las filas que el usuario alcanza, no la tabla.
  • Las escrituras anidadas: un update o delete de una fila hija dentro del PATCH del padre ({"etiquetas": {"delete": [...]}}), o dentro de un update de _tx, tiene que alcanzarla con las mismas reglas; si no, 404 con el field del 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:

owner("chatbot", "chat", "username", exempt=["supervisor"])

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.