P4-bis · La máquina de acuñar, como superficie propia
Approach. Nada implementado. Cierra las dos excepciones que quedaron abiertas en
storage-tenancy-approach.md §6·P4:warehouse-writery
duck-serverconservan llaves estáticas de R2.Y nace de una crítica al diseño actual, no de una funcionalidad nueva: al preguntarse
«¿cómo resolvería esto AWS?» aparecieron dos errores nuestros de encuadre. Están en §2 y
§3, y los dos hacen el problema más pequeño de lo que parecía.
1 · Lo que AWS prescribe, y qué parte no teníamos
Su patrón —token vending machine— se resume en una frase: ningún proceso que sirva a un inquilino sostiene una credencial duradera. El rol de la tarea no abre S3 en absoluto; para tocar bytes hay que pedir una credencial acotada (prefijo y acciones, con TTL). El aislamiento deja de ser una convención y pasa a ser estructural: no es que el motor no deba salirse de su inquilino — es que no puede.
Nosotros tenemos la máquina… colgada del sitio equivocado:
| AWS | Nosotros hoy | |
|---|---|---|
| Qué es la TVM | un endpoint propio: «credencial para inquilino T, prefijo P, operaciones O» | un efecto secundario de loadTable con X-Iceberg-Access-Delegation |
| Quién puede pedirla | cualquiera que se autentique y pase la política | sólo quien haga loadTable por la cara Iceberg REST |
| Lectura y escritura | la session policy nombra las acciones | sólo lectura |
⭐ Ése es el hallazgo. «El writer no tiene a quién pedirle la llave» no es una
limitación del patrón: es una consecuencia de dónde colgamos la máquina. Extraerla a
su propia superficie convierte aloadTableen un cliente más, y abre la puerta a los
otros dos.
2 · 🔴 Primer error de encuadre: metadata y bytes son DOS problemas
Se dijo que duck-server no podía soltar su llave porque tendría que atarse a la cara, y
que eso chocaba con P1 —«DuckDB ata un catálogo por conexión, y ahora hay un warehouse por
inquilino»—. Eso es cierto para la METADATA y falso para los BYTES:
- qué tablas existen y quién puede verlas → lo resuelve el catálogo (atarse a la cara);
- quién puede leer los bytes → lo resuelve la credencial acuñada.
Separados, la colisión desaparece: duck puede seguir resolviendo metadata como hoy y pedir
credencial por consulta para los bytes. DuckDB lo soporta —secretos con SESSION_TOKEN y
con SCOPE por prefijo, que ya usamos desde P4— así que no hay que re-atar nada.
⚠️ El bypass de catálogo de duck sigue siendo un problema real —se ata a Lakekeeper y se salta la cara, que es el mismo agujero que cerramos para Trino— pero es otro, y no bloquea quitarle la llave. Va por su propio approach.
3 · 🔴 Segundo error: el writer NO es infraestructura
Se ofreció «aceptar que el writer conserve su llave, igual que Lakekeeper». Con el criterio de AWS no se sostiene, y la distinción es limpia:
| Puede sostener credencial duradera | |
|---|---|
| Plano de control — la propia TVM, el catálogo | ✅ sí: es quien la reparte |
| Cómputo por cuenta de un inquilino — writer, duck, Trino, Karma | ❌ no, es la categoría que el patrón elimina |
warehouse-writer está en la segunda fila. Que hoy tenga llave no es una excepción
justificada: es la excepción que este approach existe para quitar.
4 · ⚡ La pregunta de la latencia: ¿no es caro pedir credencial en cada escritura?
Es la objeción correcta, y la respuesta tiene dos mitades.
La primera: no se pide por consulta — se pide por PREFIJO y se cachea. Acuñar cuesta una llamada a Cloudflare (~100-300 ms, medido) y el TTL por defecto es de 1 hora, con la caché sirviendo hasta 60 s antes de caducar. Una tabla que se escribe cien veces en una hora paga una vez. Y en el writer la comparación es aún más favorable: una ingesta mueve filas, hace commit y toca R2 durante segundos — 200 ms una vez por tabla y hora es ruido, no coste.
La segunda, y es la que decide el diseño: se puede acuñar SIN LLAMAR A NADIE. La documentación de R2 describe una alternativa a su API — firmar localmente: se construye un JWT, se firma con HS256 usando el secreto del token padre, y el secreto temporal sale de un SHA-256 de ese JWT. Tres consecuencias:
- latencia ≈ 0: es criptografía en proceso, sin viaje de red;
- no consume el presupuesto de 1.200 peticiones cada 5 minutos por cuenta — que es el techo que de verdad nos preocupa cuando haya cientos de inquilinos;
es la única vía que soporta→ ver el resultado de F1.actions
⇒ La escritura debe ir por firmado local desde el principio. Es lo que hace que la objeción de la latencia deje de existir.
✅ F1 corrido — 5 verdes / 0 rojos (2026-08-04), y con una promesa caída
① acuñada en 0,94 ms — sin viaje de red
② 200 — lee un objeto DENTRO del prefijo (1.726 bytes)
③ ⭐ AccessDenied — y NO puede leer uno FUERA
④ ⭐⭐ AccessDenied — y con `scope` de lectura NO puede escribir ni en su propio prefijo
0,94 ms frente a 100-300 ms. La objeción de la latencia deja de existir: acuñar por
escritura es más barato que el propio JSON.parse de la petición.
🔴 Pero
actionsNO funciona. La documentación la anuncia como exclusiva del firmado
local («currently supported via local signing only») y era el argumento principal de
esta fase. Medido: R2 devuelveInvalidArgument: X-Amz-Security-Tokenen las cinco
variantes probadas — nombres del propio ejemplo (GetObject,HeadObject), con
prefijos3:, sola y junto ascope.Es el mismo género de hallazgo que Cedar: documentado, anunciado en las notas de
versión, y ausente en el producto. Y se caza igual — intentándolo con un control
negativo, no leyendo.Qué cambia: nada de lo que importa. El preset
scopedistingue
object-read-onlydeobject-read-write, que es exactamente la granularidad que F3 y F4
necesitan — un motor que lee frente a un escritor que commitea. Lo que se pierde es el
refinamiento por operación, que era un nice to have.signR2Credentiallanza si
alguien pasaactions, para que el próximo que lea la documentación no repita la media
hora.
🔎 Y una trampa de forma, por si alguien la reimplementa: las dos listas de
paths
—prefixPathsyobjectPaths— van siempre, aunque una esté vacía. Omitir la vacía
produce el mismoInvalidArgumentopaco, que no menciona el campo que falta.
5 · Dónde el patrón de AWS NO se puede portar
| Lo que AWS da | En R2 |
|---|---|
| STS + session policy con acciones | no hay STS. Presets + prefijos — y actions tampoco por firmado local, pese a estar documentado (F1) |
| Bucket policies como segunda red («deny salvo que el prefijo coincida») | no existen |
| Session tags → atribución por inquilino en CloudTrail | 🔴 no existe. R2 devuelve el accessKeyId del token padre: en el almacenamiento todos los accesos se ven iguales |
La tercera es la que más pesa, y está medida. Nuestra única atribución vive en
access_events, así que cada camino que lea o escriba sin pasar por la puerta es un
acceso que nadie podrá atribuir jamás, ni a posteriori. Eso sube el listón de este
approach: no es higiene, es la condición para que la auditoría signifique algo.
6 · Las fases
F1 · El firmado local, medido — ✅ HECHO (2026-08-04) · 5 verdes
Implementar la alternativa sin API y comprobarla con el mismo control negativo que el
spike: leer dentro del prefijo, y que leer fuera dé AccessDenied.
Gate: 3 verdes como el spike de la API, más uno nuevo — que una credencial firmada
localmente con actions de sólo lectura no pueda escribir.
F2 · La máquina, como superficie propia — ✅ HECHO (2026-08-04) · 7 verdes
Extraer mintTableCredential a una entrada interna con su política: TABLE_READ_DATA para
leer, TABLE_WRITE_DATA para escribir — el privilegio existe desde C3 y hoy no abre
nada. loadTable con delegación pasa a ser un cliente más.
Gate negativo: un principal con TABLE_READ_DATA y sin TABLE_WRITE_DATA que pida
credencial de escritura no la recibe. Sin esto sólo se ha movido código de sitio.
F3 · El writer pide RW al commitear
Acotada al prefijo de su tabla. Su llave estática se retira del entorno.
Gate: facade-smoke verde sin LAKEHOUSE_S3_* en el entorno del writer. Las dos
mitades cuentan: sin la primera, la segunda es una avería en diferido.
F4 · Duck pide RO por consulta
Instalando el secreto con SESSION_TOKEN y SCOPE al prefijo. Su llave estática se retira.
Gate: el SQL Editor lee, y grep '^LAKEHOUSE_S3_' en el entorno de duck da cero.
F5 · El trinquete, medido donde vive la verdad
Ningún servicio puede tener LAKEHOUSE_S3_ACCESS_KEY_ID salvo el que sostiene la
credencial padre. Y se comprueba contra Railway, no contra el repo — la lección de hoy
con AUTHZ_BACKEND=allowall: el repo dice lo que se quiso; el proceso dice lo que hay.
7 · Lo que este approach NO cubre
- El bypass de catálogo de
duck-server(se ata a Lakekeeper, no a la cara). Es el mismo agujero que se cerró para Trino, y va aparte — §2. - La atribución por inquilino en el almacenamiento. R2 no la da y no se puede arreglar desde aquí; lo único que se puede hacer es que ningún acceso ocurra fuera de la puerta.
- El cifrado por inquilino. Otra frontera, otro approach.