Saltar a contenido

Anti-Corruption Layer (ACL)

El Anti-Corruption Layer (ACL) es la barrera que impide que el modelo de dominio externo de Keycloak contamine el modelo de dominio de negocio de la aplicación. Keycloak maneja conceptos como realm, client, role, scope y claim JWT que tienen semántica propia en el mundo IAM pero que no tienen por qué coincidir con los conceptos de negocio como Permiso, Empleado, Socio o Contrato. El ACL traduce en tiempo de ejecución los artefactos de Keycloak a objetos del dominio, protegiendo la integridad del modelo de negocio ante cambios en la configuración de Keycloak o ante una eventual migración a otro proveedor de identidad.

El problema sin ACL

Sin ACL, el código de negocio depende directamente de los claims JWT de Keycloak:

// INCORRECTO — el dominio acopla a la estructura interna de Keycloak
public boolean puedeAprobarContrato(Jwt jwt) {
    // Acoplado al claim realm_access.roles de Keycloak
    List<String> roles = jwt.getClaimAsStringList("realm_access.roles");
    return roles != null && roles.contains("contract-approver");
}

Si Keycloak cambia la estructura del claim, o si se migra a otro proveedor, hay que modificar lógica de negocio. El ACL centraliza esa traducción en un único lugar.

Diagrama de traducción

flowchart LR
    subgraph Infraestructura IAM
        KC[Keycloak JWT\nrealm_access.roles\nclient_access\ncustom claims]
    end

    subgraph ACL["ACL (Adapter Layer)"]
        TK[TokenTranslator]
        PR[PermissionResolver]
        IM[IdentityMapper]
    end

    subgraph Dominio de negocio
        US[Usuario de dominio\nusuario.permisos\nusuario.identidad]
        PE[Permiso\nAPROBAR_CONTRATO\nVER_REPORTES]
        EN[Entidades\nEmpleado, Socio]
    end

    KC -->|Jwt raw| TK
    TK -->|claims mapeados| PR
    TK -->|sub, email, name| IM
    PR --> PE
    IM --> EN
    PE --> US
    EN --> US

Conceptos Keycloak → Dominio de negocio

Concepto Keycloak Tipo Concepto de dominio Tipo de dominio
realm_access.roles[] List<String> Set<Permiso> Enum tipado
resource_access.client.roles[] List<String> Set<PermisoCliente> Value Object
sub String (UUID) IdentidadUsuario Value Object
preferred_username String NombreUsuario Value Object
email String Email Value Object
Claim custom tenant_id String TenantId Value Object
Claim custom partner_code String CodigoSocio Value Object

Implementación en Java 21 con Spring WebFlux

El enum de permisos del dominio

package com.empresa.platform.domain.identity;

public enum Permiso {
    VER_CONTRATOS,
    CREAR_CONTRATO,
    APROBAR_CONTRATO,
    GESTIONAR_USUARIOS,
    VER_REPORTES,
    ACCESO_ADMINISTRACION;
}

Value Object de identidad del dominio

package com.empresa.platform.domain.identity;

import java.util.Set;
import java.util.UUID;

public record UsuarioDominio(
    UUID id,
    String nombreUsuario,
    String email,
    String nombre,
    String apellido,
    Set<Permiso> permisos,
    TipoUsuario tipo   // EMPLEADO, SOCIO, SISTEMA
) {
    public boolean tiene(Permiso permiso) {
        return permisos.contains(permiso);
    }

    public boolean esEmpleado() {
        return tipo == TipoUsuario.EMPLEADO;
    }
}

El ACL: TokenTranslator

package com.empresa.platform.infrastructure.security.acl;

import com.empresa.platform.domain.identity.Permiso;
import com.empresa.platform.domain.identity.TipoUsuario;
import com.empresa.platform.domain.identity.UsuarioDominio;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.stream.Collectors;

@Component
public class TokenTranslator {

    private static final String CLAIM_REALM_ROLES = "realm_access";
    private static final String CLAIM_RESOURCE_ACCESS = "resource_access";
    private static final String CLIENT_ID = "partner-portal-app";

    /**
     * Traduce un JWT de Keycloak a un UsuarioDominio.
     * Este es el único lugar de la aplicación que conoce la estructura
     * interna de los claims de Keycloak.
     */
    public UsuarioDominio traducir(Jwt jwt) {
        UUID id = UUID.fromString(jwt.getSubject());
        String username = jwt.getClaimAsString("preferred_username");
        String email = jwt.getClaimAsString("email");
        String nombre = jwt.getClaimAsString("given_name");
        String apellido = jwt.getClaimAsString("family_name");

        Set<String> rolesKeycloak = extraerRoles(jwt);
        Set<Permiso> permisos = mapearPermisos(rolesKeycloak);
        TipoUsuario tipo = inferirTipo(rolesKeycloak, jwt);

        return new UsuarioDominio(id, username, email, nombre, apellido, permisos, tipo);
    }

    @SuppressWarnings("unchecked")
    private Set<String> extraerRoles(Jwt jwt) {
        Set<String> roles = new HashSet<>();

        // Roles de realm (globales)
        Map<String, Object> realmAccess = jwt.getClaimAsMap(CLAIM_REALM_ROLES);
        if (realmAccess != null) {
            List<String> realmRoles = (List<String>) realmAccess.get("roles");
            if (realmRoles != null) roles.addAll(realmRoles);
        }

        // Roles de cliente específico
        Map<String, Object> resourceAccess = jwt.getClaimAsMap(CLAIM_RESOURCE_ACCESS);
        if (resourceAccess != null && resourceAccess.containsKey(CLIENT_ID)) {
            Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get(CLIENT_ID);
            List<String> clientRoles = (List<String>) clientAccess.get("roles");
            if (clientRoles != null) roles.addAll(clientRoles);
        }

        return Collections.unmodifiableSet(roles);
    }

    private Set<Permiso> mapearPermisos(Set<String> rolesKeycloak) {
        // Mapeado explícito: rol Keycloak → Permiso de dominio
        // Cambiar la configuración de Keycloak NO rompe el dominio
        Map<String, Permiso> mapa = Map.of(
            "contract-viewer",   Permiso.VER_CONTRATOS,
            "contract-creator",  Permiso.CREAR_CONTRATO,
            "contract-approver", Permiso.APROBAR_CONTRATO,
            "user-manager",      Permiso.GESTIONAR_USUARIOS,
            "reports-viewer",    Permiso.VER_REPORTES,
            "admin",             Permiso.ACCESO_ADMINISTRACION
        );

        return rolesKeycloak.stream()
            .filter(mapa::containsKey)
            .map(mapa::get)
            .collect(Collectors.toUnmodifiableSet());
    }

    private TipoUsuario inferirTipo(Set<String> roles, Jwt jwt) {
        // Inferencia basada en claims custom o en la presencia de roles
        String realmHint = jwt.getClaimAsString("azp");  // authorized party
        if ("partner-service-m2m".equals(realmHint)) return TipoUsuario.SISTEMA;
        if (roles.contains("employee")) return TipoUsuario.EMPLEADO;
        return TipoUsuario.SOCIO;
    }
}

Integración con Spring Security: ContextHolder personalizado

package com.empresa.platform.infrastructure.security.acl;

import com.empresa.platform.domain.identity.UsuarioDominio;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
public class UsuarioDominioContext {

    private final TokenTranslator translator;

    public UsuarioDominioContext(TokenTranslator translator) {
        this.translator = translator;
    }

    /**
     * Obtiene el usuario de dominio actual desde el contexto reactivo.
     * Los use cases usan este método — nunca acceden al Jwt directamente.
     */
    public Mono<UsuarioDominio> obtenerUsuarioActual() {
        return ReactiveSecurityContextHolder.getContext()
            .map(ctx -> ctx.getAuthentication().getPrincipal())
            .cast(Jwt.class)
            .map(translator::traducir);
    }
}

Uso en un use case de dominio

@Component
public class AprobarContratoUseCase {

    private final ContratoRepository contratoRepo;
    private final UsuarioDominioContext usuarioCtx;

    public Mono<Contrato> ejecutar(UUID contratoId) {
        return usuarioCtx.obtenerUsuarioActual()
            .flatMap(usuario -> {
                // El use case trabaja con permisos de dominio, nunca con roles Keycloak
                if (!usuario.tiene(Permiso.APROBAR_CONTRATO)) {
                    return Mono.error(new AccesoDenegadoException(
                        "El usuario " + usuario.nombreUsuario() + " no tiene permiso APROBAR_CONTRATO"
                    ));
                }
                return contratoRepo.findById(contratoId)
                    .flatMap(contrato -> contratoRepo.save(contrato.aprobar(usuario.id())));
            });
    }
}

Beneficios del ACL en esta plataforma

  1. Independencia del proveedor: si se cambia Keycloak por otro IdP, solo se modifica TokenTranslator, no los use cases ni el dominio.
  2. Vocabulario de dominio: el código de negocio habla de Permiso.APROBAR_CONTRATO, no de "contract-approver" como string magic.
  3. Testabilidad: los use cases se prueban pasando UsuarioDominio directamente, sin necesidad de construir un JWT.
  4. Auditoría centralizada: toda la lógica de traducción está en TokenTranslator, facilitando auditorías de seguridad.

El ACL no es un filtro de seguridad

El ACL traduce conceptos; la validación criptográfica del JWT la hace Spring Security (JwtDecoder con JWKS de Keycloak). No reemplaces la validación de firma con lógica del ACL.

Tests del ACL

Testea TokenTranslator con JWTs de prueba construidos con Jwt.withTokenValue(...). No necesitas un Keycloak real para verificar el mapeo de claims a permisos de dominio.