Audit log

Tamper-evident SHA-256 chain.

6 minmedium

"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

apps/api/internal/models/activity_log.go
type ActivityLog struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
SequenceID int64 `gorm:"uniqueIndex;autoIncrement"`
UserID *uuid.UUID `gorm:"type:uuid"` // nullable — system events
Event string `gorm:"not null;index"` // 'user.login', 'order.refund', ...
Subject string `gorm:"index"` // resource id this is about
Metadata datatypes.JSON
IP string
UserAgent string
CreatedAt time.Time
PrevHash string `gorm:"index"` // SHA-256 of the previous row's CanonicalHash
CanonicalHash 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 PrevHash doesn't match any existing row. Chain broken.
  • Edit a row's fields — the row's CanonicalHash no longer matches what its content hashes to. Chain broken.
  • Edit and recompute — possible, but every row AFTER the edit has stale PrevHash values 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

apps/api/internal/handlers/order.go (excerpt)
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.logout
  • user.register, user.role_changed
  • user.password_changed, user.totp_enabled
  • account.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.

Compliance gold: SOC 2 Type 2 auditors specifically ask about audit-log integrity. "Show me you can't silently edit history". Grit's hash chain answers this in 20 seconds.

Verifying the chain

Run periodically (cron job, weekly batch):

func VerifyAuditChain(db *gorm.DB) error {
var rows []ActivityLog
db.Order("sequence_id ASC").Find(&rows)
var prevHash string
for _, 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

A disgruntled employee with DB access deletes their own `user.role_changed` audit row to hide that they made themselves admin. What happens at the next chain verification?

Try it

Trigger an audit event and inspect the chain:

  1. Log into your API — that fires user.login.
  2. Open GORM Studio & look at the activity_logs table.
  3. Find the most recent row. Note its PrevHash and CanonicalHash.
  4. In notes.md: paste them, and explain what would happen if you edited Event from user.login to user.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