2 minute readLast updated on

Implementing Audit Log using GORM in Go

Go
gORM

In many applications, it's essential to keep track of changes made to data in the database for auditing and debugging purposes. One common approach is to implement an audit log, which records information about the operations performed on the data. In this blog post, we will explore how to implement an audit log using GORM, a popular ORM library for Go.

Prerequisites

Before we dive into the implementation, make sure you have the following prerequisites in place:

  • Go installed on your machine
  • GORM library installed (go get -u gorm.io/gorm)
  • SQLite database installed (go get -u gorm.io/driver/sqlite)

Initial setup

To get started, we need to set up a SQLite database and define the required models. In this example, we'll create a sample table called YourTable to demonstrate the CREATE, UPDATE, and DELETE operations.

To set up the SQLite database and define the models, follow these steps:

  • Connect to the SQLite database using GORM. Ensure that you import the necessary packages (gorm.io/gorm and gorm.io/driver/sqlite).

  • A YourTable model struct is defined, which represents your application data. Sample fields are included such as Id, Name, Description, and CreatedAt.

  • Use db.AutoMigrate(&YourTable{}) to automatically create or migrate the your_tables table based on the defined model.

Here's an example of the sample audit log code:

package main

import (
	"encoding/json"
	"fmt"
	"reflect"
	"time"

	"github.com/oklog/ulid/v2"
	"github.com/sirupsen/logrus"
	"gorm.io/datatypes"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// YourTable represents sample table
type YourTable struct {
	Id          string         `json:"id" gorm:"primaryKey"`
	Name        string         `json:"name"`
	Description string         `json:"description"`
	CreatedAt   time.Time      `json:"created_at"`
	UpdatedAt   time.Time      `json:"updated_at"`
	DeletedAt   gorm.DeletedAt `json:"deleted_at"`
}

// AuditLog represents the audit log model
type AuditLog struct {
	Id            string         `json:"id" gorm:"primaryKey"`
	TableName     string         `json:"table_name"`
	OperationType string         `json:"operation_type"`
	ObjectId      string         `json:"object_id"`
	Data          datatypes.JSON `json:"data"`
	UserId        string         `json:"user_id"`
	CreatedAt     time.Time      `json:"created_at"`
}

// Sample method to retrieve user currently using the system
func getCurrentUser() string {
	return "admin"
}

func main() {
	// Connect to the SQLite database
	db, err := gorm.Open(sqlite.Open("your_database.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect to database")
	}

	db.AutoMigrate(
		&YourTable{},
		&AuditLog{},
	)

	// Register the audit log callbacks and perform migrations
	err = RegisterCallbacks(db)
	if err != nil {
		panic("failed to register audit log callbacks")
	}

	sampleTable := &YourTable{
		Id:          ulid.Make().String(),
		Name:        "John Doe",
		Description: "This is a sample text",
		CreatedAt:   time.Now(),
	}

	if err := db.Create(&sampleTable).Error; err != nil {
		panic(err)
	}

	sampleTable.Description = "Changed description"

	if err := db.Save(&sampleTable).Error; err != nil {
		panic(err)
	}

	if err := db.Delete(&sampleTable).Error; err != nil {
		panic(err)
	}

	auditLogs := []*AuditLog{}

	if err := db.Find(&auditLogs).Error; err != nil {
		panic(err)
	}

    // Print the audit logs to view them. You should see a CREATE, UPDATE and DELETE log.

}

func getKeyFromData(key string, data map[string]interface{}) string {
	objId, ok := data[key]
	if !ok {
		return ""
	}
	return objId.(string)
}

func prepareData(data map[string]interface{}) datatypes.JSON {
	dataByte, _ := json.Marshal(&data)
	return dataByte
}

func createAuditLog(db *gorm.DB) {
	if db.Statement.Schema != nil && db.Statement.Schema.Table == "audit_logs" || db.Error != nil {
		return
	}

	recordMap, err := getDataBeforeOperation(db)
	if err != nil {
		return
	}
	objId := getKeyFromData("id", recordMap)
	auditLog := &AuditLog{
		Id:            ulid.Make().String(),
		TableName:     db.Statement.Schema.Table,
		OperationType: "CREATE",
		ObjectId:      objId,
		Data:          prepareData(recordMap),
		UserId:        getCurrentUser(),
	}
	if err := db.Session(&gorm.Session{SkipHooks: true, NewDB: true}).Table("audit_logs").Create(auditLog).Error; err != nil {
		logrus.Error(fmt.Errorf("error in audit log creation: %s", err.Error()))
		return
	}
}

func updateAuditLog(db *gorm.DB) {
	if db.Statement.Schema != nil && db.Statement.Schema.Table == "audit_logs" || db.Error != nil {
		return
	}

	recordMap, err := getDataBeforeOperation(db)
	if err != nil {
		return
	}
	objId := getKeyFromData("id", recordMap)
	auditLog := &AuditLog{
		Id:            ulid.Make().String(),
		TableName:     db.Statement.Schema.Table,
		OperationType: "UPDATE",
		ObjectId:      objId,
		Data:          prepareData(recordMap),
		UserId:        getCurrentUser(),
	}
	if err := db.Session(&gorm.Session{SkipHooks: true, NewDB: true}).Table("audit_logs").Create(auditLog).Error; err != nil {
		logrus.Error(fmt.Errorf("error in audit log creation: %s", err.Error()))
		return
	}
}

func deleteAuditLog(db *gorm.DB) {
	if db.Statement.Schema != nil && db.Statement.Schema.Table == "audit_logs" || db.Error != nil {
		return
	}

	recordMap, err := getDataBeforeOperation(db)
	if err != nil {
		return
	}
	objId := getKeyFromData("id", recordMap)
	auditLog := &AuditLog{
		Id:            ulid.Make().String(),
		TableName:     db.Statement.Schema.Table,
		OperationType: "DELETE",
		ObjectId:      objId,
		Data:          prepareData(recordMap),
		UserId:        getCurrentUser(),
	}
	if err := db.Session(&gorm.Session{SkipHooks: true, NewDB: true}).Table("audit_logs").Create(auditLog).Error; err != nil {
		logrus.Error(fmt.Errorf("error in audit log creation: %s", err.Error()))
		return
	}
}

func getDataBeforeOperation(db *gorm.DB) (map[string]interface{}, error) {
	objMap := map[string]interface{}{}
	if db.Error == nil && !db.DryRun {
		objectType := reflect.TypeOf(db.Statement.ReflectValue.Interface())

		// Create a new instance of the object type
		targetObj := reflect.New(objectType).Interface()

		primaryKeyValue := ""
		value := db.Statement.ReflectValue

		// Check if the value is a struct
		if value.Kind() == reflect.Struct {
			primaryKeyValue = value.FieldByName("Id").String()
		}

		// Fetch the target object separately
		if err := db.Session(&gorm.Session{SkipHooks: true, NewDB: true}).Where("id = ?", primaryKeyValue).First(&targetObj).Error; err != nil {
			logrus.Error(fmt.Errorf("gorm callback: error while finding target object: %s", err.Error()))
			return nil, err
		}

		jsonBytes, err := json.Marshal(targetObj)
		if err != nil {
			logrus.Error(fmt.Errorf("gorm callback: error while marshalling json data: %s", err.Error()))
			return nil, err
		}
		json.Unmarshal(jsonBytes, &objMap)
	}
	return objMap, nil
}

func RegisterCallbacks(db *gorm.DB) error {
	db.Callback().Create().After("gorm:create").Register("custom_plugin:create_audit_log", createAuditLog)
	db.Callback().Update().After("gorm:update").Register("custom_plugin:update_audit_log", updateAuditLog)
	db.Callback().Delete().Before("gorm:delete").Register("custom_plugin:delete_audit_log", deleteAuditLog)
	return nil
}

The changes are currently saved in your_database.db SQLite database file.

With the database and model set up, we can now proceed to implement the audit log functionality using GORM.

Implementing the Audit Log

In the above code, we have defined several functions that are responsible for creating audit log entries. These functions are registered as GORM callbacks for the respective operations (create, update, and delete).

The createAuditLog, updateAuditLog, and deleteAuditLog functions retrieve the necessary data before the operation using the getDataBeforeOperation function. They construct an AuditLog object with the required information and use GORM's Create method to store the audit log entry in the audit_logs table. The callbacks ensure that creating an audit log does not trigger that callback again by performing the check at the start of each callback.

The getDataBeforeOperation function retrieves the data before the database operation by reflecting on the GORM statement's reflected value. It is essential to note that a new GORM session should be initialized with a NewDB so that the existing conditions(of the create, update or delete query) do not affect the find query. It creates a new instance of the object type, fetches the target object from the database, and serializes it to JSON. Finally, it deserializes the JSON into a map.

The RegisterCallbacks function is responsible for registering the audit log callbacks for the respective GORM operations. It also ensures that the necessary database table (your_tables) is created or migrated using db.AutoMigrate.

Conclusion

Implementing an audit log using GORM allows you to track changes made to your application data, enabling auditing, debugging, and maintaining data integrity. By following the steps outlined in this blog post, you can easily incorporate audit log functionality into your Go applications. Remember to customize the code to fit your specific application needs, such as the structure of your application data and any additional fields you want to include in the audit log entries. Having an audit log in place provides transparency, accountability, and traceability for data changes, which is essential for many applications.

Get in touch 👋

Feel free to email me about anything. Want some advice? Give some feedback?

You can also reach me around the web: GitHub, Twitter, Instagram