Published

Runbook · perímetro del storage, pasos A y B

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...

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 SECRET de R2 de DuckDB se crea con ellas, y el log de arranque dice r2 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-server en 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

CampoValor
Nombreduck-server-readonly
PermissionsObject Read only
Bucketlakehouse (no «todos los buckets»)
TTLsin 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-server ya 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 de LAKEHOUSE_S3_ACCESS_KEY_ID. duck-server llevaba, 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 por ml-runner. Eso mide que el escritor sigue
escribiendo — no que la llave de duck-server haya 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 aparecer PERIMETRO ABIERTO: … es EL MISMO valor que …
  • GET /health de duck-server devuelve "storage_credential": "read_only" (si devuelve shared_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-runner sigue 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-server contiene 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_secret del 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.sh sigue 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.