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
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).
<include file="db/changelog/core-storage/changelog-master.xml"/>
Crea la tabla core.file_metadata.
Configuración
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
echotechs:
storage:
s3:
bucket: mi-proyecto
region: us-east-1
# sin endpoint y sin credenciales → DefaultCredentialsProvider (rol de instancia, env, etc.)
| Propiedad | Default | Notas |
|---|---|---|
echotechs.storage.s3.bucket | — | Activa el módulo. Sin esto no se crea ningún bean |
echotechs.storage.s3.endpoint | — | Setealo para MinIO; omitilo para AWS S3 |
echotechs.storage.s3.region | us-east-1 | El SDK lo exige aunque MinIO lo ignore |
echotechs.storage.s3.access-key-id | — | Si está vacío se usa DefaultCredentialsProvider |
echotechs.storage.s3.secret-access-key | — | |
echotechs.storage.s3.path-style-access | false | true obligatorio para MinIO |
echotechs.storage.s3.upload-url-ttl | 15m | |
echotechs.storage.s3.download-url-ttl | 15m |
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ón | Relación exigida |
|---|---|
initiateUpload, uploadDirect, confirmUpload, delete | can_edit |
presignDownload | can_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étodo | Ruta | Body / respuesta |
|---|---|---|
| POST | /api/storage/files | InitiateUploadRequest → UploadInitiation |
| POST | /api/storage/files/{fileId}/confirm | → FileDescriptor |
| GET | /api/storage/files/{fileId}/download-url | → DownloadLink |
| 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ó.