Audit Logging

Sentinel includes a GORM plugin that automatically tracks every database mutation — creates, updates, and deletes — and records them as immutable audit log entries. Every change captures who made it, what changed (including before/after state for updates), and when it happened. Audit entries flow through the Sentinel pipeline asynchronously, so your application code is never blocked by audit writes.

Optional Package

The GORM audit logging plugin lives in a separate package (github.com/MUKE-coder/sentinel/gorm). It is completely optional — your core Sentinel middleware works independently. Import it only if your application uses GORM and you want automatic database-level audit trails.

Installation

The audit logging plugin requires two Sentinel packages: the GORM plugin itself and the event pipeline that transports audit entries to storage.

go importsgo
1import (
2 sentinelgorm "github.com/MUKE-coder/sentinel/gorm"
3 "github.com/MUKE-coder/sentinel/pipeline"
4)

Make sure both packages are available in your module:

go get github.com/MUKE-coder/sentinel/gorm
go get github.com/MUKE-coder/sentinel/pipeline

Setup

Setting up audit logging takes three steps: create a pipeline, start its background workers, and register the plugin with your GORM database instance.

main.gogo
1// 1. Create an event pipeline with a buffer of 10,000 events
2pipe := pipeline.New(0) // 0 = DefaultBufferSize (10,000)
3
4// 2. Start background workers to process audit events
5pipe.Start(2) // 2 worker goroutines
6defer pipe.Stop()
7
8// 3. Register the Sentinel GORM plugin
9db.Use(sentinelgorm.New(pipe))

By default, sentinelgorm.New(pipe) enables both audit logging and query shielding (N+1 detection, unfiltered query warnings). You can selectively disable features using option functions:

1// Audit logging only — disable query shielding
2db.Use(sentinelgorm.New(pipe, func(c *sentinelgorm.Config) {
3 c.QueryShieldEnabled = false
4}))
5
6// Query shielding only — disable audit logging
7db.Use(sentinelgorm.New(pipe, func(c *sentinelgorm.Config) {
8 c.AuditEnabled = false
9}))

Plugin Config

FieldTypeDefaultDescription
AuditEnabledbooltrueEnables audit logging for CREATE, UPDATE, and DELETE operations.
QueryShieldEnabledbooltrueEnables query analysis (N+1 detection, unfiltered query warnings).
SlowQueryThresholdtime.Duration200msQueries slower than this duration are flagged.
N1QueryThresholdint10Number of same-table queries in a single request before flagging as N+1.

Request Context

For audit entries to include who performed the action, you need to attach request metadata to the GORM context using sentinelgorm.WithRequestInfo(). This connects each database operation back to the originating HTTP request.

handler.gogo
1func CreateProductHandler(c *gin.Context) {
2 // Build request info from the current HTTP request
3 ctx := sentinelgorm.WithRequestInfo(c.Request.Context(), &sentinelgorm.RequestInfo{
4 IP: c.ClientIP(),
5 UserID: c.GetString("user_id"), // from your auth middleware
6 UserEmail: c.GetString("user_email"),
7 UserRole: c.GetString("user_role"),
8 UserAgent: c.Request.UserAgent(),
9 RequestID: c.GetString("request_id"),
10 })
11
12 product := Product{Name: "Widget", Price: 9.99}
13
14 // Use db.WithContext(ctx) so the plugin picks up the request info
15 if err := db.WithContext(ctx).Create(&product).Error; err != nil {
16 c.JSON(500, gin.H{"error": err.Error()})
17 return
18 }
19
20 c.JSON(201, product)
21}

Context is Required for Attribution

If you call db.Create(&record) without attaching request info via WithContext, the audit entry is still created but the UserID, IP, and UserAgent fields will be empty. Always use db.WithContext(ctx) in your HTTP handlers for full attribution.

RequestInfo Fields

FieldTypeDescription
IPstringClient IP address of the request originator.
UserIDstringAuthenticated user identifier (from your auth middleware).
UserEmailstringEmail address of the authenticated user.
UserRolestringRole of the authenticated user (e.g., "admin", "editor").
UserAgentstringThe User-Agent header from the HTTP request.
RequestIDstringUnique request identifier for correlating multiple audit entries from a single request.

What Gets Logged

The plugin hooks into GORM callbacks for all mutation operations. Every CREATE, UPDATE, and DELETE that passes through GORM produces an audit entry. Read operations (SELECT) are not audited — they are handled separately by the query shield feature.

OperationAction ValueBefore StateAfter StateDescription
CreateCREATE-Full recordCaptures the complete newly created record.
UpdateUPDATERecord before changeRecord after changeCaptures both the previous and new state so you can diff exactly what changed.
DeleteDELETERecord before deletion-Captures the full record state before it was removed.

Each audit entry also records:

  • Table name — which database table was affected (e.g., users, products)
  • Record ID — the primary key of the affected record
  • User attribution — UserID, UserEmail, UserRole from the request context
  • Request metadata — IP address, User-Agent, RequestID
  • Timestamp — when the operation occurred
  • Success flag — whether the database operation succeeded

Before/After Diffing

For UPDATE operations, the plugin captures the model state before the update runs (in a Before callback) and after it completes. Both states are serialized as JSON, giving you a complete before/after snapshot that you can diff to see exactly which fields changed.

The AuditLog Model

Every audit entry is represented by the AuditLog struct defined in the core package. This is the shape of the data that gets stored and returned by the API.

core/models.gogo
1// AuditLog represents an immutable audit trail entry.
2type AuditLog struct {
3 ID string `json:"id"`
4 Timestamp time.Time `json:"timestamp"`
5 UserID string `json:"user_id"`
6 UserEmail string `json:"user_email,omitempty"`
7 UserRole string `json:"user_role,omitempty"`
8 Action string `json:"action"`
9 Resource string `json:"resource"`
10 ResourceID string `json:"resource_id"`
11 Before JSONMap `json:"before,omitempty"`
12 After JSONMap `json:"after,omitempty"`
13 IP string `json:"ip"`
14 UserAgent string `json:"user_agent"`
15 Success bool `json:"success"`
16 Error string `json:"error,omitempty"`
17 RequestID string `json:"request_id"`
18}

Field Reference

FieldTypeDescription
IDstringUUID v4 unique identifier for the audit entry.
Timestamptime.TimeWhen the database operation occurred.
UserIDstringID of the user who performed the operation.
UserEmailstringEmail of the user (omitted if empty).
UserRolestringRole of the user (omitted if empty).
ActionstringThe operation type: CREATE, UPDATE, or DELETE.
ResourcestringThe database table name (e.g., users, orders).
ResourceIDstringThe primary key value of the affected record.
BeforeJSONMapJSON snapshot of the record before the change. Present for UPDATE and DELETE; omitted for CREATE.
AfterJSONMapJSON snapshot of the record after the change. Present for CREATE and UPDATE; omitted for DELETE.
IPstringClient IP address from the request context.
UserAgentstringUser-Agent header from the request context.
SuccessboolWhether the database operation completed without error.
ErrorstringError message if the operation failed (omitted on success).
RequestIDstringCorrelation ID to link multiple audit entries from a single HTTP request.

Full Example

The following example shows a complete main.go that sets up Sentinel with the GORM audit logging plugin, creates an HTTP handler that attaches request context, and performs a database mutation that produces an audit trail entry.

main.gogo
1package main
2
3import (
4 "net/http"
5
6 sentinel "github.com/MUKE-coder/sentinel"
7 sentinelgorm "github.com/MUKE-coder/sentinel/gorm"
8 "github.com/MUKE-coder/sentinel/pipeline"
9 "github.com/gin-gonic/gin"
10 "gorm.io/driver/sqlite"
11 "gorm.io/gorm"
12)
13
14type Product struct {
15 ID uint `gorm:"primaryKey" json:"id"`
16 Name string `json:"name"`
17 Price float64 `json:"price"`
18}
19
20func main() {
21 // --- Database setup ---
22 db, err := gorm.Open(sqlite.Open("app.db"), &gorm.Config{})
23 if err != nil {
24 panic("failed to connect database")
25 }
26 db.AutoMigrate(&Product{})
27
28 // --- Pipeline setup ---
29 pipe := pipeline.New(0) // default 10,000 buffer
30 pipe.Start(2)
31 defer pipe.Stop()
32
33 // --- Register GORM audit plugin ---
34 db.Use(sentinelgorm.New(pipe))
35
36 // --- Gin router ---
37 r := gin.Default()
38
39 // Mount Sentinel middleware + dashboard
40 sentinel.Mount(r, pipe, sentinel.Config{
41 WAF: sentinel.WAFConfig{
42 Enabled: true,
43 Mode: sentinel.ModeBlock,
44 },
45 })
46
47 // --- Routes ---
48 r.POST("/api/products", func(c *gin.Context) {
49 // Attach request info so audit entries include user attribution
50 ctx := sentinelgorm.WithRequestInfo(c.Request.Context(), &sentinelgorm.RequestInfo{
51 IP: c.ClientIP(),
52 UserID: c.GetString("user_id"),
53 UserEmail: c.GetString("user_email"),
54 UserRole: c.GetString("user_role"),
55 UserAgent: c.Request.UserAgent(),
56 RequestID: c.GetHeader("X-Request-ID"),
57 })
58
59 var product Product
60 if err := c.ShouldBindJSON(&product); err != nil {
61 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
62 return
63 }
64
65 // This Create() call automatically generates an audit log entry
66 if err := db.WithContext(ctx).Create(&product).Error; err != nil {
67 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
68 return
69 }
70
71 c.JSON(http.StatusCreated, product)
72 })
73
74 r.PUT("/api/products/:id", func(c *gin.Context) {
75 ctx := sentinelgorm.WithRequestInfo(c.Request.Context(), &sentinelgorm.RequestInfo{
76 IP: c.ClientIP(),
77 UserID: c.GetString("user_id"),
78 UserEmail: c.GetString("user_email"),
79 UserAgent: c.Request.UserAgent(),
80 })
81
82 var product Product
83 if err := db.WithContext(ctx).First(&product, c.Param("id")).Error; err != nil {
84 c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
85 return
86 }
87
88 if err := c.ShouldBindJSON(&product); err != nil {
89 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
90 return
91 }
92
93 // This Save() call generates an UPDATE audit log with before/after state
94 if err := db.WithContext(ctx).Save(&product).Error; err != nil {
95 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
96 return
97 }
98
99 c.JSON(http.StatusOK, product)
100 })
101
102 r.DELETE("/api/products/:id", func(c *gin.Context) {
103 ctx := sentinelgorm.WithRequestInfo(c.Request.Context(), &sentinelgorm.RequestInfo{
104 IP: c.ClientIP(),
105 UserID: c.GetString("user_id"),
106 UserAgent: c.Request.UserAgent(),
107 })
108
109 var product Product
110 if err := db.WithContext(ctx).First(&product, c.Param("id")).Error; err != nil {
111 c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
112 return
113 }
114
115 // This Delete() call generates a DELETE audit log with the before state
116 if err := db.WithContext(ctx).Delete(&product).Error; err != nil {
117 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
118 return
119 }
120
121 c.JSON(http.StatusOK, gin.H{"message": "deleted"})
122 })
123
124 r.Run(":8080")
125}

Dashboard

The Sentinel dashboard includes a dedicated Audit Logs page that provides a searchable, filterable view of your entire audit trail. Access it at http://localhost:8080/sentinel/ui and navigate to the Audit Logs section.

  • Search and filter — filter audit entries by user ID, action type (CREATE, UPDATE, DELETE), resource (table name), and date range.
  • Before/after diff — expand any UPDATE entry to see the full before and after JSON state side by side.
  • User attribution — each entry shows who performed the action, from which IP, and with which user agent.
  • Pagination — large audit trails are paginated for fast browsing.

Dashboard Access

The audit logs page is part of the Sentinel dashboard UI. It is available automatically when you call sentinel.Mount() — no additional configuration is needed beyond setting up the GORM plugin.

API

Audit logs are available via the Sentinel REST API. Use the GET /sentinel/api/audit-logs endpoint to query the audit trail programmatically.

Query Parameters

ParameterTypeDefaultDescription
user_idstring-Filter by the user who performed the action.
actionstring-Filter by action type: CREATE, UPDATE, or DELETE.
resourcestring-Filter by table name (e.g., users, products).
start_timestring-ISO 8601 / RFC 3339 timestamp. Only return entries after this time.
end_timestring-ISO 8601 / RFC 3339 timestamp. Only return entries before this time.
pageint1Page number for pagination.
page_sizeint20Number of entries per page.

Example Request

curl "http://localhost:8080/sentinel/api/audit-logs?action=UPDATE&resource=products&page=1&page_size=10"

Example Response

Responsejson
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"timestamp": "2025-01-15T14:30:00Z",
"user_id": "user-42",
"user_email": "admin@example.com",
"user_role": "admin",
"action": "UPDATE",
"resource": "products",
"resource_id": "7",
"before": {
"id": 7,
"name": "Widget",
"price": 9.99
},
"after": {
"id": 7,
"name": "Widget Pro",
"price": 14.99
},
"ip": "10.0.0.1",
"user_agent": "Mozilla/5.0 ...",
"success": true,
"request_id": "req-abc-123"
}
],
"meta": {
"total": 1,
"page": 1,
"page_size": 10
}
}

Testing

You can verify that audit entries are being generated by writing a test that collects events from the pipeline. The pattern below uses an in-memory SQLite database and a custom pipeline handler that captures audit events.

audit_test.gogo
1package main_test
2
3import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 sentinel "github.com/MUKE-coder/sentinel/core"
10 sentinelgorm "github.com/MUKE-coder/sentinel/gorm"
11 "github.com/MUKE-coder/sentinel/pipeline"
12 "github.com/glebarez/sqlite"
13 "gorm.io/gorm"
14)
15
16type Product struct {
17 ID uint `gorm:"primaryKey" json:"id"`
18 Name string `json:"name"`
19 Price float64 `json:"price"`
20}
21
22// auditCollector captures audit events from the pipeline.
23type auditCollector struct {
24 mu sync.Mutex
25 audits []*sentinel.AuditLog
26}
27
28func (ac *auditCollector) Handle(ctx context.Context, event pipeline.Event) error {
29 if event.Type == pipeline.EventAudit {
30 if al, ok := event.Payload.(*sentinel.AuditLog); ok {
31 ac.mu.Lock()
32 ac.audits = append(ac.audits, al)
33 ac.mu.Unlock()
34 }
35 }
36 return nil
37}
38
39func TestAuditLogging(t *testing.T) {
40 // Set up pipeline with a collector
41 pipe := pipeline.New(100)
42 collector := &auditCollector{}
43 pipe.AddHandler(collector)
44 pipe.Start(1)
45 defer pipe.Stop()
46
47 // Open in-memory SQLite
48 db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
49 if err != nil {
50 t.Fatal(err)
51 }
52
53 // Register plugin and migrate
54 db.Use(sentinelgorm.New(pipe))
55 db.AutoMigrate(&Product{})
56
57 // Attach request info
58 ctx := sentinelgorm.WithRequestInfo(context.Background(), &sentinelgorm.RequestInfo{
59 IP: "10.0.0.1",
60 UserID: "user-42",
61 })
62
63 // CREATE — should produce an audit entry
64 product := Product{Name: "Widget", Price: 9.99}
65 db.WithContext(ctx).Create(&product)
66
67 // Wait for async pipeline processing
68 time.Sleep(100 * time.Millisecond)
69
70 // Verify the audit entry
71 collector.mu.Lock()
72 defer collector.mu.Unlock()
73
74 if len(collector.audits) != 1 {
75 t.Fatalf("expected 1 audit entry, got %d", len(collector.audits))
76 }
77
78 entry := collector.audits[0]
79 if entry.Action != "CREATE" {
80 t.Errorf("expected action CREATE, got %s", entry.Action)
81 }
82 if entry.Resource != "products" {
83 t.Errorf("expected resource products, got %s", entry.Resource)
84 }
85 if entry.UserID != "user-42" {
86 t.Errorf("expected user_id user-42, got %s", entry.UserID)
87 }
88 if entry.IP != "10.0.0.1" {
89 t.Errorf("expected IP 10.0.0.1, got %s", entry.IP)
90 }
91 if entry.After == nil {
92 t.Error("expected After state to be present")
93 }
94 if entry.After["name"] != "Widget" {
95 t.Errorf("expected After.name=Widget, got %v", entry.After["name"])
96 }
97}

Pipeline is Asynchronous

Audit events are processed asynchronously by pipeline workers. In tests, add a short time.Sleep (50-100ms) after database operations to allow the pipeline to flush before asserting on collected audit entries.

You can also verify audit entries via the API in an integration test by sending an HTTP request and then querying the audit logs endpoint:

# Create a product
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "price": 9.99}'
# Check audit trail
curl "http://localhost:8080/sentinel/api/audit-logs?resource=products&action=CREATE"

Next Steps

  • Getting Started — Set up the core Sentinel middleware
  • Dashboard — Browse audit logs and other security data in the UI
  • Performance — Monitor route-level performance alongside audit trails
  • Alerting — Get notified when threat events occur
  • WAF — Protect your application from common web attacks

Built with by JB