feat(audit): record enterprise mutations

This commit is contained in:
2026-06-05 10:33:33 +08:00
parent 3f8f04d65a
commit 4597d404fd
9 changed files with 341 additions and 26 deletions
+46
View File
@@ -0,0 +1,46 @@
package v1
import (
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
)
type auditMutation struct {
AccountID uint
AuditableType string
AuditableID uint
Action string
AuditedChanges interface{}
Comment string
}
func recordAuditMutation(c *gin.Context, auditSvc *service.AuditService, mutation auditMutation) {
if auditSvc == nil {
return
}
_, err := auditSvc.Record(c.Request.Context(), service.AuditRecord{
AccountID: mutation.AccountID,
UserID: getUserID(c),
AuditableType: mutation.AuditableType,
AuditableID: mutation.AuditableID,
Action: mutation.Action,
AuditedChanges: mutation.AuditedChanges,
RemoteAddress: c.ClientIP(),
RequestUUID: firstNonEmpty(c.GetHeader("X-Request-ID"), c.GetHeader("X-Correlation-ID")),
Comment: mutation.Comment,
})
if err != nil {
applogger.L().Warnf("audit mutation skipped type=%s id=%d action=%s: %v", mutation.AuditableType, mutation.AuditableID, mutation.Action, err)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
@@ -6,13 +6,15 @@ import (
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
)
// AutomationRuleHandler handles automation rule API endpoints.
// Reference: Chatwoot AutomationRulesController — CRUD + clone
type AutomationRuleHandler struct {
svc *automation.AutomationRuleService
svc *automation.AutomationRuleService
auditSvc *service.AuditService
}
// NewAutomationRuleHandler creates a new AutomationRuleHandler.
@@ -20,6 +22,11 @@ func NewAutomationRuleHandler(svc *automation.AutomationRuleService) *Automation
return &AutomationRuleHandler{svc: svc}
}
func (h *AutomationRuleHandler) WithAuditService(auditSvc *service.AuditService) *AutomationRuleHandler {
h.auditSvc = auditSvc
return h
}
type automationRuleRequest struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -116,6 +123,13 @@ func (h *AutomationRuleHandler) Create(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "AutomationRule",
AuditableID: rule.ID,
Action: "create",
AuditedChanges: serializeAutomationRule(rule),
})
c.JSON(http.StatusOK, serializeAutomationRule(rule))
}
@@ -162,6 +176,13 @@ func (h *AutomationRuleHandler) Update(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "AutomationRule",
AuditableID: updated.ID,
Action: "update",
AuditedChanges: serializeAutomationRule(updated),
})
c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(updated)})
}
@@ -184,6 +205,13 @@ func (h *AutomationRuleHandler) Delete(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "AutomationRule",
AuditableID: automationID,
Action: "destroy",
AuditedChanges: gin.H{"id": automationID},
})
c.Status(http.StatusOK)
}
@@ -208,6 +236,14 @@ func (h *AutomationRuleHandler) Clone(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "AutomationRule",
AuditableID: cloned.ID,
Action: "create",
AuditedChanges: serializeAutomationRule(cloned),
Comment: "cloned from automation rule",
})
c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(cloned)})
}
@@ -250,6 +286,13 @@ func (h *AutomationRuleHandler) ToggleActive(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "AutomationRule",
AuditableID: rule.ID,
Action: "update",
AuditedChanges: gin.H{"active": rule.Active},
})
c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(rule)})
}
+15 -1
View File
@@ -13,13 +13,15 @@ import (
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
)
// CsatSurveyHandler handles CSAT survey API endpoints.
// Reference: Chatwoot CsatSurveyResponsesController — list, metrics, review notes, public access
type CsatSurveyHandler struct {
svc *automation.CsatSurveyService
svc *automation.CsatSurveyService
auditSvc *service.AuditService
}
// NewCsatSurveyHandler creates a new CsatSurveyHandler.
@@ -27,6 +29,11 @@ func NewCsatSurveyHandler(svc *automation.CsatSurveyService) *CsatSurveyHandler
return &CsatSurveyHandler{svc: svc}
}
func (h *CsatSurveyHandler) WithAuditService(auditSvc *service.AuditService) *CsatSurveyHandler {
h.auditSvc = auditSvc
return h
}
// List retrieves CSAT survey responses for an account with optional filters.
// GET /api/v1/accounts/:account_id/csat_survey_responses
func (h *CsatSurveyHandler) List(c *gin.Context) {
@@ -106,6 +113,13 @@ func (h *CsatSurveyHandler) UpdateReviewNotes(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: resp.AccountID,
AuditableType: "CsatSurveyResponse",
AuditableID: resp.ID,
Action: "update",
AuditedChanges: gin.H{"csat_review_notes": notes},
})
c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp))
}
+29 -2
View File
@@ -14,7 +14,8 @@ import (
// CustomRoleHandler handles CustomRole CRUD operations.
// Reference: Chatwoot enterprise/app/controllers/api/v1/custom_roles_controller.rb
type CustomRoleHandler struct {
svc *service.CustomRoleService
svc *service.CustomRoleService
auditSvc *service.AuditService
}
// NewCustomRoleHandler creates a new CustomRole handler.
@@ -22,6 +23,11 @@ func NewCustomRoleHandler(svc *service.CustomRoleService) *CustomRoleHandler {
return &CustomRoleHandler{svc: svc}
}
func (h *CustomRoleHandler) WithAuditService(auditSvc *service.AuditService) *CustomRoleHandler {
h.auditSvc = auditSvc
return h
}
// List returns all custom roles for an account.
// GET /api/v1/accounts/:account_id/custom_roles
func (h *CustomRoleHandler) List(c *gin.Context) {
@@ -66,6 +72,13 @@ func (h *CustomRoleHandler) Create(c *gin.Context) {
handleServiceError(c, err)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "CustomRole",
AuditableID: role.ID,
Action: "create",
AuditedChanges: role,
})
response.Created(c, role)
}
@@ -125,6 +138,13 @@ func (h *CustomRoleHandler) Update(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "CustomRole",
AuditableID: role.ID,
Action: "update",
AuditedChanges: role,
})
response.OK(c, role)
}
@@ -149,6 +169,13 @@ func (h *CustomRoleHandler) Delete(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "CustomRole",
AuditableID: id,
Action: "destroy",
AuditedChanges: gin.H{"id": id},
})
response.NoContent(c)
}
@@ -163,4 +190,4 @@ func RegisterCustomRoleRoutes(rg *gin.RouterGroup, h *CustomRoleHandler) {
customRoles.PUT("/:id", h.Update)
customRoles.DELETE("/:id", h.Delete)
}
}
}
@@ -2,6 +2,7 @@ package v1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -31,12 +32,13 @@ func (s *CustomRoleHandlerTestSuite) SetupSuite() {
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.CustomRole{}))
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.CustomRole{}, &model.Audit{}))
s.db = db
repo := repository.NewCustomRoleRepo(db)
svc := service.NewCustomRoleService(repo)
s.handler = NewCustomRoleHandler(svc)
auditSvc := service.NewAuditService(repository.NewAuditRepo(db))
s.handler = NewCustomRoleHandler(svc).WithAuditService(auditSvc)
s.account = &model.Account{Name: "test-custom-role-account"}
s.Require().NoError(db.Create(s.account).Error)
@@ -53,6 +55,11 @@ func TestCustomRoleHandlerSuite(t *testing.T) {
suite.Run(t, new(CustomRoleHandlerTestSuite))
}
func (s *CustomRoleHandlerTestSuite) SetupTest() {
s.Require().NoError(s.db.Exec("DELETE FROM audits").Error)
s.Require().NoError(s.db.Exec("DELETE FROM custom_roles").Error)
}
func (s *CustomRoleHandlerTestSuite) TestList_Success() {
r := gin.New()
r.GET("/api/v1/accounts/:account_id/custom_roles", s.handler.List)
@@ -175,4 +182,64 @@ func (s *CustomRoleHandlerTestSuite) TestDelete_Success() {
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
}
}
func (s *CustomRoleHandlerTestSuite) TestMutations_WriteAuditEntries() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/custom_roles", withCustomRoleAuditContext(s.account.ID, 77, s.handler.Create))
r.PUT("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAuditContext(s.account.ID, 77, s.handler.Update))
r.DELETE("/api/v1/accounts/:account_id/custom_roles/:id", withCustomRoleAuditContext(s.account.ID, 77, s.handler.Delete))
createBody := `{"custom_role":{"name":"audit-role","permissions":{"conversation_manage":"full"}}}`
createReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/custom_roles", s.account.ID), bytes.NewBufferString(createBody))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("X-Request-ID", "audit-create-req")
createReq.RemoteAddr = "203.0.113.20:1234"
createW := httptest.NewRecorder()
r.ServeHTTP(createW, createReq)
s.Require().Equal(http.StatusCreated, createW.Code)
var createResp struct {
Data model.CustomRole `json:"data"`
}
s.Require().NoError(json.Unmarshal(createW.Body.Bytes(), &createResp))
roleID := createResp.Data.ID
updateBody := `{"custom_role":{"name":"audit-role-updated","permissions":{"contact_manage":"full"}}}`
updateReq, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, roleID), bytes.NewBufferString(updateBody))
updateReq.Header.Set("Content-Type", "application/json")
updateW := httptest.NewRecorder()
r.ServeHTTP(updateW, updateReq)
s.Require().Equal(http.StatusOK, updateW.Code)
deleteReq, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/custom_roles/%d", s.account.ID, roleID), nil)
deleteW := httptest.NewRecorder()
r.ServeHTTP(deleteW, deleteReq)
s.Require().Equal(http.StatusNoContent, deleteW.Code)
var audits []model.Audit
s.Require().NoError(s.db.Order("id ASC").Find(&audits).Error)
s.Require().Len(audits, 3)
for _, audit := range audits {
s.Equal(s.account.ID, *audit.AccountID)
s.Equal("Account", audit.AssociatedType)
s.Equal(s.account.ID, *audit.AssociatedID)
s.Equal(uint(77), *audit.UserID)
s.Equal("User", audit.UserType)
s.Equal("CustomRole", audit.AuditableType)
s.Equal(roleID, audit.AuditableID)
s.NotEmpty(audit.AuditedChanges)
}
s.Equal("create", audits[0].Action)
s.Equal("audit-create-req", audits[0].RequestUUID)
s.Equal("update", audits[1].Action)
s.Equal("destroy", audits[2].Action)
}
func withCustomRoleAuditContext(accountID uint, userID uint, h gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("account_id", accountID)
c.Set("user_id", userID)
h(c)
}
}
+29 -1
View File
@@ -8,13 +8,15 @@ import (
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
)
// MacroHandler handles macro API endpoints.
// Reference: Chatwoot MacrosController — CRUD + execute
type MacroHandler struct {
svc *automation.MacroService
svc *automation.MacroService
auditSvc *service.AuditService
}
// NewMacroHandler creates a new MacroHandler.
@@ -22,6 +24,11 @@ func NewMacroHandler(svc *automation.MacroService) *MacroHandler {
return &MacroHandler{svc: svc}
}
func (h *MacroHandler) WithAuditService(auditSvc *service.AuditService) *MacroHandler {
h.auditSvc = auditSvc
return h
}
// List retrieves all macros for an account, respecting visibility.
// GET /api/v1/accounts/:account_id/macros
func (h *MacroHandler) List(c *gin.Context) {
@@ -101,6 +108,13 @@ func (h *MacroHandler) Create(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "Macro",
AuditableID: macro.ID,
Action: "create",
AuditedChanges: serializeMacro(macro),
})
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)})
}
@@ -143,6 +157,13 @@ func (h *MacroHandler) Update(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "Macro",
AuditableID: updated.ID,
Action: "update",
AuditedChanges: serializeMacro(updated),
})
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(updated)})
}
@@ -175,6 +196,13 @@ func (h *MacroHandler) Delete(c *gin.Context) {
handleServiceError(c, svcErr)
return
}
recordAuditMutation(c, h.auditSvc, auditMutation{
AccountID: accountID,
AuditableType: "Macro",
AuditableID: macroID,
Action: "destroy",
AuditedChanges: gin.H{"id": macroID},
})
c.Status(http.StatusOK)
}