🔑 PIEZA · GRANTS — la retícula de privilegios
| Versión | v1.0 |
| Estado | 🏁 En producción y APLICANDO (USER_AUTHZ=enforce) |
| Última medición | 2026-08-12 — g0 · g1 · g2 · g3, todos verdes |
| Manda sobre ella | este documento. El plan vive en plano-gobernado-approach.md §7·octies |
1 · Qué es
La respuesta a una sola pregunta: «¿puede este sujeto ejercer este privilegio
sobre este objeto?» — y su porqué, para poder auditarla.
⛔ Y qué NO es, que ahorra más que la definición
| No es | Es |
|---|---|
| No es autenticación | La identidad viene de fuera (Clerk / Keycloak). Aquí sólo se referencia |
| No es grano fino | Decide si tocas la tabla, nunca qué ves dentro. Eso es ROW-POLICY, y no existe |
| No es una lista de prohibiciones | DML_VERBS_PERMITIDOS es un sucedáneo que existía porque no había a quién conceder |
| No es una tabla de usuarios | No hay tabla principals: un sujeto es un par (kind, id) de otro sistema |
No tiene deny | Es aditivo. Un forbid explícito es de Cedar/OpenFGA — meterlo aquí serían dos motores de política |
2 · Su naturaleza
IDENTIDAD LA CADENA (Index) EL OBJETO
───────── ───────────────── ─────────
Clerk ──┐
Keycloak ──┼─▶ principal ──▶ principal role ──▶ catalog role ──▶ grant ──▶ securable
(agentes)──┘ (kind,id) «el equipo» «el paquete» (privilegio) │
▼
workspace › catalog › schema › dataset
(la HERENCIA baja por aquí)
Las dos relaciones son N:M, y el nivel de en medio es lo que resuelve «este equipo lee el esquema entero salvo dos tablas». Con un solo nivel habría que repetir el paquete de permisos por cada equipo.
Las seis propiedades de su autoridad
| Propiedad | Su consecuencia, entera | |
|---|---|---|
| ① | Aditivo, sin deny | No se puede expresar «todos menos éste» |
| ② | Hereda hacia abajo por la cadena | ⭐ Es lo que hace que los future grants de Snowflake no hagan falta: lo que nazca en un contenedor concedido nace cubierto |
| ③ | Implica transitivamente | CATALOG_MANAGE_CONTENT arrastra 10 más. Se calcula con visitados: un ciclo sería un error de datos, pero un bucle en el camino de autorización sería una caída |
| ④ | Gana el más específico | No por precedencia —el modelo es aditivo— sino para explicar: el nivel más cercano al objeto es el que un humano espera ver |
| ⑤ | Devuelve el PORQUÉ | Sin grantedBy, una autorización no se puede auditar |
| ⑥ | Grano grueso, y sólo eso | Ver §1 |
El vocabulario: 40 privilegios en 6 familias
WORKSPACE_* (4) · CATALOG_* (7) · SCHEMA_* (8) · TABLE_* (10) · VIEW_* (6) ·
POLICY_* (6)
⭐ El invariante que más se equivoca al copiarlo: *_FULL_METADATA no abre el
dato. Leer la metadata de una tabla y leer sus filas son permisos distintos
(TABLE_READ_DATA va aparte, deliberadamente), y fundirlos es cómo se filtran datos
por accidente.
3 · Dónde vive
| Ruta | Qué es | |
|---|---|---|
| Vocabulario y decisión | lib/governance/privilege.ts | Módulo puro: los 40 privilegios, grantableOn, implies, decidePrivilege. Sin I/O |
| La I/O | lib/governance/privilege-queries.ts | Carga los grants. Lanza si falla — un error de lectura no puede confundirse con «sin permisos» |
| El esquema | supabase/migrations/20261269_privilege_chain.sql | 5 tablas + 3 triggers de tenencia + la vista principal_grants |
| Plantilla de SERVICIOS | lib/governance/tenant-template.ts | 7 catalog roles · 7 principal roles. Un inquilino nace con ellos |
| ⭐ Derivación de HUMANOS | lib/governance/membership-grants.ts + -queries.ts | La proyección de workspace_members |
| Aplicador de humanos | lib/governance/user-authz.ts | off / shadow / enforce |
| Aplicador de motores | lib/governance/catalog-authz.ts | La cara Iceberg REST |
⚠️ Por qué el vocabulario vive en TypeScript y no en SQL: el CHECK de la
migración es sólo un patrón. Los 40 nombres, dónde se concede cada uno y qué implica
viven en un solo sitio — repetirlos en SQL sería el mismo conocimiento en dos
lenguajes, que es la clase de bug que ya costó una fase.
4 · Quién la ejerce — dos puertas, dos sujetos distintos
┌─ LA PUERTA (Junction) ────────────────────────────────────────┐
│ pregunta: «¿puede esta PERSONA?» │
│ lib/compute/junction.ts → user-authz.ts │
│ en lectura, escritura y CREATE · interruptor USER_AUTHZ │
└───────────────────────────────────────────────────────────────┘
┌─ LA CARA (/api/iceberg) ──────────────────────────────────────┐
│ pregunta: «¿puede este MOTOR?» │
│ lib/governance/catalog-authz.ts · fail-closed · deja asiento │
└───────────────────────────────────────────────────────────────┘
⭐ La decisión más importante de la cara: loadTable sin delegación pide
TABLE_READ_PROPERTIES; con X-Iceberg-Access-Delegation pide además
TABLE_READ_DATA. Porque son dos cosas: la primera dice dónde está la tabla; la
segunda entrega la llave para leer sus bytes.
⚠️ Y lo que ninguna de las dos puertas hace: cerrar la red. Si un motor puede apuntar al catálogo directamente, esto no aplica nada. Es infraestructura, no código — y la parte más barata y más olvidable del trabajo.
5 · Lo medido (2026-08-12)
Los sujetos
principal_grants ....... 368 filas de SERVICIO (8 sujetos, 8 inquilinos)
+ 12 sujetos HUMANOS, derivados ← F·2·0
privilege_grants ....... 248 · principal_roles 96 · catalog_roles 56 · bindings 120
| Rol de membresía | Rol derivado | Presta | Sujetos hoy |
|---|---|---|---|
owner | ws-owner | operator | 3 |
admin | ws-admin | writer | 6 |
editor | ws-editor | reader + dml-writer | 0 |
viewer | ws-viewer | reader | 3 |
guest | ws-guest | catalog-browser | 0 |
⭐⭐ owner y admin NO reciben lo mismo, y ahí está la separación de deberes:
sólo owner lleva WORKSPACE_MANAGE_ACCESS. Conceder es un oficio distinto de
crear, y Clerk ya distinguía los dos nombres — implementarlo no costó diseño,
sólo verlo.
Los gates
npx dotenv -e .env.local -- npx tsx scripts/governance/g0-censo-de-sujetos.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/g1-derivar-grants-de-usuario.ts --apply # 24/24
npx dotenv -e .env.local -- npx tsx scripts/governance/g2-aplicador-de-usuario.ts # 22/22
npx dotenv -e .env.local -- npx tsx scripts/governance/g3-future-grants.ts # 5/5
npx vitest run lib/governance/membership-grants.test.ts # 20/20
curl -H "x-diag-key: $SPARK_BRIDGE_JWT_SECRET" https://app.paladio.io/api/diag/motor # userAuthz
Lo que cada uno prueba, que no es lo mismo que «pasa»:
| Gate | Qué demuestra |
|---|---|
g1 | La derivación decide distinto por rol, con control negativo (un ajeno recibe denegación de verdad) |
g2 | El aplicador decide · y el pre-vuelo pregunta por CADA sujeto, no por una muestra: 5/5 conservan su capacidad |
g3 | ⭐ Los future grants no hacen falta: 123/123 cadenas nacen en su workspace y 168 grants, ninguno sobre un dataset suelto |
| tests | La proyección retira además de conceder: degradar quita el rol viejo |
6 · El estándar
| Fuente | Qué se TOMA | Qué NO |
|---|---|---|
| Unity Catalog | La retícula sobre securables con herencia, y que la propiedad da todo sobre ese objeto | Su ABAC: no es OSS, es del producto gestionado |
| Apache Polaris | El vocabulario de privilegios — está calcado | Su grano fino: no tiene |
| Snowflake | «No ata privilegios a usuarios: los roles llevan los grants» · propiedad de rol · SECURITYADMIN ≠ SYSADMIN | GRANT … ON FUTURE: su modelo lo necesita, el nuestro no (②) |
| UC (operativo) | «No modifiques los grupos aquí: usa tu IdP» | — |
⭐ Las dos divergencias deliberadas, dichas enteras
- Un grant de
workspacealcanza al DATO. En UC los grants de metastore no se heredan al dato. Pero nuestroworkspacejuega el papel del CATÁLOGO de UC —que sí hereda—, no el del metastore. La divergencia es de nombre, no de modelo. ⚠️ El cabo real:WORKSPACE_MANAGE_ACCESS(la retícula) yWORKSPACE_MANAGE_CONTENT(el dato) conviven en un nivel donde uno debe heredar y el otro no. - Los humanos se DERIVAN y los servicios se DECLARAN. No es incoherencia: un servicio no está escrito en ninguna otra parte; una persona sí. La regla no es «derivar siempre», es «derivar cuando hay de qué».
7 · Lo que falta — con su gate
| Qué | Gate | |
|---|---|---|
| ⭐ | El canary del camino HTTP entero | Una petición real de un viewer a /api/sql-editor/execute que reciba 403. g2 mide la función que la puerta llama, no la petición |
| 🟡 | La propiedad como eje | Aplazada con motivo medido: ⛔ datasets.created_by no es «el dueño», es «quién lo tecleó» — 14 datasets lo tienen apuntando a un SERVICIO. Entra cuando haya una columna que signifique dueño y sea de un rol |
| 🟡 | Estrechar DML_VERBS_PERMITIDOS por objeto | Ya hay a quién conceder. La lista no se retira borrando locateDmlCommand: se retira cuando el permiso decide por objeto |
| 🟡 | agent y job como sujetos | El vocabulario ya los admite y no hay ni uno. Falta el patrocinador humano y el propósito acotado (Principal.purpose existe y no se usa) |
| 🟡 | La superficie del cliente | Que un cliente administre sus roles sin que intervenga nadie. WORKSPACE_MANAGE_ACCESS sólo lo tiene ws-owner |
| ⛔ | Grano fino | No es de esta pieza: es ROW-POLICY |
8 · Historial
| Versión | Fecha | Qué cambió de su NATURALEZA |
|---|---|---|
| v1.0 | 2026-08-12 | ⭐ Deja de ser inerte. Entran los sujetos humanos (derivados de la membresía), el aplicador de personas, y USER_AUTHZ=enforce. Y se cierra que los future grants no hacen falta |
| v0.3 | 2026-08-05 | La plantilla de inquilino: un workspace nace con sus roles de servicio |
| v0.2 | 2026-08 | La cara Iceberg REST empieza a aplicar (C5/N·3a) — deja de ser decoración |
| v0.1 | — | C3: la cadena de roles, calcada de Polaris. Nace inerte a propósito: «C3 sin C5 es decoración» |