GORM Adapter
Use Go Audit with GORM via the auto-hook plugin.
The GORM adapter registers callbacks on the GORM connection so every
Create, Save, Updates, and Delete is tracked with no changes
to your model or query code.
Installation
go get github.com/gopackx/go-audit/adapters/gormSetup
import (
"context"
"database/sql"
"github.com/gopackx/go-audit"
auditgorm "github.com/gopackx/go-audit/adapters/gorm"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := gormDB.DB()
if err != nil {
return err
}
auditor, err := audit.New(sqlDB, audit.Config{
Dialect: audit.PostgreSQL,
UserFunc: func(ctx context.Context) (string, string) {
return "user-123", "user"
},
DataAudit: audit.DataAuditConfig{Enabled: true},
})
if err != nil {
return err
}
if err := gormDB.Use(auditgorm.Plugin(auditor)); err != nil {
return err
}
if err := auditor.AutoMigrate(context.Background()); err != nil {
return err
}Note the two-step DB handle:
audit.Newtakes the underlying*sql.DB(fromgormDB.DB()), whilePluginattaches to the*gorm.DB.
How It Works
The plugin registers five callbacks:
after_create— recordsActionCreatewithnew_values.before_update— snapshots the affected rows via aSELECTthat targets the same rows as the write: its WHERE clause and the model's primary key (unlessDataAudit.SkipOldValuesis set).after_update— diffs the snapshot against the new values, recordsActionUpdatewith only the changed fields.before_delete— snapshots the affected rows the same way asbefore_update; detects soft deletes via presence of adeleted_atfield.after_delete— recordsActionDelete(hard delete) orActionSoftDelete(soft delete viagorm.DeletedAt).
Errors from audit writes are attached to the *gorm.DB via
AddError, so callers see them via db.Error.
Supported Operations
db.Create()/db.CreateInBatches()db.Save()db.Updates()(struct ormap[string]any)db.UpdateColumn()/db.UpdateColumns()db.Delete()— hard and soft deletes- Bulk operations — all affected rows share one
transaction_id
Bulk Updates and Deletes
Bulk updates and deletes produce one audit log per affected row, all
sharing the same transaction_id:
db.Model(&Product{}).Where("price < ?", 100).Updates(Product{Status: "archived"})
// -> N audit logs, one per row, same transaction_id
db.Where("price < ?", 100).Delete(&Product{})
// -> N audit logs (action "delete" or "soft_delete"), one per rowA WHERE-driven bulk write carries no primary key on its model, so the
affected rows are identified by the pre-write snapshot: the SELECT
runs with the same WHERE, and each row it returns becomes one entry
with:
entity_id— that row's primary keyold_values— the snapshotnew_values— the snapshot with the assigned columns applied (nullfor a hard delete,{"deleted_at": ...}for a soft delete)
With DataAudit.SkipOldValues: true no snapshot is taken, so such bulk
writes cannot be attributed per row and are not audited. Writes
that target rows by primary key (db.Model(&p).Update(...),
db.Save(&p), db.Delete(&p), db.Delete(&rows)) are still audited
per row without a snapshot.
A write with neither a WHERE clause nor a primary key is rejected by
GORM (ErrMissingWhereClause) and no snapshot is taken. With
AllowGlobalUpdate the write touches the whole table, so the snapshot
does too.
Configuration
audit.Config{
Dialect: audit.PostgreSQL,
DataAudit: audit.DataAuditConfig{
Enabled: true,
Table: "audit_logs", // override table name
ExcludeFields: []string{"password", "token"},
ExcludeEntities: []string{"sessions"},
SkipOldValues: false, // true = skip pre-UPDATE SELECT
OnError: audit.ErrorFailLoud,
},
UserFunc: func(ctx context.Context) (string, string) {
return "user-123", "user"
},
}Soft Delete Detection
If your model includes a gorm.DeletedAt field, GORM performs an
UPDATE rather than a DELETE. The adapter detects this and records
the action as "soft_delete" with the new deleted_at value in
new_values. Hard deletes (e.g. .Unscoped().Delete()) are recorded
as "delete" with new_values: null — including an Unscoped()
delete of a row that was already soft-deleted.
Caveats
- Raw SQL executed via
db.Exec()bypasses GORM's callbacks and is not audited. Use model-based calls when you want audit trail. db.Session(&gorm.Session{SkipHooks: true})explicitly skips audit writes.- Queries that don't go through GORM (e.g. direct
database/sqlon the same connection) are not audited.