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.
Cuándo usarla
Sección titulada «Cuándo usarla»| 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 |
Flujo de una request
Sección titulada «Flujo de una request»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/query2. 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.
Schema de la política
Sección titulada «Schema de la política»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. |
Resolución
Sección titulada «Resolución»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.
Ejemplo completo
Sección titulada «Ejemplo completo»Un proveedor SaaS comparte un único dashboard de ventas. Dos clientes deben ver solo sus propias filas.
Datos (tabla multi-tenant)
Sección titulada «Datos (tabla multi-tenant)»[ { "company_id": 1, "month": "2026-05", "revenue": 42000 }, { "company_id": 2, "month": "2026-05", "revenue": 18000 }, { "company_id": 3, "month": "2026-05", "revenue": 91000 }]HTML del artefacto
Sección titulada «HTML del artefacto»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>Publicar
Sección titulada «Publicar»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 y eliminar la política
Sección titulada «Actualizar y eliminar la política»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.
Autenticación del viewer
Sección titulada «Autenticación del viewer»La política necesita una identidad verificada, así que el artefacto debe requerir login:
visibility: "private"— los visitantes anónimos no tienen identidad y, bajodefault: "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). Laaccess_policyes 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.
Data Platform (Snowflake / BigQuery)
Sección titulada «Data Platform (Snowflake / BigQuery)»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' });paramstiene que llevar exactamente una sentencia SQL (body.sql,body.queryobody.statement); se valida al guardar la política.- Con
:viewer_scope, el servidor sustituye los valores del viewer; un scope vacío queda enNULL, así queIN (NULL)no devuelve nada. Un placeholder dentro de un comentario o de un string no cuenta. - Sin
:viewer_scope, la query se envuelve comoSELECT * 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_scopetoma 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.
Cobertura de backends
Sección titulada «Cobertura de backends»| 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í |
Límites de seguridad
Sección titulada «Límites de seguridad»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
domainpara dominios compartidos de consumidores (gmail.com,outlook.com) — usá reglas deemailexplícitas. sdk.blobs,sdk.realtimeysdk.commentsno tienen scope. No almacenés datos secretos por tenant ahí.
Testing local
Sección titulada «Testing local»Corré el worker localmente y simulá distintos viewers con el helper /auth/dev:
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:
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 []Troubleshooting
Sección titulada «Troubleshooting»| 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. |
Checklist
Sección titulada «Checklist»- Cada fila tiene el campo de tenant (p. ej.
company_iden todas las filas). - El artefacto tiene
visibility: "private",auth_method: "google". -
share_withlista todos los emails de viewers (el gate de acceso). -
access_policy.fieldcoincide 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.
