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
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.
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/gormgo 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.
1// 1. Create an event pipeline with a buffer of 10,000 events2pipe := pipeline.New(0) // 0 = DefaultBufferSize (10,000)34// 2. Start background workers to process audit events5pipe.Start(2) // 2 worker goroutines6defer pipe.Stop()78// 3. Register the Sentinel GORM plugin9db.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 shielding2db.Use(sentinelgorm.New(pipe, func(c *sentinelgorm.Config) {3 c.QueryShieldEnabled = false4}))56// Query shielding only — disable audit logging7db.Use(sentinelgorm.New(pipe, func(c *sentinelgorm.Config) {8 c.AuditEnabled = false9}))
Plugin Config
| Field | Type | Default | Description |
|---|---|---|---|
AuditEnabled | bool | true | Enables audit logging for CREATE, UPDATE, and DELETE operations. |
QueryShieldEnabled | bool | true | Enables query analysis (N+1 detection, unfiltered query warnings). |
SlowQueryThreshold | time.Duration | 200ms | Queries slower than this duration are flagged. |
N1QueryThreshold | int | 10 | Number 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.
1func CreateProductHandler(c *gin.Context) {2 // Build request info from the current HTTP request3 ctx := sentinelgorm.WithRequestInfo(c.Request.Context(), &sentinelgorm.RequestInfo{4 IP: c.ClientIP(),5 UserID: c.GetString("user_id"), // from your auth middleware6 UserEmail: c.GetString("user_email"),7 UserRole: c.GetString("user_role"),8 UserAgent: c.Request.UserAgent(),9 RequestID: c.GetString("request_id"),10 })1112 product := Product{Name: "Widget", Price: 9.99}1314 // Use db.WithContext(ctx) so the plugin picks up the request info15 if err := db.WithContext(ctx).Create(&product).Error; err != nil {16 c.JSON(500, gin.H{"error": err.Error()})17 return18 }1920 c.JSON(201, product)21}
Context is Required for Attribution
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
| Field | Type | Description |
|---|---|---|
IP | string | Client IP address of the request originator. |
UserID | string | Authenticated user identifier (from your auth middleware). |
UserEmail | string | Email address of the authenticated user. |
UserRole | string | Role of the authenticated user (e.g., "admin", "editor"). |
UserAgent | string | The User-Agent header from the HTTP request. |
RequestID | string | Unique 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.
| Operation | Action Value | Before State | After State | Description |
|---|---|---|---|---|
| Create | CREATE | - | Full record | Captures the complete newly created record. |
| Update | UPDATE | Record before change | Record after change | Captures both the previous and new state so you can diff exactly what changed. |
| Delete | DELETE | Record 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
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.
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
| Field | Type | Description |
|---|---|---|
ID | string | UUID v4 unique identifier for the audit entry. |
Timestamp | time.Time | When the database operation occurred. |
UserID | string | ID of the user who performed the operation. |
UserEmail | string | Email of the user (omitted if empty). |
UserRole | string | Role of the user (omitted if empty). |
Action | string | The operation type: CREATE, UPDATE, or DELETE. |
Resource | string | The database table name (e.g., users, orders). |
ResourceID | string | The primary key value of the affected record. |
Before | JSONMap | JSON snapshot of the record before the change. Present for UPDATE and DELETE; omitted for CREATE. |
After | JSONMap | JSON snapshot of the record after the change. Present for CREATE and UPDATE; omitted for DELETE. |
IP | string | Client IP address from the request context. |
UserAgent | string | User-Agent header from the request context. |
Success | bool | Whether the database operation completed without error. |
Error | string | Error message if the operation failed (omitted on success). |
RequestID | string | Correlation 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.
1package main23import (4 "net/http"56 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)1314type Product struct {15 ID uint `gorm:"primaryKey" json:"id"`16 Name string `json:"name"`17 Price float64 `json:"price"`18}1920func 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{})2728 // --- Pipeline setup ---29 pipe := pipeline.New(0) // default 10,000 buffer30 pipe.Start(2)31 defer pipe.Stop()3233 // --- Register GORM audit plugin ---34 db.Use(sentinelgorm.New(pipe))3536 // --- Gin router ---37 r := gin.Default()3839 // Mount Sentinel middleware + dashboard40 sentinel.Mount(r, pipe, sentinel.Config{41 WAF: sentinel.WAFConfig{42 Enabled: true,43 Mode: sentinel.ModeBlock,44 },45 })4647 // --- Routes ---48 r.POST("/api/products", func(c *gin.Context) {49 // Attach request info so audit entries include user attribution50 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 })5859 var product Product60 if err := c.ShouldBindJSON(&product); err != nil {61 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})62 return63 }6465 // This Create() call automatically generates an audit log entry66 if err := db.WithContext(ctx).Create(&product).Error; err != nil {67 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})68 return69 }7071 c.JSON(http.StatusCreated, product)72 })7374 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 })8182 var product Product83 if err := db.WithContext(ctx).First(&product, c.Param("id")).Error; err != nil {84 c.JSON(http.StatusNotFound, gin.H{"error": "not found"})85 return86 }8788 if err := c.ShouldBindJSON(&product); err != nil {89 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})90 return91 }9293 // This Save() call generates an UPDATE audit log with before/after state94 if err := db.WithContext(ctx).Save(&product).Error; err != nil {95 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})96 return97 }9899 c.JSON(http.StatusOK, product)100 })101102 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 })108109 var product Product110 if err := db.WithContext(ctx).First(&product, c.Param("id")).Error; err != nil {111 c.JSON(http.StatusNotFound, gin.H{"error": "not found"})112 return113 }114115 // This Delete() call generates a DELETE audit log with the before state116 if err := db.WithContext(ctx).Delete(&product).Error; err != nil {117 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})118 return119 }120121 c.JSON(http.StatusOK, gin.H{"message": "deleted"})122 })123124 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
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
| Parameter | Type | Default | Description |
|---|---|---|---|
user_id | string | - | Filter by the user who performed the action. |
action | string | - | Filter by action type: CREATE, UPDATE, or DELETE. |
resource | string | - | Filter by table name (e.g., users, products). |
start_time | string | - | ISO 8601 / RFC 3339 timestamp. Only return entries after this time. |
end_time | string | - | ISO 8601 / RFC 3339 timestamp. Only return entries before this time. |
page | int | 1 | Page number for pagination. |
page_size | int | 20 | Number 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
{"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.
1package main_test23import (4 "context"5 "sync"6 "testing"7 "time"89 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)1516type Product struct {17 ID uint `gorm:"primaryKey" json:"id"`18 Name string `json:"name"`19 Price float64 `json:"price"`20}2122// auditCollector captures audit events from the pipeline.23type auditCollector struct {24 mu sync.Mutex25 audits []*sentinel.AuditLog26}2728func (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 nil37}3839func TestAuditLogging(t *testing.T) {40 // Set up pipeline with a collector41 pipe := pipeline.New(100)42 collector := &auditCollector{}43 pipe.AddHandler(collector)44 pipe.Start(1)45 defer pipe.Stop()4647 // Open in-memory SQLite48 db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})49 if err != nil {50 t.Fatal(err)51 }5253 // Register plugin and migrate54 db.Use(sentinelgorm.New(pipe))55 db.AutoMigrate(&Product{})5657 // Attach request info58 ctx := sentinelgorm.WithRequestInfo(context.Background(), &sentinelgorm.RequestInfo{59 IP: "10.0.0.1",60 UserID: "user-42",61 })6263 // CREATE — should produce an audit entry64 product := Product{Name: "Widget", Price: 9.99}65 db.WithContext(ctx).Create(&product)6667 // Wait for async pipeline processing68 time.Sleep(100 * time.Millisecond)6970 // Verify the audit entry71 collector.mu.Lock()72 defer collector.mu.Unlock()7374 if len(collector.audits) != 1 {75 t.Fatalf("expected 1 audit entry, got %d", len(collector.audits))76 }7778 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
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 productcurl -X POST http://localhost:8080/api/products \-H "Content-Type: application/json" \-d '{"name": "Widget", "price": 9.99}'# Check audit trailcurl "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