carbon-r2-credential — el vending temporal de R2 para el IRC de Gravitino
Qué desbloquea. El cutover del catálogo (Lakekeeper ⇒ Gravitino IRC) estaba parado en
C·2·b: Lakekeeper vende credenciales temporales (~855 s, cons3.session-token) y
el IRC contra R2 sólo sabía entregars3-secret-key, la llave larga. Migrar así habría
sido una regresión de seguridad. Este jar iguala la paridad.Encuadre:
../../docs/architecture/irc-cutover-approach.md
§6·septies (la decisión) y §6·octies (B·0, lo medido contra R2 real).
Cómo funciona
Acuña la temporal por la vía JWT local que documenta Cloudflare — sin una sola llamada de red, que es lo que protege el argumento de latencia de todo el cutover:
claims {"bucket":…,"scope":…,["paths":{"prefixPaths":[…],"objectPaths":[]},]
"sub":cuenta,"iss":parentAccessKeyId,"aud":host,"iat":…,"exp":…}
firma HS256 con el PARENT SECRET ACCESS KEY
deriva secretAccessKey = SHA-256 hex del JWT
sessionToken = base64("jwt/" + jwt)
accessKeyId = el parent access key id, tal cual
El scope y los prefixPaths salen del PathBasedCredentialContext que el IRC ya
construye con la ubicación de la tabla: lectura vs escritura según qué conjunto llene,
y el prefijo de la tabla como ámbito. Medido en B·0 contra el bucket real: el mismo token,
contra un objeto fuera de su prefijo, da 403 AccessDenied.
Configurar
gravitino.iceberg-rest.credential-providers = r2-token
⭐ No hace falta ningún secreto nuevo. Reutiliza lo que el .conf ya tiene para R2:
| Propiedad | |
|---|---|
s3-endpoint | obligatoria — de ella sale el aud y, por defecto, la cuenta |
s3-access-key-id | obligatoria — el parent, que es también el accessKeyId de salida |
s3-secret-access-key | obligatoria — la clave HMAC. No se emite nunca |
r2-account-id | opcional — por defecto, el primer segmento del host del endpoint |
r2-credential-ttl-seconds | opcional — por defecto 900, comparable a los ~855 s de Lakekeeper |
Construir
docker build --target gate . # el gate de B·1 — falla el build si algo falla
docker build --target jar -o out . # deja out/carbon-r2-credential.jar (~8 KB)
⭐ Se compila contra la imagen que se despliega (apache/gravitino-iceberg-rest:1.2.0),
no contra Maven Central: el classpath del build es literalmente el libs/ que monta el pod,
así que «compila» y «carga en el pod» son la misma afirmación.
⭐ Cero dependencias de runtime. HS256 es javax.crypto.Mac y el JSON de los claims son
dos StringBuilder. Este jar aterriza junto a 174 jars ajenos: cada dependencia añadida
sería una colisión de versiones esperando su turno.
Tres decisiones que no son de estilo
① El tipo es r2-token, no s3-token. Censado con ServiceLoader sobre el classpath
real, la distribución ya declara 9 providers:
adls-token · aws-irsa · azure-account-key · gcs-token · oss-secret-key
oss-token · s3-secret-key · s3-token (+ el nuestro, r2-token)
CredentialProviderFactory resuelve por tipo y revienta con «Multiple credential
providers found» si dos coinciden. El nombre tiene que ser nuestro.
② Pero la credencial devuelta es la S3TokenCredential DE SERIE.
CredentialPropertyUtils.toIcebergProperties despacha por instanceof de la clase
concreta, no por el string del tipo: una Credential propia caería al else y el cliente
recibiría s3-session-token (nombre interno de Gravitino) en vez de s3.session-token
(el que lee Iceberg) — arrancaría todo igual y la credencial no se usaría. Se calca el
patrón en la FORMA y se deriva el MECANISMO.
③ Si no puede acotar, no vende. Contexto sin rutas, o rutas de dos buckets distintos ⇒ excepción. La salida cómoda —vender el bucket entero— es exactamente la regresión que este jar existe para evitar: una credencial que no acota es la llave larga con fecha.
El gate (B·1)
src/gate/java/…/B1Gate.java, y corre dentro del build: un jar que no pasa B·1 no
llega a existir. Comprueba las cuatro cosas que fallan con un 403 en producción y con nada
al arrancar:
| ① determinismo | mismas entradas ⇒ mismo JWT ⇒ mismo secreto (con control negativo: otro iat ⇒ otro secreto) |
| ② vector dorado | el mismo token, byte a byte, que produce la sonda TS de B·0 (scripts/warehouse/b1-vector-dorado.ts). El secreto es el SHA-256 del JWT serializado ⇒ el orden de los claims es contrato, no estética |
| ③ la traducción | toIcebergProperties emite s3.access-key-id · s3.secret-access-key · s3.session-token, y no filtra nombres internos |
| ④ el ámbito | lectura/escritura y prefijo salen del contexto; rechaza no acotar y rechaza dos buckets |
| ⑤ el registro | censo por ServiceLoader sobre el classpath real |
⏭️ Lo que falta
B·2 — el jar al libs/ del IRC (mismo mecanismo de initContainer que C·0·a) y
credential-providers = r2-token en los 9 catálogos-de-inquilino.
B·3 — re-correr C·2: Spark escribe y lee con vended-credentials.
B·4 — paridad declarada con Lakekeeper.
⚠️ Lo que este gate NO prueba: que la credencial acuñada por el Java sea aceptada por R2 en vivo. Lo prueba por transitividad —produce el mismo JWT byte a byte que la sonda de B·0, y el secreto es función pura de esa cadena—, pero el 200 con bytes reales lo da B·3.