S3 en Spring Boot: guardar archivos con seguridad y permisos

1. Descripción general

Amazon S3 es la opción más popular para guardar archivos como facturas, avatares o adjuntos. La parte de upload suele ser solo unas pocas líneas de código. La parte difícil es la gestión de permisos: quién puede leer, quién puede escribir, y cómo compartir archivos sin abrir el bucket al público.

En este artículo, usarás AWS SDK for Java v2 en Spring Boot 3.x para guardar y leer archivos. Después, configurarás permisos con IAM policy y bucket policy, y usarás presigned URL para compartir archivos con tiempo de expiración. Al final del artículo hay un test ejecutado con LocalStack.

2. Configuración del proyecto

2.1. Dependencias

Deberías usar el BOM de AWS SDK para asegurar que todos los módulos siempre tengan la misma versión:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>bom</artifactId>
            <version>2.31.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>s3</artifactId>
    </dependency>
</dependencies>

El módulo s3 ya incluye tanto S3Client como S3Presigner, así que no necesitas agregar ningún otro módulo.

2.2. Configuración del bean

Declara la región y el nombre del bucket en application.properties:

app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod

Luego, crea dos beans S3Client y S3Presigner:

@Configuration
public class S3Config {

    @Bean
    S3Client s3Client(@Value("${app.s3.region}") String region) {
        return S3Client.builder()
                .region(Region.of(region))
                .build();
    }

    @Bean
    S3Presigner s3Presigner(@Value("${app.s3.region}") String region) {
        return S3Presigner.builder()
                .region(Region.of(region))
                .build();
    }
}

El código anterior no declara credenciales. El SDK usará DefaultCredentialsProvider y buscará credenciales sucesivamente en variables de entorno, archivo ~/.aws/credentials, y luego en el rol IAM de EC2/ECS/EKS. En un entorno de producción, deberías dejar que la aplicación se ejecute con un rol IAM.

Nota: No codifiques access keys en el código ni en application.properties. Las keys filtradas a través de Git son uno de los incidentes de AWS más comunes.

3. Guardar y leer archivos

El ejemplo en este artículo es un servicio que guarda facturas PDF para clientes. Cada archivo está bajo un prefijo según el customerId. Esta forma de establecer la key te ayuda a verificar la propiedad más fácilmente en la sección 5.

@Service
public class InvoiceStorageService {

    private final S3Client s3;
    private final S3Presigner presigner;
    private final String bucket;

    public InvoiceStorageService(S3Client s3, S3Presigner presigner,
                                 @Value("${app.s3.bucket}") String bucket) {
        this.s3 = s3;
        this.presigner = presigner;
        this.bucket = bucket;
    }

    public String upload(long customerId, InputStream content, long size) {
        String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

        PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType("application/pdf")
                .serverSideEncryption(ServerSideEncryption.AES256)
                .build();

        s3.putObject(request, RequestBody.fromInputStream(content, size));
        return key;
    }

    public byte[] download(String key) {
        return s3.getObjectAsBytes(b -> b.bucket(bucket).key(key)).asByteArray();
    }

    public void delete(String key) {
        s3.deleteObject(b -> b.bucket(bucket).key(key));
    }
}

Los puntos clave en el código:

  • La key tiene la forma invoices/42/7f3c...pdf. La parte UUID evita conflictos de nombres. Tampoco incluyes el nombre del archivo del usuario en la key, evitando así caracteres extraños y path traversal.
  • contentType se guarda junto con el objeto. Cuando el navegador descarga el archivo a través de una presigned URL, mostrará el PDF correctamente.
  • serverSideEncryption(AES256) solicita a S3 que cifre el archivo al guardarlo (SSE-S3). Actualmente S3 lo habilita por defecto. Especificarlo explícitamente en la request es útil si la bucket policy requiere este encabezado de cifrado.
  • RequestBody.fromInputStream necesita conocer el size de antemano. Si recibes un MultipartFile, pasa file.getInputStream() y file.getSize().
  • getObjectAsBytes carga el archivo completo en memoria. Este enfoque solo es apropiado para archivos pequeños.

Nota: Con archivos grandes (cientos de MB), no uses getObjectAsBytes. Usa s3.getObject(...) para obtener ResponseInputStream y transmite directamente en la response. Otra opción es dejar que el cliente descargue directamente a través de una presigned URL (sección 5).

4. Modelo de permisos de S3

Una request a S3 solo se acepta si pasa múltiples capas de verificación:

  1. Block Public Access: un interruptor a nivel de cuenta y bucket. Cuando está habilitado, todas las policies o ACLs que concedan permisos públicos se invalidan.
  2. IAM policy: se adjunta a un usuario o rol, describe qué puede hacer esa identidad.
  3. Bucket policy: se adjunta al bucket, describe quién puede hacer qué en ese bucket.
  4. ACL: mecanismo antiguo. Los buckets nuevos usan por defecto BucketOwnerEnforced para Object Ownership, es decir, ACL está deshabilitado.

La regla de evaluación es muy simple: un Deny explícito en cualquier lugar siempre gana. Si no hay Deny, la request necesita al menos un Allow que coincida.

Recomendación para la mayoría de aplicaciones:

  • Mantén Block Public Access habilitado en las cuatro opciones.
  • Mantén ACL deshabilitado (BucketOwnerEnforced).
  • Concede permisos a la aplicación a través de rol IAM.
  • Usa bucket policy para restricciones generales, por ejemplo, obligar TLS.

4.1. IAM policy según el principio de mínimo privilegio

El rol de la aplicación solo necesita leer, escribir y eliminar objetos bajo el prefijo invoices/:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvoiceObjects",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::nonla-invoices-prod/invoices/*"
    },
    {
      "Sid": "ListInvoicePrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::nonla-invoices-prod",
      "Condition": {
        "StringLike": { "s3:prefix": "invoices/*" }
      }
    }
  ]
}

Observa las dos formas de ARN diferentes:

  • Las operaciones en objetos usan ARN con /* al final.
  • s3:ListBucket es una operación en el bucket, así que usa ARN sin /*.

Confundir estos dos ARNs es la causa más común de errores AccessDenied. Hay otro detalle más: si falta s3:ListBucket, cuando el objeto no existe S3 devuelve 403 en lugar de 404. Este es un mecanismo deliberado de S3 que evita revelar si una key existe o no.

4.2. Bucket policy que obliga TLS

La bucket policy es ideal para establecer reglas que se apliquen a todos, incluyendo administradores:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::nonla-invoices-prod",
        "arn:aws:s3:::nonla-invoices-prod/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    }
  ]
}

Este Statement es Deny, así que todas las requests por HTTP se rechazan. Ningún Allow puede anular esto. El SDK v2 usa HTTPS por defecto, así que tu aplicación no se verá afectada.

5. Compartir archivos con presigned URL

Los clientes necesitan descargar facturas, pero no quieres abrir el bucket al público ni transmitir cada archivo a través del servidor. Presigned URL resuelve exactamente esto. Es una URL firmada con las credenciales de la aplicación y solo se puede usar durante un período corto de tiempo.

Agrega estos dos métodos a InvoiceStorageService:

public URL presignDownload(long customerId, String key, Duration ttl) {
    if (!key.startsWith("invoices/%d/".formatted(customerId))) {
        throw new ForbiddenFileAccessException(customerId, key);
    }

    GetObjectPresignRequest request = GetObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .getObjectRequest(b -> b.bucket(bucket).key(key))
            .build();

    return presigner.presignGetObject(request).url();
}

public URL presignUpload(long customerId, Duration ttl) {
    String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

    PutObjectPresignRequest request = PutObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .putObjectRequest(b -> b.bucket(bucket).key(key).contentType("application/pdf"))
            .build();

    return presigner.presignPutObject(request).url();
}

Una clase de excepción simple:

public class ForbiddenFileAccessException extends RuntimeException {
    public ForbiddenFileAccessException(long customerId, String key) {
        super("Customer %d cannot access %s".formatted(customerId, key));
    }
}

Cómo funciona el código:

  • La línea de verificación startsWith es la capa de autorización a nivel de aplicación. S3 solo sabe que tu aplicación puede leer todos los invoices/*. S3 no sabe que el cliente 42 no puede ver facturas del cliente 43. Si omites esta línea, cualquiera que adivine la key puede descargar archivos de otros.
  • signatureDuration establece la fecha de expiración de la URL. Para descargas de archivos, unos minutos suelen ser suficientes.
  • Con presigned PUT, el cliente debe enviar exactamente el mismo Content-Type: application/pdf. Si no, la firma no coincidirá.

La URL generada tiene esta forma:

https://nonla-invoices-prod.s3.ap-southeast-1.amazonaws.com/invoices/42/7f3c...pdf
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Date=20261013T080000Z
  &X-Amz-Expires=300
  &X-Amz-Credential=...
  &X-Amz-Signature=...

El cliente carga directamente a S3 usando presigned PUT:

curl -X PUT -H "Content-Type: application/pdf" \
     --upload-file invoice.pdf "<presigned-url>"

Nota: La presigned URL tiene los permisos de quién la firmó. Si el rol IAM de la aplicación pierde s3:GetObject, la URL ya emitida también dejará de funcionar. El tiempo máximo de expiración es de 7 días. Al firmar con credenciales temporales del rol IAM, la URL expira aún más rápido, junto con la sesión del rol (típicamente unas pocas horas). No guardes presigned URLs en base de datos para uso a largo plazo. Guarda la key y genera una nueva URL cada vez que la necesites.

6. Pruebas con LocalStack

Agrega Testcontainers para ejecutar un S3 simulado en Docker:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot 3.x ya gestiona la versión de Testcontainers, así que no necesitas especificar version.

@Testcontainers
class InvoiceStorageServiceTest {

    @Container
    static LocalStackContainer localstack =
            new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8"))
                    .withServices(LocalStackContainer.Service.S3);

    static InvoiceStorageService service;

    @BeforeAll
    static void setUp() {
        var credentials = StaticCredentialsProvider.create(
                AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()));
        var region = Region.of(localstack.getRegion());

        S3Client s3 = S3Client.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .forcePathStyle(true)
                .build();

        S3Presigner presigner = S3Presigner.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .build();

        s3.createBucket(b -> b.bucket("test-invoices"));
        service = new InvoiceStorageService(s3, presigner, "test-invoices");
    }

    @Test
    void givenUploadedInvoice_whenDownload_thenContentIsSame() {
        byte[] pdf = "%PDF-1.7 fake invoice".getBytes(StandardCharsets.UTF_8);

        String key = service.upload(42L, new ByteArrayInputStream(pdf), pdf.length);

        assertThat(key).startsWith("invoices/42/").endsWith(".pdf");
        assertThat(service.download(key)).isEqualTo(pdf);
    }

    @Test
    void givenOwnKey_whenPresign_thenUrlHasExpiry() {
        URL url = service.presignDownload(42L, "invoices/42/a.pdf", Duration.ofMinutes(5));

        assertThat(url.getQuery()).contains("X-Amz-Expires=300");
    }

    @Test
    void givenOtherCustomersKey_whenPresign_thenForbidden() {
        assertThatThrownBy(() ->
                service.presignDownload(42L, "invoices/43/b.pdf", Duration.ofMinutes(5)))
                .isInstanceOf(ForbiddenFileAccessException.class);
    }
}

Algunos detalles a notar:

  • forcePathStyle(true) permite acceder al bucket de la forma http://localhost:4566/test-invoices/.... LocalStack necesita esto porque no puede resolver subdominios como test-invoices.localhost.
  • La tercera prueba es la más importante en términos de seguridad. Asegura que el cliente 42 no pueda obtener una URL para archivos del cliente 43.

LocalStack no evalúa IAM policies en la versión gratuita. Por lo tanto, deberías verificar las policies reales usando el IAM Policy Simulator o en un entorno staging.

7. Errores comunes

  • AccessDenied aunque tienes s3:GetObject: ARN sin /*, nombre de bucket incorrecto, o una bucket policy Deny te está bloqueando. Si el bucket usa SSE-KMS, el rol también necesita permiso kms:Decrypt en la key KMS correspondiente.
  • 403 en lugar de 404 cuando el objeto no existe: el rol no tiene s3:ListBucket. Este es el comportamiento correcto de S3, no un bug.
  • SignatureDoesNotMatch con presigned PUT: el cliente envía un Content-Type diferente al de la firma, o el presigner está configurado con una región diferente a la del bucket.
  • Hacer público con ACL public-read: con BucketOwnerEnforced, las requests con ACL se rechazan. Si realmente necesitas contenido público, por ejemplo imágenes de productos, coloca CloudFront frente al bucket y usa Origin Access Control. No desactives Block Public Access.
  • Crear un nuevo S3Client para cada request: el cliente mantiene un connection pool y debe ser compartido. Decláralo como singleton bean como en la sección 2.2.

Nota: S3 no es un file system. No tienes operaciones “rename” o “append”. Cambiar nombre significa copiar a una key nueva y eliminar la antigua. Si necesitas escribir continuamente datos nuevos en un archivo, S3 no es la opción adecuada.

8. Conclusión

En este artículo, usaste AWS SDK v2 en Spring Boot para cargar, descargar y eliminar archivos en S3, con keys diseñadas según el customerId. También configuraste permisos en múltiples capas:

  • Block Public Access y ACL deshabilitado para mantener el bucket siempre privado.
  • IAM policy con mínimo privilegio para el rol de la aplicación.
  • Bucket policy para reglas generales como obligar TLS.
  • Verificación de propiedad a nivel de aplicación antes de emitir presigned URLs.

El punto más importante a recordar: S3 solo sabe qué puede hacer tu aplicación. Determinar qué usuarios pueden ver qué archivos sigue siendo responsabilidad de tu código. Por eso deberías tener pruebas que cubran el caso donde un usuario intenta acceder a archivos de otro.

awss3spring-bootiamjava