Published

carbon-r2-credential — el vending temporal de R2 para el IRC de Gravitino

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

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, con s3.session-token) y
el IRC contra R2 sólo sabía entregar s3-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-endpointobligatoria — de ella sale el aud y, por defecto, la cuenta
s3-access-key-idobligatoria — el parent, que es también el accessKeyId de salida
s3-secret-access-keyobligatoria — la clave HMAC. No se emite nunca
r2-account-idopcional — por defecto, el primer segmento del host del endpoint
r2-credential-ttl-secondsopcional — 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:

determinismomismas entradas ⇒ mismo JWT ⇒ mismo secreto (con control negativo: otro iat ⇒ otro secreto)
vector doradoel 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 serializadoel orden de los claims es contrato, no estética
la traduccióntoIcebergProperties emite s3.access-key-id · s3.secret-access-key · s3.session-token, y no filtra nombres internos
el ámbitolectura/escritura y prefijo salen del contexto; rechaza no acotar y rechaza dos buckets
el registrocenso 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.