feat(audit): record enterprise mutations
This commit is contained in:
@@ -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)})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user