Runbook · perímetro del storage, pasos A y B
Ejecuta storage-perimeter.md §2·A (una sola
fuente) y §2·B (separar READ de WRITE).El código ya está hecho y desplegable. Lo que queda aquí son las acciones en los
dashboards de Cloudflare y Railway, que no se pueden automatizar desde el repo.Orden pensado para que no haya ventana de rotura: el servicio arranca igual antes y
después de cada paso.
Qué cambió en el código (ya en main)
duck-server acepta un par de credenciales de sólo lectura, opcional:
LAKEHOUSE_S3_RO_ACCESS_KEY_ID
LAKEHOUSE_S3_RO_SECRET_ACCESS_KEY
- Si están → la
SECRETde R2 de DuckDB se crea con ellas, y el log de arranque dicer2 secret usa la credencial de SOLO LECTURA (perimetro B). - Si no están → cae a las read-write actuales y emite un WARNING nombrando qué falta. El servicio arranca igual.
Esa caída es deliberada: permite desplegar el código antes de que el token exista.
Paso 1 · Desplegar el código (sin efecto observable)
duck-server recoge el cambio. Sigue usando la credencial compartida; lo único nuevo es
el WARNING en el arranque, que es precisamente el marcador de que B está pendiente.
- Redesplegar
duck-serveren Railway - En los logs de arranque debe aparecer:
r2 secret usa la credencial COMPARTIDA read-write…
Paso 2 · B · Crear el token de sólo lectura en Cloudflare
Cloudflare → R2 → Manage API tokens → Create API token
| Campo | Valor |
|---|---|
| Nombre | duck-server-readonly |
| Permissions | Object Read only |
| Bucket | lakehouse (no «todos los buckets») |
| TTL | sin caducidad, o la que exija tu política |
Guarda el Access Key ID y el Secret Access Key que devuelve.
Por qué acotarlo al bucket: con «todos los buckets» la credencial alcanza cualquier
cosa de la cuenta.duck-serverya limita su alcance por prefijo (DUCK_S3_SCOPE), pero
esa defensa vive dentro del proceso; ésta vive en Cloudflare y no se puede eludir desde
dentro.
Paso 3 · B · Ponerlas en duck-server
Railway → duck-server → Variables, como valores literales (no reference vars: son
su propio secreto, y ml-runner no debe tenerlas — él es quien escribe):
LAKEHOUSE_S3_RO_ACCESS_KEY_ID = <access key del paso 2>
LAKEHOUSE_S3_RO_SECRET_ACCESS_KEY = <secret del paso 2>
🔴 Este paso se dio por hecho, y no lo estaba (medido el 2026-08-05)
Las variables RO estaban puestas en Railway, el log decía
perimetro B… y su valor
era idéntico al deLAKEHOUSE_S3_ACCESS_KEY_ID.duck-serverllevaba, y lleva,
una llave que puede escribir todo el warehouse.Por qué no se detectó: la comprobación que había aquí ejecutaba
facade-smoke.ts, que escribe porml-runner. Eso mide que el escritor sigue
escribiendo — no que la llave deduck-serverhaya dejado de poder hacerlo. Un
instrumento que mide otra cosa da verde y tranquiliza, que es lo peor que puede
hacer. Sustituido abajo por un control negativo sobre la credencial real.
- Redesplegar
- El log de arranque debe decir
r2 secret usa la credencial de SOLO LECTURA (perimetro B)— y no debe aparecerPERIMETRO ABIERTO: … es EL MISMO valor que … -
GET /healthdeduck-serverdevuelve"storage_credential": "read_only"(si devuelveshared_read_write, las dos llaves coinciden) - ⭐ El control negativo — es lo que da valor al paso; sin él sólo has cambiado una variable:
npx dotenv -e .env.local -- npx tsx scripts/storage/duck-ro-credential-gate.ts
Comprueba las cuatro cosas, y las dos últimas son el gate: que las llaves sean distintas, que la RO lea, que NO pueda escribir y que NO pueda borrar — exigiendo en ambos casos un rechazo por permiso, no un fallo cualquiera.
- Y que no se haya roto lo de siempre: la escritura por
ml-runnersigue viva (npx dotenv -e .env.local -- npx tsx scripts/dataspaces/facade-smoke.ts). Si falla, has puesto las RO en el servicio equivocado.
Paso 4 · A · Una sola fuente para el resto de LAKEHOUSE_S3_*
Railway → duck-server → Variables. Sustituir los valores literales por referencias a
ml-runner, que es el propietario del secreto:
LAKEHOUSE_S3_ENDPOINT = ${{ml-runner.LAKEHOUSE_S3_ENDPOINT}}
LAKEHOUSE_S3_REGION = ${{ml-runner.LAKEHOUSE_S3_REGION}}
LAKEHOUSE_S3_ACCESS_KEY_ID = ${{ml-runner.LAKEHOUSE_S3_ACCESS_KEY_ID}}
LAKEHOUSE_S3_SECRET_ACCESS_KEY = ${{ml-runner.LAKEHOUSE_S3_SECRET_ACCESS_KEY}}
Las dos últimas quedan como fallback inerte una vez B está puesto — pero referenciadas, no copiadas, para que una rotación futura no deje una copia rancia detrás.
- Aplicado y redesplegado
- Ninguna variable de
duck-servercontiene un secreto literal salvo las dos RO
Por qué esto importa aunque «funcione igual»: el valor copiado y el original son
indistinguibles hasta el día que uno cambia. La divergencia no se nota al copiar: se nota
en la rotación, en producción, y a las 3 de la mañana.
Lo que queda fuera de A y B, y no conviene olvidar
- C · el trinquete
check:storage-perimeter— sin él, el perímetro vuelve a crecer sin que nadie lo decida. - D · rotar las credenciales actuales. El
client_secretdel catálogo y las llaves de R2 pasaron por el historial de una conversación. B reduce el radio de daño; no lo elimina. - La grieta del EDC:
services/edc-service/run-cycle-r2.shsigue entregando la credencial plena en el payload del transfer. Es un ciclo local, pero es el patrón que se está ensayando para Gaia-X — ver storage-perimeter.md §4.