Ir al contenido

Política de acceso (nivel de fila)

La política de acceso a nivel de fila te permite compartir un artefacto externamente y que cada viewer vea solo su propio slice de los datos — cada cliente su company_id, cada prospecto su pitch, cada responsable de región su region. El filtrado se aplica del lado del servidor dentro del Cloudflare Worker, después de identificar al viewer y antes de devolver cualquier fila. El viewer no puede evitarlo ni ampliarlo.

Para una explicación no técnica sobre visibilidad y compartición, ver Quién ve qué.

Por qué el enforcement tiene que ser server-side

Sección titulada «Por qué el enforcement tiene que ser server-side»

Un artefacto de ShareOut corre en el browser del viewer y es completamente inspeccionable — puede leer el JS, abrir devtools y llamar directamente a los endpoints /v1/data/… con cualquier filtro que quiera. Un filtro escrito en el código de tu artefacto (sdk.table('sales').find({ company_id: 1 })) es cosmético. La política de acceso corre dentro del Worker; el viewer no puede influir en ella.

Objetivo ¿Usar política?
Un dashboard compartido con múltiples clientes externos, cada uno viendo solo sus filas Sí
Un pitch deck donde cada prospecto ve solo sus propios números Sí
Herramienta interna donde todos los colaboradores deben ver todo No — usá colaboradores
Ocultar qué otros tenants existen a viewers sin coincidencia Sí — configurá default: "deny"
Contenido secreto por viewer dentro del HTML No es posible — el contenido secreto debe llegar por la capa de datos
Autor Viewer
───── ──────
1. Publica el artefacto 3. Abre la URL privada del artefacto
visibility: private → se loguea con Google o código OTP
auth_method: google → el Worker tiene el email VERIFICADO
share_with: [emails]
access_policy: { … } 4. El artefacto llama sdk.table('sales').find()
→ POST /v1/data/{id}/tables/sales/query
2. Política guardada server-side 5. Worker resuelve email/dominio → scope
(nunca leída del HTML) ejecuta: WHERE <filtro cliente>
AND company_id IN (…)
6. Solo se devuelven las filas en scope.

Los owners y colaboradores con rol editor bypasean la política y ven todos los datos (para autoría y QA). La política aplica solo a colaboradores con rol viewer y sign-ins externos.

Usá sdk.me() en tu artefacto para ramificar la UI por rol del viewer (por ejemplo, panel admin para canEdit, vista cliente read-only para el resto). La aplicación server-side no cambia — nunca confíes solo en checks del cliente.

Adjuntá un objeto access_policy al publicar. Se guarda server-side y puede actualizarse sin re-publicar el HTML.

{
"version": 1,
"field": "company_id",
"default": "deny",
"rules": [
{ "match": { "email": "buyer@acme.com" }, "values": [1] },
{ "match": { "domain": "acme.com" }, "values": [1, 2] },
{ "match": { "domain": "globex.com" }, "values": [3] }
]
}
Key Tipo Descripción
version 1 Versión del schema. Debe ser 1.
field string El campo de la fila a filtrar. Debe coincidir con la key JSON almacenada en cada fila (p. ej. company_id, tenant, region).
default "deny" | "allow" Resultado para un viewer que no coincide con ninguna regla. "deny" → cero filas (recomendado). "allow" → sin filtro.
rules array Lista ordenada de reglas match→values. Un viewer acumula la unión de values de todas las reglas que lo coinciden.
rules[].match.email string Coincidencia exacta de email, case-insensitive.
rules[].match.domain string Coincidencia de dominio de email (buyer@acme.com → dominio acme.com). Cada regla debe tener al menos uno de email o domain.
rules[].values array de string|number Valores permitidos de field para un viewer coincidente. No puede estar vacío.

La política del ejemplo anterior:

Viewer Rol Scope aplicado
buyer@acme.com viewer company_id IN (1, 2) — regla email [1] ∪ regla dominio [1, 2]
ceo@acme.com viewer company_id IN (1, 2) — solo regla dominio
ops@globex.com viewer company_id IN (3)
someone@stranger.com viewer sin filas (default: deny, sin coincidencia)
anónimo — sin filas (default: deny)
owner / editor owner/editor todas las filas (bypass de política)

El scope siempre se combina con AND con lo que consulta el artefacto — solo estrecha, nunca amplía. Si el artefacto envía find({ company_id: 99 }) pero el scope del viewer es [1, 2], el resultado es vacío.

Un proveedor SaaS comparte un único dashboard de ventas. Dos clientes deben ver solo sus propias filas.

[
{ "company_id": 1, "month": "2026-05", "revenue": 42000 },
{ "company_id": 2, "month": "2026-05", "revenue": 18000 },
{ "company_id": 3, "month": "2026-05", "revenue": 91000 }
]

El artefacto consulta sin ningún filtro de tenant — el servidor estrecha automáticamente:

<!doctype html>
<html>
<head>
<link rel="stylesheet" href="https://shareout.site/sdk/shareout.css">
<script src="https://shareout.site/sdk/shareout.js"></script>
<script type="shareout/manifest">
{
"version": "2.0",
"sources": {
"tables": {
"sales": { "schema": [
{ "name": "company_id", "type": "number" },
{ "name": "month", "type": "string" },
{ "name": "revenue", "type": "number" }
] }
}
}
}
</script>
</head>
<body>
<script>
const sdk = new ShareOut();
// Sin filtro de tenant del lado cliente — el servidor lo aplica.
const rows = await sdk.table('sales').find().sort({ month: -1 }).toArray();
// rows ya contiene SOLO las filas del viewer logueado.
</script>
</body>
</html>
Ventana de terminal
TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.shareout/credentials')))['token'])")
python3 -c 'import json; print(json.dumps({
"name": "customer-sales",
"files": [{"path": "index.html", "content": open("index.html").read(), "mime": "text/html"}],
"visibility": "private",
"auth_method": "google",
"share_with": ["buyer@acme.com", "ops@globex.com"],
"access_policy": {
"version": 1, "field": "company_id", "default": "deny",
"rules": [
{"match": {"domain": "acme.com"}, "values": [1, 2]},
{"match": {"domain": "globex.com"}, "values": [3]}
]
}
}))' | curl -sS -X POST 'https://shareout.site/v1/publish' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' --data-binary @-

Actualizar sin re-publicar el HTML — PATCH del artefacto:

PATCH /v1/artifacts/{artifact_id}
Authorization: Bearer {token}
Content-Type: application/json
{ "access_policy": { "version": 1, "field": "company_id", "default": "deny", "rules": [ … ] } }

Eliminar la política (todos los viewers ven todo nuevamente):

PATCH /v1/artifacts/{artifact_id}
Content-Type: application/json
{ "access_policy": null }

Las políticas inválidas se rechazan al escribir con 400 INVALID_ACCESS_POLICY y un mensaje que indica el campo con error.

La política necesita una identidad verificada, así que el artefacto debe requerir login:

  • visibility: "private" — los visitantes anónimos no tienen identidad y, bajo default: "deny", no obtienen datos.
  • auth_method: "google" — los viewers se loguean con Google OAuth o un código de 6 dígitos por email; el Worker recibe su email verificado.
  • share_with: […] — el gate de acceso (quién puede abrir el artefacto). La access_policy es el filtro de filas (qué ve cada viewer permitido). Son dos capas independientes.

Un viewer debe pasar ambos controles: estar en share_with (o pertenecer al workspace) y coincidir con una regla de la política para recibir filas.

Los viewers con scope nunca mandan SQL. Declarás cada query de warehouse en la política, bajo queries; la página la corre por nombre y el servidor le aplica el scope del viewer:

"queries": {
"revenue_by_month": {
"connection": "conn_…", "provider": "snowflake", "endpoint": "statements.execute",
"params": { "body": { "statement": "SELECT month, SUM(revenue) AS revenue FROM sales WHERE company_id IN (:viewer_scope) GROUP BY month" } }
}
}
await sdk.platform.provider('snowflake').execute('statements.execute', { query: 'revenue_by_month' });
  • params tiene que llevar exactamente una sentencia SQL (body.sql, body.query o body.statement); se valida al guardar la política.
  • Con :viewer_scope, el servidor sustituye los valores del viewer; un scope vacío queda en NULL, así que IN (NULL) no devuelve nada. Un placeholder dentro de un comentario o de un string no cuenta.
  • Sin :viewer_scope, la query se envuelve como SELECT * FROM (<sql>) AS so_scoped WHERE so_scoped.<field> IN (…), así que el resultado tiene que incluir el campo de la política.
  • SQL propio, nombres desconocidos o un nombre usado con otro proveedor/endpoint/conexión → 403 SCOPE_REQUIRED — fail-closed.
  • Owners/editores pueden seguir mandando SQL. Al correr una query declarada, :viewer_scope toma todos los valores que nombra la política, y sin placeholder la query corre sin envolver.
  • El modo de ejecución directa está bloqueado para viewers con scope (le pasaría credenciales al browser).
  • El cache de plataforma está segmentado por scope — el resultado filtrado de un viewer nunca se sirve a otro.
  • Los proveedores no-SQL (Google Sheets, Analytics, Shopify, S3) y las queries de sdk.connection() no admiten scope; los viewers con scope quedan bloqueados.
Backend Bajo una política de acceso, un viewer con scope…
sdk.table() ve solo filas en scope; lecturas y escrituras filtradas; findById fuera de scope → 404
Data Platform — SQL (Snowflake, BigQuery, Supabase, Redshift, Aurora) solo queries declaradas en access_policy.queries; si no, 403 SCOPE_REQUIRED
Data Platform — no-SQL (Sheets, GA, Shopify, S3) bloqueado (403 SCOPE_REQUIRED)
sdk.connection() bloqueado (403 SCOPE_REQUIRED)
sdk.json bloqueado (403) — solo owner/editor bajo una política
sdk.blobs, sdk.realtime, sdk.comments no tienen scope — no almacenés datos secretos por tenant ahí

Lo que la política garantiza:

  • Todos los paths de lectura y escritura de sdk.table() están filtrados — find, count, distinct, findById, update, delete. Ningún path bypasea la cláusula de scope.
  • El scope se combina con AND con el filtro del cliente; solo puede estrechar.
  • El SQL de Data Platform es fail-closed: un viewer con scope solo corre queries declaradas, nunca SQL propio → si no, 403.
  • El bypass de owner/editor se determina a partir de la sesión verificada por el servidor, no de nada que el cliente envíe.

Lo que no protege:

  • Cualquier cosa hardcodeada en el HTML/JS publicado es visible para todo viewer autorizado. El contenido secreto por viewer debe llegar por sdk.table(), clave por el campo de la política.
  • La coincidencia por dominio confía en el dominio del email. No uses reglas de domain para dominios compartidos de consumidores (gmail.com, outlook.com) — usá reglas de email explícitas.
  • sdk.blobs, sdk.realtime y sdk.comments no tienen scope. No almacenés datos secretos por tenant ahí.

Corré el worker localmente y simulá distintos viewers con el helper /auth/dev:

Ventana de terminal
open "http://localhost:55162/auth/dev?email=buyer@acme.com&redirect=/a/customer-sales"
# En otro perfil del browser:
open "http://localhost:55162/auth/dev?email=ops@globex.com&redirect=/a/customer-sales"

Prueba de leak — confirmá que el servidor estrecha incluso cuando el cliente pide una fila fuera de scope:

Ventana de terminal
curl -sS -X POST 'http://localhost:55162/v1/data/{artifact_id}/tables/sales/query' \
-H 'Content-Type: application/json' --cookie 'shareout_session=…' \
--data '{"filter": {"company_id": 3}}' # como viewer de Acme → devuelve []
Síntoma Causa probable
El viewer no ve filas Ninguna regla coincide con su email/dominio y default es deny; o las filas no tienen el field; o el nombre de field en la política difiere de la key JSON en las filas.
El viewer ve todas las filas default: "allow"; o es owner/editor (bypass); o el artefacto no es private.
El cambio de política no tiene efecto El metadata del artefacto se cachea ~5 min. Publicar y PATCH limpian el cache automáticamente.
400 INVALID_ACCESS_POLICY al publicar Política malformada — el mensaje indica el campo con error.
Re-publicar “perdió” mi política No la perdió. Omitir access_policy la preserva; enviá access_policy: null via PATCH para eliminarla.
  • Cada fila tiene el campo de tenant (p. ej. company_id en todas las filas).
  • El artefacto tiene visibility: "private", auth_method: "google".
  • share_with lista todos los emails de viewers (el gate de acceso).
  • access_policy.field coincide exactamente con la key JSON de las filas.
  • default: "deny" salvo que viewers sin coincidencia deban ver todo.
  • Ningún dato secreto por viewer hardcodeado en el HTML.
  • Probado como dos viewers distintos y como owner; ejecutada prueba de leak.