Saltar al contenido principal
Versión: main (sin publicar)

core-storage

Qué problema resuelve

Subir archivos a través del backend es la forma cara de hacerlo: consume memoria y ancho de banda del servicio para mover bytes que podrían ir directo al object storage. Y hacerlo bien —URLs prefirmadas, metadata, permisos— es suficientemente tedioso como para que cada proyecto lo reimplemente distinto.

core-storage implementa el flujo de tres pasos: el cliente pide una URL prefirmada, sube los bytes directo a S3/MinIO, y confirma. El backend nunca ve el contenido del archivo, sólo emite URLs y lleva la metadata.

La parte importante para multi-tenancy: cada archivo se guarda atado al recurso al que pertenece (resourceType/resourceId), y cada operación verifica el permiso sobre ese recurso padre vía core-authz. core-storage no tiene ninguna opinión propia sobre autorización de dominio.

Cómo se agrega

build.gradle.kts
dependencies {
implementation("dev.echotechs.core:core-storage:0.1.0-SNAPSHOT")
}

Arrastra core-auth, core-authz, core-persistence y software.amazon.awssdk:s3 (BOM 2.49.4).

db/changelog/app/changelog-master.xml
<include file="db/changelog/core-storage/changelog-master.xml"/>

Crea la tabla core.file_metadata.

Configuración

application.yml — MinIO
echotechs:
storage:
s3:
endpoint: http://minio:9000
bucket: mi-proyecto
access-key-id: ${MINIO_ACCESS_KEY}
secret-access-key: ${MINIO_SECRET_KEY}
path-style-access: true
region: us-east-1
application.yml — AWS S3
echotechs:
storage:
s3:
bucket: mi-proyecto
region: us-east-1
# sin endpoint y sin credenciales → DefaultCredentialsProvider (rol de instancia, env, etc.)
PropiedadDefaultNotas
echotechs.storage.s3.bucketActiva el módulo. Sin esto no se crea ningún bean
echotechs.storage.s3.endpointSetealo para MinIO; omitilo para AWS S3
echotechs.storage.s3.regionus-east-1El SDK lo exige aunque MinIO lo ignore
echotechs.storage.s3.access-key-idSi está vacío se usa DefaultCredentialsProvider
echotechs.storage.s3.secret-access-key
echotechs.storage.s3.path-style-accessfalsetrue obligatorio para MinIO
echotechs.storage.s3.upload-url-ttl15m
echotechs.storage.s3.download-url-ttl15m

core-storage también necesita core-authz configurado (echotechs.authz.openfga.api-url), porque FileStorageService recibe FgaService por constructor.

API pública

FileStorageService

public class FileStorageService {

/** Paso 1 del flujo del widget: emite la URL prefirmada de subida. Requiere can_edit sobre el recurso. */
public UploadInitiation initiateUpload(String resourceType, UUID resourceId, String fileName, String contentType);

/** Paso 3: verifica vía HeadObject que el objeto llegó, y marca la fila CONFIRMED. Requiere can_edit. */
public FileDescriptor confirmUpload(UUID fileId);

/** Sube bytes que el backend ya tiene en memoria, sin URL prefirmada. Queda CONFIRMED directo. Requiere can_edit. */
public FileDescriptor uploadDirect(String resourceType, UUID resourceId, String fileName, String contentType, byte[] content);

/** URL prefirmada de descarga. Requiere can_view. Sólo para archivos CONFIRMED. */
public DownloadLink presignDownload(UUID fileId);

/** Borra el objeto de S3 y marca la fila DELETED (soft delete). Requiere can_edit. */
public void delete(UUID fileId);
}

Los permisos que verifica cada operación, contra el recurso padre:

OperaciónRelación exigida
initiateUpload, uploadDirect, confirmUpload, deletecan_edit
presignDownloadcan_view

Records de retorno

public record UploadInitiation(UUID fileId, String uploadUrl, Instant expiresAt) {}

public record DownloadLink(String downloadUrl, Instant expiresAt) {}

public record FileDescriptor(UUID id, String originalFileName, String contentType, Long sizeBytes,
FileStatus status, Instant createdAt) {}

FileDescriptor no expone el object key ni el bucket, a propósito: los llamadores reciben URLs, no claves crudas.

FileStatus

public enum FileStatus {
/** URL prefirmada emitida, el llamador todavía no confirmó que la subida llegó a S3. */
PENDING,
/** HeadObject verificó que el objeto existe — seguro para enlazar y descargar. */
CONFIRMED,
/** Objeto borrado de S3; la fila queda para auditoría en vez de un hard delete. */
DELETED
}

FileMetadata y su repositorio

@Entity
@Table(name = "file_metadata", schema = "core")
public class FileMetadata extends CoreEntity {
// organizationId, resourceType, resourceId, uploadedByType, uploadedById,
// bucket, objectKey, originalFileName, contentType, sizeBytes, etag,
// status, createdAt, confirmedAt, deletedAt

public void confirm(long actualSizeBytes, String actualContentType, String etag);
public void markDeleted();
public boolean isConfirmed();
// + getters
}
public interface FileMetadataRepository extends JpaRepository<FileMetadata, UUID> {
List<FileMetadata> findByResourceTypeAndResourceIdAndStatus(String resourceType, UUID resourceId, FileStatus status);
}

Usá ese finder para listar los archivos de un recurso (filtrando por CONFIRMED) — FileStorageService no expone un método de listado.

Endpoints

FileStorageController, registrado automáticamente en /api/storage/files:

MétodoRutaBody / respuesta
POST/api/storage/filesInitiateUploadRequestUploadInitiation
POST/api/storage/files/{fileId}/confirmFileDescriptor
GET/api/storage/files/{fileId}/download-urlDownloadLink
DELETE/api/storage/files/{fileId}→ 204
public record InitiateUploadRequest(
@NotBlank String resourceType,
@NotNull UUID resourceId,
@NotBlank String fileName,
@NotBlank String contentType
) {}

Cómo se arma el object key

{organizationId}/{resourceType}/{resourceId}/{uuid-aleatorio}-{fileName-saneado}

El nombre se sanea con fileName.replaceAll("[^a-zA-Z0-9._-]", "_"), así que acentos, espacios y demás se convierten en _. El UUID aleatorio hace que dos subidas del mismo nombre nunca colisionen.

Ejemplo de uso

El flujo completo, verificado contra MinIO real

De FileStorageIntegrationTest.uploadConfirmDownloadAndDeleteRoundTripsThroughRealMinio:

// 1. El usuario necesita can_edit sobre el recurso padre — vía admin en la organización dueña.
tupleWriter.grant(FgaId.of("user", userId), "admin", FgaId.of("organization", orgId));
tupleWriter.grant(FgaId.of("organization", orgId), "owner_organization", FgaId.of("resource", resourceId));

// 2. Pedir la URL prefirmada.
UploadInitiation initiation =
fileStorageService.initiateUpload("resource", resourceId, "greeting.txt", "text/plain");

// 3. El cliente hace PUT directo a S3/MinIO — el backend no ve estos bytes.
HttpRequest request = HttpRequest.newBuilder(URI.create(initiation.uploadUrl()))
.header("Content-Type", "text/plain")
.PUT(HttpRequest.BodyPublishers.ofByteArray(fileBytes))
.build();
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());

// 4. Confirmar: HeadObject verifica que llegó, y se guardan tamaño/contentType/etag reales.
FileDescriptor confirmed = fileStorageService.confirmUpload(initiation.fileId());
assertThat(confirmed.status()).isEqualTo(FileStatus.CONFIRMED);
assertThat(confirmed.sizeBytes()).isEqualTo((long) fileBytes.length);

// 5. Descargar con otra URL prefirmada.
DownloadLink downloadLink = fileStorageService.presignDownload(initiation.fileId());

// 6. Borrar.
fileStorageService.delete(initiation.fileId());

Después del borrado, presignDownload tira ResourceNotFoundException — un archivo DELETED se comporta como inexistente.

Subida directa desde el backend

Cuando los bytes ya están en memoria (un reporte generado, por ejemplo), no hay cliente al que darle una URL:

FileDescriptor descriptor = fileStorageService.uploadDirect(
"purchase_request", purchaseRequestId, "orden.pdf", "application/pdf", pdfBytes);
// Queda CONFIRMED directo — no hay paso de confirmación.

Es exactamente lo que hace core-reporting.

Un endpoint de dominio que expone los archivos de un recurso

@RestController
@RequestMapping("/api/purchase-requests/{id}/files")
public class PurchaseRequestFilesController {

private final FileMetadataRepository fileRepository;

@GetMapping
@RequiresPermission(relation = "can_view", objectType = "purchase_request", objectId = "#id")
public List<FileDescriptor> list(@PathVariable UUID id) {
return fileRepository
.findByResourceTypeAndResourceIdAndStatus("purchase_request", id, FileStatus.CONFIRMED)
.stream()
.map(f -> new FileDescriptor(f.getId(), f.getOriginalFileName(), f.getContentType(),
f.getSizeBytes(), f.getStatus(), f.getCreatedAt()))
.toList();
}
}

Configuración de test con MinIO + OpenFGA

De FileStorageIntegrationTest — el bloque static no es decorativo:

@Container
static final MinIOContainer MINIO = new MinIOContainer("minio/minio:RELEASE.2025-09-07T16-13-09Z");

@Container
static final OpenFGAContainer OPENFGA = new OpenFGAContainer("openfga/openfga:v1.18.1");

// @DynamicPropertySource corre durante la carga del contexto de Spring, y JUnit 5 no garantiza que eso
// pase después del BeforeAllCallback de @Testcontainers — arrancarlos explícitamente (es idempotente)
// evita que getS3URL()/getHttpEndpoint() corran contra un contenedor todavía no iniciado.
static {
MINIO.start();
OPENFGA.start();
}

@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) throws Exception {
registry.add("echotechs.storage.s3.endpoint", MINIO::getS3URL);
registry.add("echotechs.storage.s3.bucket", () -> BUCKET);
registry.add("echotechs.storage.s3.access-key-id", MINIO::getUserName);
registry.add("echotechs.storage.s3.secret-access-key", MINIO::getPassword);
registry.add("echotechs.storage.s3.path-style-access", () -> "true");
registry.add("echotechs.storage.s3.region", () -> "us-east-1");

String apiUrl = OPENFGA.getHttpEndpoint();
registry.add("echotechs.authz.openfga.api-url", () -> apiUrl);
OpenFgaClient bootstrapClient = new OpenFgaClient(new ClientConfiguration().apiUrl(apiUrl));
String storeId = bootstrapClient.createStore(new CreateStoreRequest().name("core-storage-test")).get().getId();
registry.add("echotechs.authz.openfga.store-id", () -> storeId);
}

Errores comunes

Las URLs prefirmadas dan 404 contra MinIO. Falta path-style-access: true. En el S3Presigner, path-style no es una opción del builder como sí lo es en S3ClientBuilder — vive en S3Configuration. CoreStorageAutoConfiguration ya lo cablea bien, pero sólo si la propiedad está en true. Si la dejás en el default (false) contra MinIO, las URLs firmadas apuntan a bucket.endpoint/key y MinIO responde 404 pelado.

ConflictException: UPLOAD_NOT_FOUND al confirmar. confirmUpload hace HeadObject; si el cliente todavía no terminó el PUT, o falló, no hay objeto. La fila queda en PENDING y se puede reintentar la confirmación.

AccessDeniedException en initiateUpload. Falta la relación can_edit sobre el recurso padre. Fijate que sea can_edit, no can_view: cualquier operación que cambie estado exige can_edit, incluida la confirmación.

presignDownload tira ResourceNotFoundException sobre un archivo que existe. findConfirmedById trata cualquier estado distinto de CONFIRMED como inexistente. Un archivo PENDING (subida iniciada pero nunca confirmada) no se puede descargar.

Filas PENDING acumulándose. Si un cliente pide una URL y nunca sube, la fila queda PENDING para siempre. No hay limpieza automática en el módulo — si te importa, escribí un job propio que borre PENDING viejas.

IllegalStateException desde TenantContext.getCurrentOrg(). initiateUpload y uploadDirect lo llaman para armar el object key. Necesitan un principal organization-scoped: un ServiceAccountPrincipal (cuyo organizationId() es null) no puede subir archivos por esta vía.

La app no arranca: no hay bean FgaService. core-storage necesita core-authz configurado. Configurar sólo echotechs.storage.s3.bucket sin echotechs.authz.openfga.api-url hace fallar el contexto.

Se pierden acentos en el nombre del archivo. El saneado convierte todo lo que no sea [a-zA-Z0-9._-] en _. originalFileName en la base sí conserva el nombre real; el que se degrada es el object key.

Notas de implementación

Diferencias entre platform-core-spec.md y lo que hace el código:

El spec no menciona uploadDirect. La sección 1.4 describe sólo el flujo de URLs prefirmadas ("el frontend nunca sube directo a través del backend"). El código agrega uploadDirect, que sí manda los bytes a través del backend, porque core-reporting lo necesita: un PDF generado en el servidor no tiene un cliente al que entregarle una URL prefirmada.

El módulo depende de core-authz, cosa que el spec no anticipa. El spec 1.4 dice que la metadata queda "asociado a la autorización del recurso padre", sin aclarar quién verifica. En el código FileStorageService verifica activamente cada operación llamando a FgaService, lo que convierte a core-authz en dependencia dura: no se puede usar core-storage sin OpenFGA configurado.

No hay soft-delete configurable ni limpieza de huérfanos. El spec no lo pide, pero conviene saberlo: delete borra el objeto de S3 y deja la fila con status = DELETED, y no existe ningún proceso que limpie filas PENDING cuya subida nunca ocurrió.