Implement audit logging functionality across services and middleware
- Introduced a new `auditlog` package to handle audit logging for user actions, including creation, updates, deletions, and authentication events. - Enhanced existing services (customer, user) to log relevant actions using the new audit logger, capturing details such as actor ID, action type, target type, and success status. - Added middleware to enrich request context with metadata for audit logging, ensuring comprehensive tracking of user interactions. - Integrated Elasticsearch for persistent storage of audit logs, with fallback to file-only logging if Elasticsearch is unavailable. - Updated API documentation to include new audit log endpoints for administrative access. This update significantly improves the system's ability to track and audit user actions, enhancing security and accountability within the application.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package auditlog
|
||||
|
||||
// Entry represents an audit log record returned by the API.
|
||||
type Entry struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
At int64 `json:"at"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ListResponse is the paginated audit log list response.
|
||||
type ListResponse struct {
|
||||
Logs []*Entry `json:"logs"`
|
||||
Meta *ListMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ListMeta contains pagination metadata.
|
||||
type ListMeta struct {
|
||||
Total int64 `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Page int `json:"page"`
|
||||
Pages int `json:"pages"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package auditlog
|
||||
|
||||
// SearchForm defines query parameters for audit log search.
|
||||
type SearchForm struct {
|
||||
ActorID string `query:"actor_id" valid:"optional"`
|
||||
ActorType string `query:"actor_type" valid:"optional,in(admin|customer|system)"`
|
||||
Action string `query:"action" valid:"optional"`
|
||||
TargetType string `query:"target_type" valid:"optional"`
|
||||
TargetID string `query:"target_id" valid:"optional"`
|
||||
From int64 `query:"from" valid:"optional"`
|
||||
To int64 `query:"to" valid:"optional"`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"tm/pkg/response"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for audit log operations.
|
||||
type Handler struct {
|
||||
service Service
|
||||
}
|
||||
|
||||
// NewHandler creates an audit log handler.
|
||||
func NewHandler(service Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// Search returns paginated audit logs with optional filters.
|
||||
// @Summary Search audit logs
|
||||
// @Description Retrieve paginated user action audit logs with optional filtering by actor, action, target, and time range
|
||||
// @Tags Admin-AuditLogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param actor_id query string false "Filter by actor ID"
|
||||
// @Param actor_type query string false "Filter by actor type" Enums(admin,customer,system)
|
||||
// @Param action query string false "Filter by action (e.g. auth.login.success)"
|
||||
// @Param target_type query string false "Filter by target type"
|
||||
// @Param target_id query string false "Filter by target ID"
|
||||
// @Param from query int64 false "Filter by timestamp from (Unix seconds)"
|
||||
// @Param to query int64 false "Filter by timestamp to (Unix seconds)"
|
||||
// @Param limit query int false "Number of items per page (default: 10, max: 100)"
|
||||
// @Param offset query int false "Number of items to skip (default: 0)"
|
||||
// @Success 200 {object} response.APIResponse{data=ListResponse} "Audit logs retrieved successfully"
|
||||
// @Failure 400 {object} response.APIResponse "Bad request"
|
||||
// @Failure 401 {object} response.APIResponse "Unauthorized"
|
||||
// @Failure 403 {object} response.APIResponse "Forbidden"
|
||||
// @Failure 500 {object} response.APIResponse "Internal server error"
|
||||
// @Router /admin/v1/audit-logs [get]
|
||||
func (h *Handler) Search(c echo.Context) error {
|
||||
form, err := response.Parse[SearchForm](c)
|
||||
if err != nil {
|
||||
return response.ValidationError(c, "Invalid request data", err.Error())
|
||||
}
|
||||
|
||||
if _, err := govalidator.ValidateStruct(form); err != nil {
|
||||
return response.ValidationError(c, "Validation failed", err.Error())
|
||||
}
|
||||
|
||||
pagination, err := response.NewPagination(c)
|
||||
if err != nil {
|
||||
return response.PaginationBadRequest(c, err)
|
||||
}
|
||||
|
||||
result, err := h.service.Search(c.Request().Context(), *form, pagination)
|
||||
if err != nil {
|
||||
return response.InternalServerError(c, "Failed to retrieve audit logs")
|
||||
}
|
||||
|
||||
return response.SuccessWithMeta(c, result, &response.Meta{
|
||||
Total: int(result.Meta.Total),
|
||||
Limit: result.Meta.Limit,
|
||||
Offset: result.Meta.Offset,
|
||||
Page: result.Meta.Page,
|
||||
Pages: result.Meta.Pages,
|
||||
}, "audit logs retrieved successfully")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"tm/pkg/audit"
|
||||
)
|
||||
|
||||
// Repository defines audit log data access.
|
||||
type Repository interface {
|
||||
Search(ctx context.Context, filter audit.SearchFilter) (*audit.SearchResult, error)
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
store audit.Store
|
||||
}
|
||||
|
||||
// NewRepository creates an audit log repository.
|
||||
func NewRepository(store audit.Store) Repository {
|
||||
return &repository{store: store}
|
||||
}
|
||||
|
||||
func (r *repository) Search(ctx context.Context, filter audit.SearchFilter) (*audit.SearchResult, error) {
|
||||
return r.store.Search(ctx, filter)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/logger"
|
||||
"tm/pkg/response"
|
||||
)
|
||||
|
||||
// Service defines audit log query operations.
|
||||
type Service interface {
|
||||
Search(ctx context.Context, form SearchForm, pagination *response.Pagination) (*ListResponse, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repository Repository
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewService creates an audit log service.
|
||||
func NewService(repository Repository, log logger.Logger) Service {
|
||||
return &service{
|
||||
repository: repository,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Search(ctx context.Context, form SearchForm, pagination *response.Pagination) (*ListResponse, error) {
|
||||
filter := audit.SearchFilter{
|
||||
ActorID: form.ActorID,
|
||||
ActorType: form.ActorType,
|
||||
Action: form.Action,
|
||||
TargetType: form.TargetType,
|
||||
TargetID: form.TargetID,
|
||||
From: form.From,
|
||||
To: form.To,
|
||||
Limit: pagination.Limit,
|
||||
Offset: pagination.Offset,
|
||||
}
|
||||
|
||||
result, err := s.repository.Search(ctx, filter)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to search audit logs", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := make([]*Entry, 0, len(result.Items))
|
||||
for _, item := range result.Items {
|
||||
entries = append(entries, &Entry{
|
||||
ActorID: item.ActorID,
|
||||
ActorType: item.ActorType,
|
||||
Action: item.Action,
|
||||
TargetType: item.TargetType,
|
||||
TargetID: item.TargetID,
|
||||
At: item.At,
|
||||
IP: item.IP,
|
||||
RequestID: item.RequestID,
|
||||
UserAgent: item.UserAgent,
|
||||
Success: item.Success,
|
||||
Metadata: item.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
total := result.TotalCount
|
||||
pages := 0
|
||||
if pagination.Limit > 0 {
|
||||
pages = int(math.Ceil(float64(total) / float64(pagination.Limit)))
|
||||
}
|
||||
page := 1
|
||||
if pagination.Limit > 0 {
|
||||
page = (pagination.Offset / pagination.Limit) + 1
|
||||
}
|
||||
|
||||
return &ListResponse{
|
||||
Logs: entries,
|
||||
Meta: &ListMeta{
|
||||
Total: total,
|
||||
Limit: pagination.Limit,
|
||||
Offset: pagination.Offset,
|
||||
Page: page,
|
||||
Pages: pages,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -88,9 +88,11 @@ func (s *customerService) AdminResetPassword(ctx context.Context, actorID, targe
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: actorID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: targetID,
|
||||
Action: audit.ActionPasswordReset,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
s.logger.Info("Customer password reset completed", map[string]interface{}{
|
||||
|
||||
@@ -3,6 +3,7 @@ package customer
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/response"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -52,6 +53,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc {
|
||||
c.Set("company_id", validationResult.Claims.CompanyID)
|
||||
c.Set("access_token", tokenString)
|
||||
|
||||
audit.EnrichEchoContext(c, audit.ActorTypeCustomer)
|
||||
|
||||
// Continue to next handler
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,14 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
|
||||
"company_ids": Companies,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerCreate,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(customer.Role)},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: customer.Email,
|
||||
Title: "Welcome to Opplens",
|
||||
@@ -198,6 +206,13 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp
|
||||
customer, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err.Error() == "customer not found" {
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerRead,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "not_found"},
|
||||
})
|
||||
return nil, errors.New("customer not found")
|
||||
}
|
||||
s.logger.Error("Failed to get customer by ID", map[string]interface{}{
|
||||
@@ -223,6 +238,13 @@ func (s *customerService) GetByID(ctx context.Context, id string) (*CustomerResp
|
||||
"email": customer.Email,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerRead,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return customer.ToResponse(companies), nil
|
||||
}
|
||||
|
||||
@@ -249,12 +271,37 @@ func (s *customerService) Search(ctx context.Context, form *SearchCustomersForm,
|
||||
customerResponses = append(customerResponses, customer.ToResponse(companies))
|
||||
}
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerList,
|
||||
Success: true,
|
||||
Metadata: audit.MergeMetadata(
|
||||
customerSearchFilterMetadata(form),
|
||||
audit.PaginationMetadata(pagination.Limit, pagination.Offset, page.TotalCount),
|
||||
),
|
||||
})
|
||||
|
||||
return &CustomerListResponse{
|
||||
Customers: customerResponses,
|
||||
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func customerSearchFilterMetadata(form *SearchCustomersForm) map[string]string {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return audit.MergeMetadata(
|
||||
audit.FlagMetadata("has_search", form.Search != nil && *form.Search != ""),
|
||||
audit.FlagMetadata("has_full_name", form.FullName != nil && *form.FullName != ""),
|
||||
audit.FlagMetadata("has_email_filter", form.Email != nil && *form.Email != ""),
|
||||
audit.OptionalStringMetadata("status", form.Status),
|
||||
audit.OptionalStringMetadata("role", form.Role),
|
||||
audit.OptionalStringMetadata("type", form.Type),
|
||||
audit.OptionalStringMetadata("company_id", form.CompanyID),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *customerService) SearchNotification(ctx context.Context, form *SearchCustomersForm, pagination *response.Pagination) ([]*CustomerNotificationResponse, error) {
|
||||
page, err := s.repository.Search(ctx, form, pagination)
|
||||
if err != nil {
|
||||
@@ -452,6 +499,13 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
|
||||
"email": customer.Email,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerUpdate,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
responseCompanies, err := s.loadCompaniesForCustomer(ctx, customer)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to load companies for customer response", map[string]interface{}{
|
||||
@@ -490,6 +544,13 @@ func (s *customerService) Delete(ctx context.Context, id string) error {
|
||||
"customer_id": id,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerDelete,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -518,6 +579,14 @@ func (s *customerService) UpdateStatus(ctx context.Context, id string, form *Upd
|
||||
"status": form.Status,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionCustomerStatusChange,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"status": form.Status},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: customer.Email,
|
||||
Title: "Account Status Updated",
|
||||
@@ -682,6 +751,12 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
s.logger.Warn("Customer login failed - username not found", map[string]interface{}{
|
||||
"username": strings.ToLower(form.Username),
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -692,6 +767,15 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
"customer_id": customer.ID,
|
||||
"status": string(customer.Status),
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customer.ID.Hex(),
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "inactive"},
|
||||
})
|
||||
return nil, errors.New("account is not active")
|
||||
}
|
||||
|
||||
@@ -702,6 +786,14 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
"customer_id": customer.ID,
|
||||
"email": customer.Email,
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customer.ID.Hex(),
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -747,6 +839,16 @@ func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResp
|
||||
"customer_type": string(customer.Type),
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customer.ID.Hex(),
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customer.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginSuccess,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(customer.Role)},
|
||||
})
|
||||
|
||||
return &AuthResponse{
|
||||
Customer: customer.ToResponse(companies),
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
@@ -942,6 +1044,15 @@ func (s *customerService) Logout(ctx context.Context, customerID string, accessT
|
||||
"customer_id": customerID,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: customerID,
|
||||
ActorType: audit.ActorTypeCustomer,
|
||||
TargetType: audit.TargetTypeCustomer,
|
||||
TargetID: customerID,
|
||||
Action: audit.ActionAuthLogout,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package user
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"tm/pkg/audit"
|
||||
"tm/pkg/response"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -59,6 +60,8 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc {
|
||||
c.Set("company_id", validationResult.Claims.CompanyID)
|
||||
c.Set("access_token", tokenString)
|
||||
|
||||
audit.EnrichEchoContext(c, audit.ActorTypeAdmin)
|
||||
|
||||
// Continue to next handler
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -93,9 +93,11 @@ func (s *userService) AdminResetPassword(ctx context.Context, actorID, actorRole
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: actorID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: targetID,
|
||||
Action: audit.ActionPasswordReset,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
s.logger.Info("Admin password reset completed", map[string]interface{}{
|
||||
|
||||
@@ -135,6 +135,14 @@ func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*User
|
||||
"role": user.Role,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserCreate,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: user.ID.Hex(),
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(user.Role)},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: form.Email,
|
||||
Title: "Welcome to Opplens",
|
||||
@@ -225,6 +233,13 @@ func (s *userService) Update(ctx context.Context, id string, form *UpdateUserFor
|
||||
"user_id": id,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserUpdate,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return user.ToResponse(), nil
|
||||
}
|
||||
|
||||
@@ -255,6 +270,14 @@ func (s *userService) UpdateStatus(ctx context.Context, id string, form *UpdateU
|
||||
"status": form.Status,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserStatusChange,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"status": form.Status},
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: user.Email,
|
||||
Title: "Account Status Updated",
|
||||
@@ -272,6 +295,13 @@ func (s *userService) GetUserByID(ctx context.Context, id string) (*UserResponse
|
||||
user, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserRead,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "not_found"},
|
||||
})
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
s.logger.Error("Failed to get user by ID", map[string]interface{}{
|
||||
@@ -281,6 +311,13 @@ func (s *userService) GetUserByID(ctx context.Context, id string) (*UserResponse
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserRead,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return user.ToResponse(), nil
|
||||
}
|
||||
|
||||
@@ -317,12 +354,33 @@ func (s *userService) Search(ctx context.Context, form *SearchUsersForm, paginat
|
||||
userResponses = append(userResponses, user.ToResponse())
|
||||
}
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserList,
|
||||
Success: true,
|
||||
Metadata: audit.MergeMetadata(
|
||||
userSearchFilterMetadata(form),
|
||||
audit.PaginationMetadata(pagination.Limit, pagination.Offset, page.TotalCount),
|
||||
),
|
||||
})
|
||||
|
||||
return &UserListResponse{
|
||||
Users: userResponses,
|
||||
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userSearchFilterMetadata(form *SearchUsersForm) map[string]string {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return audit.MergeMetadata(
|
||||
audit.FlagMetadata("has_search", form.Search != nil && *form.Search != ""),
|
||||
audit.OptionalStringMetadata("status", form.Status),
|
||||
audit.OptionalStringMetadata("role", form.Role),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete deletes a user (hard delete)
|
||||
func (s *userService) Delete(ctx context.Context, id string) error {
|
||||
err := s.repository.Delete(ctx, id)
|
||||
@@ -338,6 +396,13 @@ func (s *userService) Delete(ctx context.Context, id string) error {
|
||||
"user_id": id,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionUserDelete,
|
||||
TargetType: audit.TargetTypeUser,
|
||||
TargetID: id,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -358,11 +423,26 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
||||
"email_or_username": form.Username,
|
||||
"error": err.Error(),
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if user.Status != UserStatusActive {
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
Metadata: map[string]string{"reason": "inactive"},
|
||||
})
|
||||
return nil, errors.New("account is inactive")
|
||||
}
|
||||
|
||||
@@ -373,6 +453,14 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
})
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginFailure,
|
||||
Success: false,
|
||||
})
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -410,6 +498,16 @@ func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse
|
||||
"role": user.Role,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthLoginSuccess,
|
||||
Success: true,
|
||||
Metadata: map[string]string{"role": string(user.Role)},
|
||||
})
|
||||
|
||||
return &AuthResponse{
|
||||
User: user.ToResponse(),
|
||||
AccessToken: tokenPair.AccessToken,
|
||||
@@ -520,6 +618,15 @@ func (s *userService) ChangePassword(ctx context.Context, userID string, form *C
|
||||
"user_id": userID,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: userID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: userID,
|
||||
Action: audit.ActionAuthPasswordChange,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
go s.notification.SendEmail(context.Background(), notification.Model{
|
||||
Recipient: user.Email,
|
||||
Title: "Password Reset Success",
|
||||
@@ -550,6 +657,15 @@ func (s *userService) ResetPassword(ctx context.Context, form *ResetPasswordForm
|
||||
"email": user.Email,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: user.ID.Hex(),
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: user.ID.Hex(),
|
||||
Action: audit.ActionAuthPasswordResetRequest,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -699,5 +815,14 @@ func (s *userService) Logout(ctx context.Context, userID string, accessToken str
|
||||
"user_id": userID,
|
||||
})
|
||||
|
||||
s.auditLogger.Log(ctx, audit.Entry{
|
||||
ActorID: userID,
|
||||
ActorType: audit.ActorTypeAdmin,
|
||||
TargetType: audit.TargetTypeAdmin,
|
||||
TargetID: userID,
|
||||
Action: audit.ActionAuthLogout,
|
||||
Success: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user