Modelo de Seguridad
Vincent Depassier · 29 de agosto de 2026
PraxQL — Modelo de Seguridad
Todo request pasa por seis capas independientes antes de que vuelva un solo dato. Conviene entenderlas como conjunto, porque cada una falla distinto y esas diferencias son lo que define tu modelo de acceso.
Capa 1 — Autenticar la key
Llega el request
→ Se extrae el token Bearer del header Authorization, o el header x-api-key
→ Se contrasta contra las credenciales almacenadas
→ Se verifica que la credencial esté Activa
→ Se verifica que ExpiresAt no haya pasado, si está seteado
→ Se verifica que el principal esté ActivoCualquier cosa que falle acá es 401 UNAUTHORIZED.
La key en sí nunca se guarda — solo un hash de una vía. Por eso el portal te muestra una key nueva exactamente una vez: nada en la plataforma puede volver a mostrártela, y un backup filtrado no le entrega a nadie una credencial funcional.
Revocar una key no es instantáneo. La búsqueda de credenciales se cachea hasta dos minutos, así que una key revocada puede seguir siendo aceptada un rato. Contá con esa ventana cuando rotes bajo presión; suspender el principal tiene la misma demora, y solo terminar las sesiones de un usuario final aplica de inmediato.
Capa 2 — Aislamiento de workspace, por estructura
Los GUID de tabla de refs no se resuelven solos. El workspace autenticado va soldado dentro de la búsqueda, así que un GUID de otro workspace simplemente no aparece.
Vale la pena decirlo con precisión por lo que descarta: no hay política cross-tenant que se pueda configurar mal, ni regla que un administrador pueda olvidarse de aplicar. Una key emitida para el workspace A no puede llegar a los datos del workspace B porque el request que los devolvería no se puede expresar.
Capa 3 — Permisos de tabla
Cada credencial lleva scopes de tabla.
Nivel de acceso | Permite |
| solo |
| solo |
| ambos |
Una tabla sin scope responde `403 SCOPE_VIOLATION`, no `404`, y el mensaje no confirma si la tabla existe. Un 404 permitiría mapear tu workspace probando GUIDs.
De dónde sale el scope
Hay dos fuentes, y cuál aplica depende de quién llama:
Quien llama | Los permisos salen de |
Una key de servidor o publicable ( | los scopes de tabla y columna de la credencial |
Un usuario final con JWT de sesión | los scopes de sus roles de gateway, combinados como unión |
La vía de roles importa: un usuario con dos roles obtiene la unión de lo que ambos permiten, así que quitar un permiso significa quitarlo de todos los roles que lo otorgan, no solo del obvio.
Capa 4 — Permisos de columna
Dentro de una tabla permitida, cada operación sobre cada columna es independiente:
Permiso | Controla | Default en un scope de columna nuevo |
| puede aparecer en | activo |
| puede aparecer en | activo |
| puede aparecer en | activo |
| puede aparecer en | inactivo |
| puede usarse con una función de agregación | inactivo |
| puede ser seteada por una mutación | inactivo |
Están separados porque filtran información de maneras distintas. Una columna sobre la que alguien puede filtrar pero no leer igual le dice algo: filtrás por sueldo, contás las filas, y aprendiste un rango sin haber seleccionado nunca la columna. Otorgá CanFilter a propósito, no como efecto colateral de otorgar CanRead.
Un scope de tabla sin scopes de columna definidos permite todas las columnas. Definí scopes de columna cuando la tabla guarde algo puntual que no debería viajar — pero ojo con la asimetría: definirlos te mueve de "todo permitido" a los defaults de arriba, donde escribir, agrupar y agregar están apagados.
Un scope de columna puede además llevar una plantilla de valor por defecto para los insert. Acepta marcadores de claims ({{claim:sub}}), se inyecta del lado del servidor, y quien llama no la puede sobrescribir — así una fila queda marcada con su dueño sin confiar en que el cliente lo mande.
Capa 5 — Filtros de fila
Un filtro de fila vive en el scope de tabla y se inyecta en el where de todo request que toque esa tabla. El llamador no lo ve, no lo puede sacar ni escribir alrededor — sus condiciones se combinan con AND contra él.
{ "field": "ClientId", "op": "eq", "value": "guid-del-cliente" }Todo request que haga esa key lleva esa condición. El llamador no ve las filas de otros clientes, y tampoco puede averiguar cuántos otros clientes existen.
Lecturas y escrituras, juntas o separadas
Un scope tiene dos filtros, y el segundo es opcional:
Filtro | Aplica a |
Filtro de filas |
|
Filtro de escritura |
|
Dejá vacío el de escritura y una sola regla gobierna todo: una fila que no se puede leer no se puede modificar. Es el default correcto, y para un esquema de un tenant por key es todo lo que necesitás.
Definilo cuando varias personas comparten un contenedor. Un canal de chat con scope Canal = X y permiso de escritura deja que cada miembro edite y borre los mensajes de todos los demás, porque un solo filtro solo decide qué filas se matchean. Separarlos expresa la regla que querías de verdad — leé toda la sala, cambiá solo lo tuyo:
filtro de lectura → Canal eq X
filtro de escritura → CREATEDBY eq {{claim:sub}}`CREATEDBY` es el ancla natural. El gateway la estampa desde el token verificado de quien llama en cada insert y se niega a que un cliente la setee, así que es la única columna que nadie puede falsificar.
Un UPDATE se verifica de nuevo después de correr: si las filas que tocó no satisfacen el filtro de escritura, la transacción se revierte en vez de commitear.
Filtros que se resuelven desde el token
En un scope de rol, cualquiera de los dos filtros puede leer un claim del token del usuario final en vez de tener un valor fijo:
{ "field": "OwnerUserId", "op": "eq", "valueFromClaim": "sub" }Eso se resuelve al id del propio usuario autenticado en tiempo de request, y es lo que hace que el aislamiento por usuario sea automático en vez de algo que cada endpoint tiene que acordarse de hacer.
Hay un atajo para el caso típico. Poner la plantilla del filtro en el literal __SELF__ le dice a la plataforma que busque ella misma la primera columna de usuario final de la tabla y arme exactamente el filtro de arriba. Conseguís aislamiento por usuario sin nombrar una columna — y sin que el filtro se rompa cuando alguien la renombre.
Si la tabla no tiene columna de usuario final,
__SELF__no tiene a qué atarse. Agregá la columna antes de depender de él.
Capa 6 — Guardrails
Algunos límites son absolutos. Ningún scope, plan ni configuración los sube:
Guardrail | Tope |
Refs de tabla por request | 20 |
Columnas en | 100 |
Condiciones | 50 |
Profundidad de anidación de condiciones | 5 |
Longitud de patrón | 200 caracteres |
Filas por consulta | 1.000 |
Profundidad de relaciones | 5 |
Filas por insert | 100 |
Columnas en el | 50 |
Timeout de query y mutation | 30 segundos |
Otros son por scope, y arrancan más abajo que el tope. Un scope de tabla recién creado se emite con:
Ajuste | Scope de credencial | Scope de rol |
|
|
|
Límite máximo | 200 filas | 100 filas |
Profundidad de relaciones | 2 | 2 |
Permitir relaciones | activo | activo |
Permitir agregaciones | inactivo | inactivo |
Permitir introspección | inactivo | inactivo |
O sea que una credencial real no arranca con el tope absoluto — y el rol de un usuario final arranca todavía más abajo. Si una consulta devuelve menos filas de las que pediste, leé meta.limit: es el scope hablando, no la plataforma.
Un override se puede mover hasta el tope absoluto de ese scope, nunca más allá.
Enmascarado, después de ejecutar
Cuando vuelven las filas, cada columna se enmascara según la MaskingRule de su scope:
Regla | Efecto |
| sin enmascarar |
| primer y último carácter: |
| parte local oculta: |
| últimos cuatro dígitos: |
|
|
El enmascarado corre después del filtrado, así que la comparación se sigue haciendo contra el valor real. Filtrar sobre una columna enmascarada sigue funcionando — que es el punto, y también la razón por la que CanFilter merece pensarse aparte.
Trazabilidad
Todo request, exitoso o no, se registra de forma asíncrona una vez enviada la respuesta: workspace, credencial y principal, id del usuario final cuando aplica, el cuerpo completo de la consulta, código de estado, duración, filas devueltas, IP de origen y user agent, y el error cuando lo hubo.
Los logs están en la pestaña Logs y no se pueden borrar.
Lo que este modelo no cubre
Estas seis capas gobiernan datos. Otros dos ejes aplican al mismo tiempo y se configuran en otro lado: qué documentos puede tocar una credencial (Docs scopes) y qué herramientas MCP puede llamar siquiera (permisos de herramientas). Ninguno de los tres puede ampliar lo que otro niega — otorgar un grupo de herramientas no otorga los datos que esa herramienta leería.