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 |