Audit log
Tamper-evident SHA-256 chain.
"Who changed this and when?" is a question every production app eventually answers. Grit's audit log answers it tamper-evidently â each row stores a SHA-256 hash of the previous row + its canonical fields. Delete a row, the chain breaks at verify time.
The audit log model
type ActivityLog struct {ID uuid.UUID `gorm:"type:uuid;primaryKey"`SequenceID int64 `gorm:"uniqueIndex;autoIncrement"`UserID *uuid.UUID `gorm:"type:uuid"` // nullable â system eventsEvent string `gorm:"not null;index"` // 'user.login', 'order.refund', ...Subject string `gorm:"index"` // resource id this is aboutMetadata datatypes.JSONIP stringUserAgent stringCreatedAt time.TimePrevHash string `gorm:"index"` // SHA-256 of the previous row's CanonicalHashCanonicalHash string // SHA-256 of THIS row's fields, including PrevHash}
Two hash columns. PrevHash chains back to the previous row; CanonicalHash is this row's identity. Hash construction:
CanonicalHash = SHA256(SequenceID + UserID + Event + Subject + Metadata + CreatedAt + PrevHash)
Why this is tamper-evident
Suppose an attacker has DB access and tries to hide their actions:
- Delete a row â the next row's
PrevHashdoesn't match any existing row. Chain broken. - Edit a row's fields â the row's
CanonicalHashno longer matches what its content hashes to. Chain broken. - Edit and recompute â possible, but every row AFTER the edit has stale
PrevHashvalues that no longer match. To stay consistent, they'd have to rebuild every subsequent row's hash â and the verifier still notices the mismatch with a hash you exported earlier.
Same pattern as git commits and blockchain blocks. Cryptographic chaining, no central authority.
Writing audit events
func (h *OrderHandler) Refund(c *gin.Context) {order, _ := h.svc.RefundByID(ctx, orderID)middleware.LogSecurityEvent(c, h.db, middleware.AuditEvent{Event: "order.refund",Subject: order.ID.String(),Metadata: map[string]any{"amount": order.Total,"reason": "customer requested",},})respond.OK(c, order, "Refund processed")}
The middleware grabs user_id from context, computes thePrevHash from the last row, computes the new CanonicalHash, writes the row.
Standard events Grit pre-wires
user.login,user.logoutuser.register,user.role_changeduser.password_changed,user.totp_enabledaccount.locked(AuthShield triggered)authz.denied(RequireRoles rejected)sentinel.suspicious(WAF / Anomaly fired)
Add your domain events alongside: order.created, refund.issued, invoice.voided. Anything material to your business.
Verifying the chain
Run periodically (cron job, weekly batch):
func VerifyAuditChain(db *gorm.DB) error {var rows []ActivityLogdb.Order("sequence_id ASC").Find(&rows)var prevHash stringfor _, row := range rows {if row.PrevHash != prevHash {return fmt.Errorf("chain break at seq %d", row.SequenceID)}if row.CanonicalHash != computeHash(row, prevHash) {return fmt.Errorf("row tampered at seq %d", row.SequenceID)}prevHash = row.CanonicalHash}return nil}
Alert / page when this returns an error. Now you're informed about DB-level tampering.
Performance
Audit writes are synchronous (you don't want to lose audit events in a worker queue). Each write is one SQL INSERT + one SHA-256 hash â sub-millisecond on modern hardware. For very high-write systems, you can move this to the worker queue with an idempotency key, at the cost of weakened guarantees.
Quick check
Try it
Trigger an audit event and inspect the chain:
- Log into your API â that fires
user.login. - Open GORM Studio & look at the
activity_logstable. - Find the most recent row. Note its
PrevHashandCanonicalHash. - In
notes.md: paste them, and explain what would happen if you editedEventfromuser.logintouser.logout.
What's next
Final chapter â Deploy. Take what you built to a VPS with one command and a domain name.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled â suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub