Published

G·6 — La gobernanza se escribe en SQL

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

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 compositesws-owner ⊃ … ⊃ ws-guest, verificado por expansiónk6 · k9
🏁 ③Aplicador con catalog_role dedicado — con control negativog5-2
🏁 ④Grants por objetoPOST /api/governance/grantspestaña Permissions
🏁 ⑤USE SCHEMA — la travesíag6-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.

SnowflakeDatabricks UCApache PolarisCarbon (hoy)
Destinatario de un grantrolgrupo/principalcatalog roleprincipal role
Herencia hacia abajo
¿Hace falta atravesar el contenedor? (USAGE) (USE CATALOG/USE SCHEMA)no, aditivo 🏁 (CATALOG_USE/SCHEMA_USE)
PropiedadOWNERSHIP, un rolowner, un principalOWNERSHIP, un rol 🏁
Quién reparte permisosMANAGE GRANTSowner / metastore admin*_MANAGE_ACCESSdecideGrantAuthority 🏁

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.ts volvió a preguntar, pero a decidePrivilege y 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 fuera SCHEMA_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-0 se 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 es g6-2.
Un test fija ahora esa lista contra expandPrivilege, para que no puedan divergir otra vez.

Tres cosas se leen de ahí, y las tres mandan sobre el diseño:

  1. 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.
  2. 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).
  3. 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_CONTENT sobre 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:

sentencianota
CREATE ROLE r · DROP ROLE rDDL de principal_roles
GRANT USE CATALOG ON CATALOG c TO ROLE rsecurable CATALOG, hoy ausente del parser
GRANT USE SCHEMA ON SCHEMA s TO ROLE rhoy se llama USAGE; se admiten los dos y se documenta cuál es el canónico
GRANT SELECT ON SCHEMA s TO ROLE rhoy 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 ROLESlectura; devuelven un resultset, no un efecto
TO ROLE rla 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 GRANT es 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 de grant-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:

  1. CREATE ROLE, los tres GRANT, GRANT ROLE … TO la persona;
  2. una lectura de esa tabla pasa;
  3. REVOKE USE SCHEMA;
  4. 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é decide decidePrivilege y por dónde se autora.
  • No toca las políticas de celda. row_filter y column_mask tienen 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. Un forbid explí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