Saltar a contenido

Spring Boot: auditoría automática

Con el starter, guardar una entidad escribe la traza sola: ningún servicio llama a diff(). Funciona con snapshots Kotlin (KSP) o Java (APT), incluso mezclados.

Respaldado por el módulo ejecutable kaudit-samples-spring: ./gradlew :kaudit-samples-spring:run.


1 · KAudit y la auditoría de Spring Data son complementarias

Spring Data ya tiene "auditing" (@EnableJpaAuditing, @EnableR2dbcAuditing), y no es lo mismo:

Spring Data auditing KAudit
Quién / cuándo (@CreatedBy, @LastModifiedDate) ❌ (los toma de ahí)
Qué cambió (campo, valor viejo → nuevo)
Redacción de campos sensibles, diff de colecciones por identidad

Por eso KAudit reutiliza tu AuditorAware en vez de inventar una interfaz: si ya lo configuraste para @CreatedBy, no tocas nada.

2 · Dependencias

plugins {
    kotlin("jvm") version "2.1.21"
    kotlin("plugin.spring") version "2.1.21"
    kotlin("plugin.jpa") version "2.1.21"
    id("com.google.devtools.ksp") version "2.1.21-2.0.1"
}

dependencies {
    implementation("io.github.kuroxbyte:kaudit-annotations:0.2.0")
    implementation("io.github.kuroxbyte:kaudit-runtime:0.2.0")
    implementation("io.github.kuroxbyte:kaudit-spring-boot-starter:0.2.0")
    implementation("io.github.kuroxbyte:kaudit-spring-data-jpa:0.2.0")   // o -r2dbc
    ksp("io.github.kuroxbyte:kaudit-processor:0.2.0")                    // snapshots Kotlin
    // annotationProcessor("io.github.kuroxbyte:kaudit-apt:0.2.0")       // snapshots Java

    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

El paso que no se puede olvidar

Sin esto no se genera ningún adaptador, el registro queda vacío y no se audita nada.

ksp { arg("kaudit.componentModel", "spring") }                        // Kotlin

tasks.withType<JavaCompile>().configureEach {                         // Java
    options.compilerArgs.add("-Akaudit.componentModel=spring")
}

3 · El modelo: instantánea + entidad

KAudit compara dos instantáneas inmutables, pero una entidad JPA es mutable. Así que hay dos tipos: el @Auditable (lo que se compara) y la @Entity (lo que persiste).

@Auditable
data class Account(
    @AuditKey val id: Long,
    val name: String,
    val balance: Long,
    @Sensitive val apiKey: String,     // se audita el cambio, nunca el valor
)

@Entity @Table(name = "account")
@EntityListeners(AuditEntityListener::class)
class AccountEntity(
    @Id var id: Long = 0,
    var name: String = "",
    var balance: Long = 0,
    var apiKey: String = "",
) : AuditableEntity<Account> {
    @Transient override var auditSnapshot: Account? = null
    override val auditId get() = id
    override fun toSnapshot() = Account(id, name, balance, apiKey)
}
@Auditable
public record Order(@AuditKey long id, String reference, int quantity) {}

@Entity @Table(name = "orders")
@EntityListeners(AuditEntityListener.class)
public class OrderEntity implements AuditableEntity<Order> {
    @Id private long id;
    private String reference; private int quantity;
    @Transient private Order auditSnapshot;

    @Override public Order toSnapshot() { return new Order(id, reference, quantity); }
    @Override public Order getAuditSnapshot() { return auditSnapshot; }
    @Override public void setAuditSnapshot(Order s) { this.auditSnapshot = s; }
    @Override public Object getAuditId() { return id; }
}

toSnapshot() es el único mapeo que escribes — y es justo lo que KMapX genera.

4 · El servicio no cambia

@Transactional
fun update(id: Long, balance: Long) {
    val account = repository.findById(id).orElseThrow()
    account.balance = balance
    // al confirmar, el flush dispara @PreUpdate y se escribe la traza
}

5 · Dónde acaba la traza

AuditWriter es un SPI. Declara tu bean y gana el tuyo:

@Bean
fun auditWriter(): AuditWriter = AuditWriter { record ->
    // record.entity, .entityId, .changedBy, .changedAt, .changes
    kafka.send("audit", record.toJson())
}

O elige una implementación incluida con kaudit.writer:

Valor Qué hace
none (default) Ninguna: el arranque falla si no declaras un AuditWriter. Es a propósito: perder trazas en silencio es peor que un arranque roto.
log Escribe con SLF4J. Para empezar sin tocar la BD.
jpa Persiste AuditEntry en la tabla audit_entry (el DDL lo creas tú; ver KDoc).

6 · Salida real del sample

=== Kotlin (KSP): cambian saldo y apiKey ===
  [audit] Account#1 por ana@example.com:
            balance: 100 → 250
            apiKey: *** (redactado)

=== Java (APT): cambia la cantidad ===
  [audit] Order#1 por ana@example.com:
            quantity: 3 → 10

=== Sin cambios reales: no se escribe traza ===
  (nada, como debe ser)

Tres cosas que se ven ahí: @Sensitive nunca expone su valor, el frontend (KSP o APT) es indistinguible para el starter, y sin cambios no se escribe registro — una tabla de auditoría no es un log de accesos.

7 · Cómo funciona por dentro (y sus dos gotchas)

findById(1)   → JPA carga → @PostLoad → auditSnapshot = toSnapshot()   ← el "antes"
account.balance = 250
commit        → flush → @PreUpdate
                  → registry[Account] → el adaptador @Component → diff()  (código generado)
                  → si hay cambios → AuditWriter

Gotcha 1 · La entidad tiene que CARGARSE

El snapshot lo toma @PostLoad. Una entidad creada y modificada en el mismo persistence context no se audita: findById la devuelve del caché de primer nivel sin ir a la base de datos, así que @PostLoad no se dispara y no hay con qué comparar.

En una app real no suele importar (cada petición es su transacción), pero sí muerde en tests y procesos por lotes. Separa el alta de la modificación, o usa el camino explícito.

Gotcha 2 · El listener no es un bean

Un @EntityListeners lo instancia JPA, no Spring, así que no admite inyección. KAudit llega a él por un puente estático que instala la auto-configuración — el mismo truco que usa Spring Data en su AuditingEntityListener. Si el puente no está, el listener no audita en vez de reventar.

Además: solo se engancha @PreUpdate, no @PrePersist. En un alta no hay estado previo, y registrar "todos los campos cambiaron" sería ruido — eso ya lo marca @CreatedDate.

8 · Camino explícito (y obligatorio en R2DBC)

Si no quieres magia, o necesitas control total:

@Transactional
fun update(id: Long, cmd: Cmd): Account {
    val before = repository.findById(id).orElseThrow().toSnapshot()
    val after = repository.save(/* … */).toSnapshot()
    audit.record("Account", id, before, after)     // KAuditOperations
    return after
}

Se apaga el automático con kaudit.jpa.auto-listener=false.

R2DBC: aquí no hay listener

R2DBC no tiene persistence-context ni dirty-checking, y sus EntityCallback reciben la entidad a guardar, nunca la fila previa: un save() es un UPDATE a ciegas. Así que el estado anterior lo pasa quien ya lo leyó:

fun update(id: Long, cmd: Cmd): Mono<Account> =
    repo.findById(id)
        .flatMap { old -> repo.save(old.apply(cmd)).map { old to it } }
        .flatMap { (old, saved) -> audit.record("Account", id, old, saved).thenReturn(saved) }

El diff() es síncrono y puro, así que corre dentro del chain sin bloquear.

9 · Configuración

Property Default Para qué
kaudit.enabled true Apagar toda la integración.
kaudit.writer none none | log | jpa. Irrelevante si declaras tu bean.
kaudit.jpa.auto-listener true En false, el listener queda inerte: solo el camino explícito.

Problemas frecuentes

Síntoma Causa Arreglo
No se audita nada, sin errores Falta kaudit.componentModel=spring: no hay adaptadores Añadir el arg/-A del §2
El arranque falla: "no hay ningún AuditWriter" Es deliberado Declara tu bean o kaudit.writer=log
Una entidad concreta no se audita Se creó y modificó en la misma transacción (§7) Separa las transacciones o usa KAuditOperations
Un snapshot Java no se audita Falta annotationProcessor(kaudit-apt) y su -A KSP no procesa tipos Java a propósito: son de APT