init push
This commit is contained in:
359
internal/transport/http/handlers/audit_handler.go
Normal file
359
internal/transport/http/handlers/audit_handler.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"wucher/internal/domain/audit"
|
||||
"wucher/internal/service"
|
||||
sharedconst "wucher/internal/shared/const"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
"wucher/internal/transport/http/dto"
|
||||
"wucher/internal/transport/http/jsonapi"
|
||||
"wucher/internal/transport/http/response"
|
||||
)
|
||||
|
||||
type AuditHandler struct {
|
||||
svc *service.AuditLogService
|
||||
}
|
||||
|
||||
func NewAuditHandler(svc *service.AuditLogService) *AuditHandler {
|
||||
return &AuditHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ListAuditLogs godoc
|
||||
// @Summary List audit logs
|
||||
// @Description JSON:API list with pagination, filtering and sorting.
|
||||
// @Tags Audit Logs
|
||||
// @Produce json
|
||||
// @Param filter[search] query string false "Search by request_id/layer/action/method/path/message (contains)"
|
||||
// @Param filter[method] query string false "Method filter; single/comma-separated values or ALL/*" Enums(POST,PUT,PATCH,DELETE,UPDATE,ALL,*)
|
||||
// @Param method query string false "Alias for filter[method]" Enums(POST,PUT,PATCH,DELETE,UPDATE,ALL,*)
|
||||
// @Param filter[module] query string false "Module filter; single/comma-separated values or ALL/*" Enums(air_rescuer_checklist,audit_log,auth,base,base_regular,base_hems,base_roster,branding,contact,duty_roster,facility,federal_state,file_manager,flight,flight_data,forces_present,health_insurance_company,helicopter,helicopter_file,hems_roster,hospital,icao,insurance_patient_data,land,master_setting,medicine,mission,opc,patient_data,reserve_ac,role,user,vocation,unknown,ALL,*)
|
||||
// @Param module query string false "Alias for filter[module]" Enums(air_rescuer_checklist,audit_log,auth,base,base_regular,base_hems,base_roster,branding,contact,duty_roster,facility,federal_state,file_manager,flight,flight_data,forces_present,health_insurance_company,helicopter,helicopter_file,hems_roster,hospital,icao,insurance_patient_data,land,master_setting,medicine,mission,opc,patient_data,reserve_ac,role,user,vocation,unknown,ALL,*)
|
||||
// @Param filter[action] query string false "Action filter; single/comma-separated values or ALL/*" Enums(list,get,create,update,delete,export,custom,ALL,*)
|
||||
// @Param action query string false "Alias for filter[action]" Enums(list,get,create,update,delete,export,custom,ALL,*)
|
||||
// @Param page[number] query int false "Page number (default 1)"
|
||||
// @Param page[size] query int false "Page size (default 20, max 200)"
|
||||
// @Param sort query string false "Sort order" Enums(created_at,-created_at,module,-module,action,-action,layer,-layer,status_code,-status_code,success,-success)
|
||||
// @Success 200 {object} jsonapi.Document
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 422 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/audit-logs/get-all [get]
|
||||
func (h *AuditHandler) List(c *fiber.Ctx) error {
|
||||
filter := c.Query("filter[search]")
|
||||
if filter == "" {
|
||||
filter = c.Query("filter[action]")
|
||||
}
|
||||
if filter == "" {
|
||||
filter = c.Query("filter[layer]")
|
||||
}
|
||||
methodFilter := c.Query("filter[method]")
|
||||
if methodFilter == "" {
|
||||
methodFilter = c.Query("method")
|
||||
}
|
||||
methods := parseAuditMethodFilter(methodFilter)
|
||||
moduleFilter := c.Query("filter[module]")
|
||||
if moduleFilter == "" {
|
||||
moduleFilter = c.Query("module")
|
||||
}
|
||||
modules := parseAuditTokenFilter(moduleFilter)
|
||||
actionFilter := c.Query("filter[action]")
|
||||
if actionFilter == "" {
|
||||
actionFilter = c.Query("action")
|
||||
}
|
||||
actions := parseAuditTokenFilter(actionFilter)
|
||||
|
||||
pageNumber := parseIntDefault(c.Query("page[number]"), 1)
|
||||
pageSize := parseIntDefault(c.Query("page[size]"), 20)
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
if pageNumber < 1 {
|
||||
pageNumber = 1
|
||||
}
|
||||
|
||||
offset := (pageNumber - 1) * pageSize
|
||||
_ = offset
|
||||
sort := c.Query("sort")
|
||||
if errObj := validateAuditListQuery(methods, modules, actions, sort); errObj != nil {
|
||||
return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
||||
}
|
||||
|
||||
logs, total, err := h.svc.List(c.UserContext(), filter, methods, modules, actions, sort, 0, 0)
|
||||
if err != nil {
|
||||
return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
|
||||
Status: "400",
|
||||
Title: "List failed",
|
||||
Detail: safeInternalDetail(),
|
||||
}})
|
||||
}
|
||||
|
||||
data := make([]dto.AuditLogResource, 0, len(logs))
|
||||
for i := range logs {
|
||||
data = append(data, auditLogResource(&logs[i]))
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"total": total,
|
||||
}
|
||||
return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
|
||||
}
|
||||
|
||||
func parseAuditMethodFilter(raw string) []string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
token := strings.TrimSpace(part)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func auditLogResource(v *audit.AuditLog) dto.AuditLogResource {
|
||||
id, _ := uuidv7.BytesToString(v.ID)
|
||||
actorID := ""
|
||||
if len(v.ActorUserID) == 16 {
|
||||
actorID, _ = uuidv7.BytesToString(v.ActorUserID)
|
||||
}
|
||||
|
||||
var metadata any
|
||||
if len(v.Metadata) > 0 {
|
||||
_ = json.Unmarshal(v.Metadata, &metadata)
|
||||
}
|
||||
module := strings.TrimSpace(v.Module)
|
||||
if module == "" {
|
||||
module = inferAuditModuleFromPath(strings.TrimSpace(v.Path))
|
||||
}
|
||||
|
||||
return dto.AuditLogResource{
|
||||
Type: "audit_log",
|
||||
ID: id,
|
||||
Attributes: dto.AuditLogAttributes{
|
||||
RequestID: strings.TrimSpace(v.RequestID),
|
||||
ActorUserID: actorID,
|
||||
Layer: strings.TrimSpace(v.Layer),
|
||||
Module: module,
|
||||
Action: strings.TrimSpace(v.Action),
|
||||
Method: strings.TrimSpace(v.Method),
|
||||
Path: strings.TrimSpace(v.Path),
|
||||
StatusCode: v.StatusCode,
|
||||
Success: v.Success,
|
||||
IP: strings.TrimSpace(v.IP),
|
||||
UserAgent: strings.TrimSpace(v.UserAgent),
|
||||
Message: strings.TrimSpace(v.Message),
|
||||
Metadata: metadata,
|
||||
CreatedAt: v.CreatedAt.UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func inferAuditModuleFromPath(path string) string {
|
||||
parts := strings.Split(strings.Trim(strings.ToLower(strings.TrimSpace(path)), "/"), "/")
|
||||
if len(parts) < 3 || parts[0] != "api" || parts[1] != "v1" {
|
||||
return sharedconst.AuditModuleUnknown
|
||||
}
|
||||
switch parts[2] {
|
||||
case "air-rescuer-checklist":
|
||||
return sharedconst.AuditModuleAirRescuerChecklist
|
||||
case "audit-logs":
|
||||
return sharedconst.AuditModuleAuditLog
|
||||
case "auth":
|
||||
return sharedconst.AuditModuleAuth
|
||||
case "bases":
|
||||
return sharedconst.AuditModuleBase
|
||||
case "branding", "public":
|
||||
return sharedconst.AuditModuleBranding
|
||||
case "contacts":
|
||||
return sharedconst.AuditModuleContact
|
||||
case "duty-roster":
|
||||
return sharedconst.AuditModuleDutyRoster
|
||||
case "dul":
|
||||
return sharedconst.AuditModuleDUL
|
||||
case "facilities":
|
||||
return sharedconst.AuditModuleFacility
|
||||
case "federal-state":
|
||||
return sharedconst.AuditModuleFederalState
|
||||
case "file-manager":
|
||||
return sharedconst.AuditModuleFileManager
|
||||
case "flight-data":
|
||||
return sharedconst.AuditModuleFlightData
|
||||
case "flights":
|
||||
return sharedconst.AuditModuleFlight
|
||||
case "forces-present":
|
||||
return sharedconst.AuditModuleForcesPresent
|
||||
case "health-insurance-companies":
|
||||
return sharedconst.AuditModuleHealthInsuranceCompany
|
||||
case "helicopter-files":
|
||||
return sharedconst.AuditModuleHelicopterFile
|
||||
case "helicopters":
|
||||
return sharedconst.AuditModuleHelicopter
|
||||
case "hems-operation", "hems-operation-category", "hems-operational-data":
|
||||
return sharedconst.AuditModuleUnknown
|
||||
case "hospital":
|
||||
return sharedconst.AuditModuleHospital
|
||||
case "icao":
|
||||
return sharedconst.AuditModuleICAO
|
||||
case "insurance-patient-data":
|
||||
return sharedconst.AuditModuleInsurancePatientData
|
||||
case "land":
|
||||
return sharedconst.AuditModuleLand
|
||||
case "master-settings":
|
||||
return sharedconst.AuditModuleMasterSetting
|
||||
case "medicine":
|
||||
return sharedconst.AuditModuleMedicine
|
||||
case "mission":
|
||||
return sharedconst.AuditModuleMission
|
||||
case "opc":
|
||||
return sharedconst.AuditModuleOPC
|
||||
case "patient-data":
|
||||
return sharedconst.AuditModulePatientData
|
||||
case "reserve-acs":
|
||||
return sharedconst.AuditModuleReserveAC
|
||||
case "roles":
|
||||
return sharedconst.AuditModuleRole
|
||||
case "users":
|
||||
return sharedconst.AuditModuleUser
|
||||
case "vocation":
|
||||
return sharedconst.AuditModuleVocation
|
||||
default:
|
||||
return sharedconst.AuditModuleUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func parseAuditTokenFilter(raw string) []string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
token := strings.TrimSpace(part)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateAuditListQuery(methods, modules, actions []string, sort string) *jsonapi.ErrorObject {
|
||||
for _, method := range methods {
|
||||
v := strings.ToUpper(strings.TrimSpace(method))
|
||||
if v == "" || v == "ALL" || v == "*" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains([]string{"POST", "PUT", "PATCH", "DELETE", "UPDATE"}, v) {
|
||||
return &jsonapi.ErrorObject{
|
||||
Status: "422",
|
||||
Title: "Validation error",
|
||||
Detail: fmt.Sprintf("invalid method filter: %s", method),
|
||||
Source: &jsonapi.ErrorSource{Pointer: "/query/method"},
|
||||
}
|
||||
}
|
||||
}
|
||||
validModules := []string{
|
||||
sharedconst.AuditModuleAirRescuerChecklist,
|
||||
sharedconst.AuditModuleAuditLog,
|
||||
sharedconst.AuditModuleAuth,
|
||||
sharedconst.AuditModuleBase,
|
||||
sharedconst.AuditModuleBaseRegular,
|
||||
sharedconst.AuditModuleBaseHEMS,
|
||||
sharedconst.AuditModuleBranding,
|
||||
sharedconst.AuditModuleContact,
|
||||
sharedconst.AuditModuleDutyRoster,
|
||||
sharedconst.AuditModuleDUL,
|
||||
sharedconst.AuditModuleFacility,
|
||||
sharedconst.AuditModuleFederalState,
|
||||
sharedconst.AuditModuleFileManager,
|
||||
sharedconst.AuditModuleFlightData,
|
||||
sharedconst.AuditModuleFlight,
|
||||
sharedconst.AuditModuleForcesPresent,
|
||||
sharedconst.AuditModuleHealthInsuranceCompany,
|
||||
sharedconst.AuditModuleHelicopterFile,
|
||||
sharedconst.AuditModuleHelicopter,
|
||||
sharedconst.AuditModuleHospital,
|
||||
sharedconst.AuditModuleICAO,
|
||||
sharedconst.AuditModuleInsurancePatientData,
|
||||
sharedconst.AuditModuleLand,
|
||||
sharedconst.AuditModuleMasterSetting,
|
||||
sharedconst.AuditModuleMedicine,
|
||||
sharedconst.AuditModuleMission,
|
||||
sharedconst.AuditModuleOPC,
|
||||
sharedconst.AuditModulePatientData,
|
||||
sharedconst.AuditModuleReserveAC,
|
||||
sharedconst.AuditModuleRole,
|
||||
sharedconst.AuditModuleUser,
|
||||
sharedconst.AuditModuleVocation,
|
||||
sharedconst.AuditModuleUnknown,
|
||||
}
|
||||
for _, module := range modules {
|
||||
v := strings.ToLower(strings.TrimSpace(module))
|
||||
if v == "" || v == "all" || v == "*" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(validModules, v) {
|
||||
return &jsonapi.ErrorObject{
|
||||
Status: "422",
|
||||
Title: "Validation error",
|
||||
Detail: fmt.Sprintf("invalid module filter: %s", module),
|
||||
Source: &jsonapi.ErrorSource{Pointer: "/query/module"},
|
||||
}
|
||||
}
|
||||
}
|
||||
validActions := []string{
|
||||
sharedconst.AuditActionList,
|
||||
sharedconst.AuditActionGet,
|
||||
sharedconst.AuditActionCreate,
|
||||
sharedconst.AuditActionUpdate,
|
||||
sharedconst.AuditActionDelete,
|
||||
sharedconst.AuditActionExport,
|
||||
sharedconst.AuditActionCustom,
|
||||
}
|
||||
for _, action := range actions {
|
||||
v := strings.ToLower(strings.TrimSpace(action))
|
||||
if v == "" || v == "all" || v == "*" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(validActions, v) {
|
||||
return &jsonapi.ErrorObject{
|
||||
Status: "422",
|
||||
Title: "Validation error",
|
||||
Detail: fmt.Sprintf("invalid action filter: %s", action),
|
||||
Source: &jsonapi.ErrorSource{Pointer: "/query/action"},
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(sort) != "" {
|
||||
allowed := map[string]struct{}{
|
||||
"created_at": {}, "-created_at": {},
|
||||
"module": {}, "-module": {},
|
||||
"action": {}, "-action": {},
|
||||
"layer": {}, "-layer": {},
|
||||
"status_code": {}, "-status_code": {},
|
||||
"success": {}, "-success": {},
|
||||
}
|
||||
if _, ok := allowed[strings.TrimSpace(sort)]; !ok {
|
||||
return &jsonapi.ErrorObject{
|
||||
Status: "422",
|
||||
Title: "Validation error",
|
||||
Detail: fmt.Sprintf("invalid sort: %s", sort),
|
||||
Source: &jsonapi.ErrorSource{Pointer: "/query/sort"},
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user