G·6 — La gobernanza se escribe en SQL
Qué es esto. El approach para que roles, privilegios y políticas se administren
desde el SQL Editor, con una sola superficie y un solo punto de aplicación.Regla de la casa: cada afirmación lleva el comando que la demuestra.
0 · Dónde estamos
El orden acordado el 2026-08-15 tenía cinco pasos. Los cinco están cerrados, y el quinto es este documento.
| gate | ||
|---|---|---|
| 🏁 ① | OWNERSHIP — privilegio exclusivo, y decideGrantAuthority como respuesta a «¿quién puede conceder?» | g5-1 · privilege.test.ts |
| 🏁 ② | Roles en Keycloak con composites — ws-owner ⊃ … ⊃ ws-guest, verificado por expansión | k6 · k9 |
| 🏁 ③ | Aplicador con catalog_role dedicado — con control negativo | g5-2 |
| 🏁 ④ | Grants por objeto — POST /api/governance/grants | pestaña Permissions |
| 🏁 ⑤ | USE SCHEMA — la travesía | g6-2 · g6-5 en vivo |
⑤ estaba marcado como «el cerrojo, cuando ya haya puerta», con una condición: se decide antes del primer grant de objeto. Se decidió ahí, y se cerró.
⏭️ Lo que queda de ⑤ es MANAGE GRANTS como privilegio propio: hoy la autoridad para
conceder la da decideGrantAuthority (OWNERSHIP o *_MANAGE_ACCESS), que es suficiente y
está probado, pero no es un privilegio que se pueda delegar por separado.
1 · La norma del sector, y dónde estamos respecto a ella
Los tres modelos de referencia coinciden en la estructura y discrepan en un punto, que es justamente el que hay que decidir.
| Snowflake | Databricks UC | Apache Polaris | Carbon (hoy) | |
|---|---|---|---|---|
| Destinatario de un grant | rol | grupo/principal | catalog role | principal role |
| Herencia hacia abajo | sí | sí | sí | sí |
| ¿Hace falta atravesar el contenedor? | sí (USAGE) | sí (USE CATALOG/USE SCHEMA) | no, aditivo | sí 🏁 (CATALOG_USE/SCHEMA_USE) |
| Propiedad | OWNERSHIP, un rol | owner, un principal | — | OWNERSHIP, un rol 🏁 |
| Quién reparte permisos | MANAGE GRANTS | owner / metastore admin | *_MANAGE_ACCESS | decideGrantAuthority 🏁 |
En todo lo demás ya estábamos en el estándar. En la travesía no: éramos Polaris, y el SQL que se quería escribir es dialecto Databricks. Ese es el hueco que cierra G·6.
GRANT USE CATALOG ON CATALOG workspace TO ROLE data_analyst_role;
GRANT USE SCHEMA ON SCHEMA workspace.public TO ROLE data_analyst_role;
GRANT SELECT ON SCHEMA workspace.public TO ROLE data_analyst_role;
Ese SQL presupone prerrequisitos. Con el motor aditivo, las dos primeras sentencias escribirían filas, dirían «ok» y quitarlas no cerraría ninguna puerta.
⛔ Aceptar esa sintaxis sin cambiar el motor produce SQL que aparenta proteger. Es el
peor resultado posible de este frente: peor que no tenerlo, porque genera confianza.
Decisión: se adopta el modelo con prerrequisitos. No por seguir a Databricks, sino
porque es lo que hace que un solo REVOKE USE SCHEMA corte el acceso a todo lo de dentro
en vez de obligar a revocar tabla por tabla. La travesía es el único mecanismo que
convierte «quitar permisos» en una operación acotada.
2 · La premisa, medida antes de tocar el motor
Añadir un prerrequisito retira acceso a quien no lo cumple. Es la única clase de
cambio de este motor que puede romper lecturas que hoy funcionan, y romperlas en silencio
para quien las tenía: un git revert no devuelve las consultas que fallaron.
npx dotenv -e .env.local -- npx tsx scripts/governance/g6-0-premisa-prerequisitos.ts
grants por nivel: workspace 224 · schema 24 · dataset 0
sujetos con acceso a datos: 15
conservan el acceso : 14
⛔ lo perderían : 1 → service:lakekeeper-trino
⛔⛔ ESTA ESTIMACIÓN ERA FALSA — y por poco cuesta un incidente
g6-2-travesia-en-vivo.tsvolvió a preguntar, pero adecidePrivilegey sobre la
cadena real de un dataset real. El veredicto: 5 perdían, no 1. Y entre ellos
lakekeeper-spark-editor— el único actor vivo, 5.514 eventos, el último el mismo día.
Desplegar con el número de arriba habría roto el SQL Editor en producción.Dos causas, y las dos del instrumento:
· la consulta sólo traía seis privilegios, y se dejaba fueraSCHEMA_LIST, que sí implica travesía;
· las implicaciones estaban reescritas a mano en el script en vez de preguntarle al motor.🪤 Un instrumento que reimplementa lo que mide, mide su propia reimplementación.
Ya había pasado con los reconocedores privados de la route (M3·F0), y volvió a pasar.⇒
g6-0se conserva porque su lectura estructural (§ siguiente) sigue siendo correcta y
es la que justificó la decisión. Para el radio de daño, la fuente esg6-2.
Un test fija ahora esa lista contraexpandPrivilege, para que no puedan divergir otra vez.
Tres cosas se leen de ahí, y las tres mandan sobre el diseño:
- ⭐ Hay 0 grants de
dataset. El modelo aditivo nunca se ha ejercido sobre un objeto. El cambio se hace antes de que exista nada que romper — la mejor ventana que va a haber. - El radio de daño es acotado y tiene nombres — cinco servicios, ninguna persona (ver el recuadro de arriba: el número correcto es 5, no 1).
- La travesía se IMPLICA, no se concede. 10 catálogos × 16 schemas × 15 sujetos son
390 filas si se sembrara una a una. Quien tiene
CATALOG_MANAGE_CONTENTsobre el workspace no necesita permiso para entrar en su propia casa.
Los sujetos en riesgo — cinco, y uno vivo
Veredicto del motor (g6-2):
conservan acceso a datos : 8
⛔ pierden por travesía : 5
lakekeeper-spark-editor ← ⚠️ VIVO: 5.514 eventos, el último hoy
lakekeeper-spark-maintenance
lakekeeper-spark
lakekeeper-duck-server
lakekeeper-trino
A los cinco les faltaba CATALOG_USE, y a ninguno SCHEMA_USE. Tiene sentido: USAGE ON SCHEMA ya se traducía a SCHEMA_LIST, que lo implica —la decisión de continuidad
funcionó—, mientras que atravesar un catálogo no era un concepto que existiera, así que
nada podía implicarlo.
Sembrado CATALOG_USE en los cuatro paquetes afectados (reader, reader-default-schema,
maintenance, dml-writer) y re-medido con el motor: 0 sin travesía.
⚠️ Se sembró en paquetes compartidos, y aquí es correcto aunque en cualquier otro caso
sería el arrastre de §3·1: la travesía no concede nada por sí sola —hay test que lo
fija, expandPrivilege('CATALOG_USE').size === 1—. Un permiso de datos no se comparte; un
permiso de paso, sí.
⚠️ Y lakekeeper-trino sigue dormido, no muerto: 256 accesos, el último el 2026-08-08,
el día antes de que se decidiera «Trino fuera». Se le siembra la travesía igualmente —
apagar un motor por omisión, mientras se cambia otra cosa, mezcla dos decisiones.
3 · Dos deudas que este frente destapa y no resuelve
① El prefijo lakekeeper- es un fósil. Los 8 principals de servicio se llaman
lakekeeper-spark, lakekeeper-trino, lakekeeper-duck-server… y el catálogo es
Gravitino desde el cutover. No es cosmético del todo: un nombre que miente sobre qué
sistema hay detrás manda a leer el código equivocado cuando algo falla. Renombrar un
principal es rotar una identidad —tocar Keycloak, los grants y la configuración de cada
motor—, así que es un frente propio, no un sed.
② Dos de esos ocho apuntan a motores retirados. lakekeeper-duck-server (DuckDB salió
del read-path) y lakekeeper-trino. Una identidad de servicio con grants vivos y sin
servicio detrás es una credencial sin dueño.
4 · El approach, por pasos verificables
Cada paso deja un gate. Ninguno depende de que el siguiente salga bien.
🏁 G·6·1 · El vocabulario y el motor
Dos privilegios nuevos, CATALOG_USE y SCHEMA_USE, y una regla en decidePrivilege:
para ejercer un privilegio sobre un objetivo hay que poder atravesar cada contenedor de
su cadena.
Los agregados existentes los implican, y eso es lo que hace el cambio seguro:
CATALOG_MANAGE_CONTENT, SCHEMA_FULL_METADATA y OWNERSHIP ya significan gobernar lo
de dentro.
⚠️ Un grant en la raíz no exige travesía: por encima del workspace no hay nada que atravesar. Sin esa excepción, los 224 grants de workspace dejarían de valer.
Gate: los tests fijan el radio de daño medido — que 14 sujetos conservan el acceso y que el 15º es exactamente el conocido. Un test que sólo comprobara «con USE se puede» no distinguiría el modelo nuevo del viejo.
🏁 G·6·2 · Sembrar la travesía — y MEDIR con el motor, no con una copia
Antes de encender la regla, no después. Y preguntando a decidePrivilege sobre la cadena
real: es el paso que destapó que la estimación se quedaba corta por cuatro.
🏁 G·6·3 · El parser crece
grant-sql.ts ya parsea
GRANT/REVOKE … ON TABLE|SCHEMA … TO/FROM rol, con 17 tests. Le faltan:
| sentencia | nota |
|---|---|
CREATE ROLE r · DROP ROLE r | DDL de principal_roles |
GRANT USE CATALOG ON CATALOG c TO ROLE r | securable CATALOG, hoy ausente del parser |
GRANT USE SCHEMA ON SCHEMA s TO ROLE r | hoy se llama USAGE; se admiten los dos y se documenta cuál es el canónico |
GRANT SELECT ON SCHEMA s TO ROLE r | hoy SELECT sólo se admite sobre tabla |
GRANT ROLE r TO 'persona@x' | ⭐ no viola «a roles, no a personas»: eso prohíbe conceder privilegios a una persona. Meter a alguien en un rol es el mecanismo correcto, y es lo que faltaba |
SHOW ROLES · SHOW GRANTS ON ROLE r · SHOW CURRENT ROLES | lectura; devuelven un resultset, no un efecto |
TO ROLE r | la palabra ROLE explícita, que el parser hoy no admite |
⚠️ El mapeo se mantiene corto a propósito. Cada entrada nueva es una decisión sobre qué
puede hacer alguien; añadirlas «por completitud» es cómo se conceden permisos que nadie
pidió. SELECT seguirá concediendo dos privilegios (TABLE_READ_DATA +
TABLE_READ_PROPERTIES), porque el primer 403 medido contra el catálogo fue «falta
TABLE_READ_PROPERTIES»: sin él no se carga ni la tabla.
🏁 G·6·4 · La intercepción, antes del motor
El editor desvía la sentencia de gobernanza a Index sin que llegue a Spark.
«Un
GRANTes una operación de GOBIERNO, no de datos. Si llegara a Spark, escribiría
permisos en el catálogo — un segundo plano de autorización que Index no conoce.»
— cabecera degrant-sql.ts, escrita antes de que existiera este documento
Hoy grant-sql.ts tiene cero llamantes: está escrito, probado e inerte. Y los tres
verbos se rechazan por accidente, no por decisión: GRANT da plan-failed, REVOKE
interpreta el rol como una tabla, SHOW GRANTS da no-tables.
La autorización de la propia sentencia es la que ya existe: decideGrantAuthority sobre
la cadena del objeto. Escribir el GRANT en SQL no lo hace más permitido.
🏁 G·6·5 · El gate en vivo — el que de verdad importa
Con una persona real y una tabla real:
CREATE ROLE, los tresGRANT,GRANT ROLE … TOla persona;- una lectura de esa tabla pasa;
REVOKE USE SCHEMA;- la misma lectura falla.
⭐ El paso 4 es el gate. Sin él tendríamos SQL que escribe filas y nadie sabría si cierran algo — que es exactamente lo que este documento vino a evitar. Es el mismo control negativo que salvó al gate del rol dedicado: un gate que sólo comprueba el camino feliz aprueba siempre.
5 · Lo que este approach NO hace
- No mueve el punto de aplicación. Quien aplica sigue siendo la cara sobre
/scan; esto sólo cambia qué decidedecidePrivilegey por dónde se autora. - No toca las políticas de celda.
row_filterycolumn_masktienen su propio frente (F·2) y su propia gramática. - No introduce
deny. El modelo sigue siendo aditivo dentro de cada nivel: la travesía es un prerrequisito, no una negación. Unforbidexplícito sería un segundo motor de política, que es lo que se descartó con Cedar y con OpenFGA. - No renombra los principals (§3). Se mide y se deja escrito.
6 · Comandos
# la premisa, antes de tocar el motor
npx dotenv -e .env.local -- npx tsx scripts/governance/g6-0-premisa-prerequisitos.ts
# el plano de permisos, para leer el estado
npx dotenv -e .env.local -- npx tsx scripts/governance/g2-madurez-del-plano.ts
npx dotenv -e .env.local -- npx tsx scripts/governance/g3-acoplamiento-de-roles.ts
# el circuito de identidad (exige sesión activa de la persona)
CLERK_SECRET_KEY=sk_live_… KC_ADMIN_USER=… KC_ADMIN_PASS=… \
npx tsx scripts/governance/k9-gate-canje-en-vivo.ts <email>
# los trinquetes
npm run check:sujetos
npm run check:superficie
# en frío
npx vitest run lib/governance # 401
npx tsc --noEmit