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,37 @@
|
||||
package audit
|
||||
|
||||
const ActionPasswordReset = "auth.password.reset"
|
||||
|
||||
const (
|
||||
ActorTypeAdmin = "admin"
|
||||
ActorTypeCustomer = "customer"
|
||||
ActorTypeSystem = "system"
|
||||
)
|
||||
|
||||
const (
|
||||
TargetTypeAdmin = "admin"
|
||||
TargetTypeCustomer = "customer"
|
||||
TargetTypeUser = "user"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionAuthLoginSuccess = "auth.login.success"
|
||||
ActionAuthLoginFailure = "auth.login.failure"
|
||||
ActionAuthLogout = "auth.logout"
|
||||
ActionAuthPasswordChange = "auth.password.change"
|
||||
ActionAuthPasswordResetRequest = "auth.password.reset.request"
|
||||
|
||||
ActionUserCreate = "user.create"
|
||||
ActionUserUpdate = "user.update"
|
||||
ActionUserDelete = "user.delete"
|
||||
ActionUserStatusChange = "user.status.change"
|
||||
ActionUserRead = "user.read"
|
||||
ActionUserList = "user.list"
|
||||
|
||||
ActionCustomerCreate = "customer.create"
|
||||
ActionCustomerUpdate = "customer.update"
|
||||
ActionCustomerDelete = "customer.delete"
|
||||
ActionCustomerStatusChange = "customer.status.change"
|
||||
ActionCustomerRead = "customer.read"
|
||||
ActionCustomerList = "customer.list"
|
||||
)
|
||||
+49
-9
@@ -7,21 +7,19 @@ import (
|
||||
"tm/pkg/logger"
|
||||
)
|
||||
|
||||
const ActionPasswordReset = "password.reset"
|
||||
|
||||
// TargetType values for audit entries.
|
||||
const (
|
||||
TargetTypeAdmin = "admin"
|
||||
TargetTypeCustomer = "customer"
|
||||
)
|
||||
|
||||
// Entry represents a security-relevant audit event.
|
||||
type Entry struct {
|
||||
ActorID string
|
||||
ActorType string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Action string
|
||||
At int64
|
||||
IP string
|
||||
RequestID string
|
||||
UserAgent string
|
||||
Success bool
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Logger records audit events.
|
||||
@@ -33,12 +31,13 @@ type auditLogger struct {
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewLogger creates an audit logger backed by structured application logs.
|
||||
// NewLogger creates an audit logger backed by structured application logs only.
|
||||
func NewLogger(log logger.Logger) Logger {
|
||||
return &auditLogger{logger: log}
|
||||
}
|
||||
|
||||
func (a *auditLogger) Log(ctx context.Context, entry Entry) {
|
||||
entry = enrichEntry(ctx, entry)
|
||||
if entry.At == 0 {
|
||||
entry.At = time.Now().Unix()
|
||||
}
|
||||
@@ -46,9 +45,50 @@ func (a *auditLogger) Log(ctx context.Context, entry Entry) {
|
||||
a.logger.Info("audit event", map[string]interface{}{
|
||||
"audit": true,
|
||||
"actor_id": entry.ActorID,
|
||||
"actor_type": entry.ActorType,
|
||||
"target_type": entry.TargetType,
|
||||
"target_id": entry.TargetID,
|
||||
"action": entry.Action,
|
||||
"at": entry.At,
|
||||
"ip": entry.IP,
|
||||
"request_id": entry.RequestID,
|
||||
"user_agent": entry.UserAgent,
|
||||
"success": entry.Success,
|
||||
"metadata": entry.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
type compositeLogger struct {
|
||||
fileLogger Logger
|
||||
store Store
|
||||
logger logger.Logger
|
||||
}
|
||||
|
||||
// NewCompositeLogger writes audit events to structured logs and an optional store.
|
||||
func NewCompositeLogger(log logger.Logger, store Store) Logger {
|
||||
if store == nil {
|
||||
store = NoopStore()
|
||||
}
|
||||
return &compositeLogger{
|
||||
fileLogger: NewLogger(log),
|
||||
store: store,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *compositeLogger) Log(ctx context.Context, entry Entry) {
|
||||
entry = enrichEntry(ctx, entry)
|
||||
if entry.At == 0 {
|
||||
entry.At = time.Now().Unix()
|
||||
}
|
||||
|
||||
c.fileLogger.Log(ctx, entry)
|
||||
|
||||
doc := entryToDocument(entry)
|
||||
if err := c.store.Save(ctx, doc); err != nil {
|
||||
c.logger.Error("Failed to persist audit event", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"action": entry.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
keyRequestMeta contextKey = iota
|
||||
keyActor
|
||||
)
|
||||
|
||||
type RequestMeta struct {
|
||||
IP string
|
||||
RequestID string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type Actor struct {
|
||||
ID string
|
||||
Type string
|
||||
}
|
||||
|
||||
func WithRequestMeta(ctx context.Context, meta RequestMeta) context.Context {
|
||||
return context.WithValue(ctx, keyRequestMeta, meta)
|
||||
}
|
||||
|
||||
func WithActor(ctx context.Context, actor Actor) context.Context {
|
||||
return context.WithValue(ctx, keyActor, actor)
|
||||
}
|
||||
|
||||
func RequestMetaFromContext(ctx context.Context) (RequestMeta, bool) {
|
||||
meta, ok := ctx.Value(keyRequestMeta).(RequestMeta)
|
||||
return meta, ok
|
||||
}
|
||||
|
||||
func ActorFromContext(ctx context.Context) (Actor, bool) {
|
||||
actor, ok := ctx.Value(keyActor).(Actor)
|
||||
return actor, ok
|
||||
}
|
||||
|
||||
func enrichEntry(ctx context.Context, entry Entry) Entry {
|
||||
if entry.At == 0 {
|
||||
entry.At = time.Now().Unix()
|
||||
}
|
||||
|
||||
if meta, ok := RequestMetaFromContext(ctx); ok {
|
||||
if entry.IP == "" {
|
||||
entry.IP = meta.IP
|
||||
}
|
||||
if entry.RequestID == "" {
|
||||
entry.RequestID = meta.RequestID
|
||||
}
|
||||
if entry.UserAgent == "" {
|
||||
entry.UserAgent = meta.UserAgent
|
||||
}
|
||||
}
|
||||
|
||||
if entry.ActorID == "" || entry.ActorType == "" {
|
||||
if actor, ok := ActorFromContext(ctx); ok {
|
||||
if entry.ActorID == "" {
|
||||
entry.ActorID = actor.ID
|
||||
}
|
||||
if entry.ActorType == "" {
|
||||
entry.ActorType = actor.Type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnrichEntryFromContext(t *testing.T) {
|
||||
ctx := WithRequestMeta(context.Background(), RequestMeta{
|
||||
IP: "127.0.0.1",
|
||||
RequestID: "req-1",
|
||||
UserAgent: "test-agent",
|
||||
})
|
||||
ctx = WithActor(ctx, Actor{ID: "actor-1", Type: ActorTypeAdmin})
|
||||
|
||||
entry := enrichEntry(ctx, Entry{
|
||||
Action: ActionAuthLoginSuccess,
|
||||
TargetType: TargetTypeAdmin,
|
||||
TargetID: "actor-1",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
if entry.ActorID != "actor-1" {
|
||||
t.Fatalf("expected actor_id actor-1, got %s", entry.ActorID)
|
||||
}
|
||||
if entry.IP != "127.0.0.1" {
|
||||
t.Fatalf("expected ip 127.0.0.1, got %s", entry.IP)
|
||||
}
|
||||
if entry.RequestID != "req-1" {
|
||||
t.Fatalf("expected request_id req-1, got %s", entry.RequestID)
|
||||
}
|
||||
if entry.At == 0 {
|
||||
t.Fatal("expected timestamp to be set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package audit
|
||||
|
||||
import "strconv"
|
||||
|
||||
// MergeMetadata combines multiple metadata maps into one.
|
||||
func MergeMetadata(parts ...map[string]string) map[string]string {
|
||||
merged := make(map[string]string)
|
||||
for _, part := range parts {
|
||||
for key, value := range part {
|
||||
if value != "" {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// FlagMetadata returns a single boolean flag metadata entry when condition is true.
|
||||
func FlagMetadata(key string, condition bool) map[string]string {
|
||||
if !condition {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{key: "true"}
|
||||
}
|
||||
|
||||
// StringMetadata returns metadata for a non-empty string value.
|
||||
func StringMetadata(key, value string) map[string]string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{key: value}
|
||||
}
|
||||
|
||||
// OptionalStringMetadata returns metadata when a pointer string is set and non-empty.
|
||||
func OptionalStringMetadata(key string, value *string) map[string]string {
|
||||
if value == nil || *value == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{key: *value}
|
||||
}
|
||||
|
||||
// PaginationMetadata returns safe pagination metadata for list audit events.
|
||||
func PaginationMetadata(limit, offset int, totalCount int64) map[string]string {
|
||||
return map[string]string{
|
||||
"limit": strconv.Itoa(limit),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"result_count": strconv.FormatInt(totalCount, 10),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeMetadata(t *testing.T) {
|
||||
merged := MergeMetadata(
|
||||
map[string]string{"status": "active"},
|
||||
map[string]string{"role": "admin"},
|
||||
nil,
|
||||
)
|
||||
|
||||
if merged["status"] != "active" || merged["role"] != "admin" {
|
||||
t.Fatalf("unexpected merged metadata: %#v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagMetadata(t *testing.T) {
|
||||
if FlagMetadata("has_search", false) != nil {
|
||||
t.Fatal("expected nil metadata for false flag")
|
||||
}
|
||||
|
||||
meta := FlagMetadata("has_search", true)
|
||||
if meta["has_search"] != "true" {
|
||||
t.Fatalf("unexpected flag metadata: %#v", meta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// RequestMetaMiddleware injects request metadata into the request context for audit logging.
|
||||
func RequestMetaMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
requestID := c.Response().Header().Get(echo.HeaderXRequestID)
|
||||
if requestID == "" {
|
||||
requestID = c.Request().Header.Get(echo.HeaderXRequestID)
|
||||
}
|
||||
|
||||
ctx := WithRequestMeta(c.Request().Context(), RequestMeta{
|
||||
IP: c.RealIP(),
|
||||
RequestID: requestID,
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
})
|
||||
c.SetRequest(c.Request().WithContext(ctx))
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EnrichEchoContext copies Echo actor values into the Go context when present.
|
||||
func EnrichEchoContext(c echo.Context, actorType string) {
|
||||
actorID := ""
|
||||
if actorType == ActorTypeAdmin {
|
||||
if id, ok := c.Get("user_id").(string); ok {
|
||||
actorID = id
|
||||
}
|
||||
}
|
||||
if actorType == ActorTypeCustomer {
|
||||
if id, ok := c.Get("customer_id").(string); ok {
|
||||
actorID = id
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
if actorID != "" {
|
||||
ctx = WithActor(ctx, Actor{ID: actorID, Type: actorType})
|
||||
}
|
||||
c.SetRequest(c.Request().WithContext(ctx))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package audit
|
||||
|
||||
import "context"
|
||||
|
||||
// Document is the persisted audit log shape stored in Elasticsearch.
|
||||
type Document 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"`
|
||||
}
|
||||
|
||||
// SearchFilter defines query filters for audit log search.
|
||||
type SearchFilter struct {
|
||||
ActorID string
|
||||
ActorType string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
From int64
|
||||
To int64
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// SearchResult holds paginated audit log search results.
|
||||
type SearchResult struct {
|
||||
Items []Document
|
||||
TotalCount int64
|
||||
}
|
||||
|
||||
// Store persists and queries audit documents.
|
||||
type Store interface {
|
||||
Save(ctx context.Context, doc Document) error
|
||||
Search(ctx context.Context, filter SearchFilter) (*SearchResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// noopStore discards audit documents when Elasticsearch is disabled.
|
||||
type noopStore struct{}
|
||||
|
||||
func (noopStore) Save(context.Context, Document) error { return nil }
|
||||
|
||||
func (noopStore) Search(context.Context, SearchFilter) (*SearchResult, error) {
|
||||
return &SearchResult{Items: []Document{}}, nil
|
||||
}
|
||||
|
||||
func (noopStore) Close() error { return nil }
|
||||
|
||||
// NoopStore returns a store that performs no persistence.
|
||||
func NoopStore() Store {
|
||||
return noopStore{}
|
||||
}
|
||||
|
||||
func entryToDocument(entry Entry) Document {
|
||||
return Document{
|
||||
ActorID: entry.ActorID,
|
||||
ActorType: entry.ActorType,
|
||||
Action: entry.Action,
|
||||
TargetType: entry.TargetType,
|
||||
TargetID: entry.TargetID,
|
||||
At: entry.At,
|
||||
IP: entry.IP,
|
||||
RequestID: entry.RequestID,
|
||||
UserAgent: entry.UserAgent,
|
||||
Success: entry.Success,
|
||||
Metadata: entry.Metadata,
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,17 @@ type NotificationConfig struct {
|
||||
UserAgent string `env:"NOTIFICATION_USER_AGENT"`
|
||||
}
|
||||
|
||||
type ElasticsearchConfig struct {
|
||||
URL string `env:"ELASTICSEARCH_URL"`
|
||||
Username string `env:"ELASTICSEARCH_USERNAME"`
|
||||
Password string `env:"ELASTICSEARCH_PASSWORD"`
|
||||
IndexPrefix string `env:"ELASTICSEARCH_INDEX_PREFIX"`
|
||||
Enabled bool `env:"ELASTICSEARCH_ENABLED"`
|
||||
RequestTimeout time.Duration `env:"ELASTICSEARCH_REQUEST_TIMEOUT"`
|
||||
BulkFlushPeriod time.Duration `env:"ELASTICSEARCH_BULK_FLUSH_PERIOD"`
|
||||
QueueSize int `env:"ELASTICSEARCH_QUEUE_SIZE"`
|
||||
}
|
||||
|
||||
// OllamaConfig represents the configuration for the Ollama SDK
|
||||
type OllamaConfig struct {
|
||||
BaseURL string `env:"OLLAMA_BASE_URL" default:"http://localhost:11434"`
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package elasticsearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"tm/pkg/audit"
|
||||
)
|
||||
|
||||
// AuditStore persists audit documents to Elasticsearch.
|
||||
type AuditStore struct {
|
||||
client Client
|
||||
}
|
||||
|
||||
// NewAuditStore creates an audit store backed by Elasticsearch.
|
||||
func NewAuditStore(client Client) audit.Store {
|
||||
if client == nil {
|
||||
return audit.NoopStore()
|
||||
}
|
||||
return &AuditStore{client: client}
|
||||
}
|
||||
|
||||
func (s *AuditStore) Save(ctx context.Context, doc audit.Document) error {
|
||||
return s.client.Index(ctx, AuditDocument(doc))
|
||||
}
|
||||
|
||||
func (s *AuditStore) Search(ctx context.Context, filter audit.SearchFilter) (*audit.SearchResult, error) {
|
||||
result, err := s.client.Search(ctx, SearchFilter(filter))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]audit.Document, 0, len(result.Items))
|
||||
for _, item := range result.Items {
|
||||
items = append(items, audit.Document(item))
|
||||
}
|
||||
|
||||
return &audit.SearchResult{
|
||||
Items: items,
|
||||
TotalCount: result.TotalCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuditStore) Close() error {
|
||||
if s.client == nil {
|
||||
return nil
|
||||
}
|
||||
return s.client.Close()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package elasticsearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/elastic/go-elasticsearch/v8"
|
||||
"tm/pkg/logger"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
es *elasticsearch.Client
|
||||
config Config
|
||||
logger logger.Logger
|
||||
queue chan AuditDocument
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewClient creates an Elasticsearch client for audit log indexing and search.
|
||||
func NewClient(config Config, log logger.Logger) (Client, error) {
|
||||
if !config.Enabled || strings.TrimSpace(config.URL) == "" {
|
||||
return NewNoopClient(), nil
|
||||
}
|
||||
|
||||
if config.RequestTimeout == 0 {
|
||||
config.RequestTimeout = 10 * time.Second
|
||||
}
|
||||
if config.BulkFlushPeriod == 0 {
|
||||
config.BulkFlushPeriod = 5 * time.Second
|
||||
}
|
||||
if config.QueueSize == 0 {
|
||||
config.QueueSize = 1000
|
||||
}
|
||||
if config.IndexPrefix == "" {
|
||||
config.IndexPrefix = "tm-audit-logs"
|
||||
}
|
||||
|
||||
esCfg := elasticsearch.Config{
|
||||
Addresses: []string{config.URL},
|
||||
Username: config.Username,
|
||||
Password: config.Password,
|
||||
}
|
||||
|
||||
es, err := elasticsearch.NewClient(esCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create elasticsearch client: %w", err)
|
||||
}
|
||||
|
||||
c := &client{
|
||||
es: es,
|
||||
config: config,
|
||||
logger: log,
|
||||
queue: make(chan AuditDocument, config.QueueSize),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
|
||||
defer cancel()
|
||||
if err := c.ping(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.wg.Add(1)
|
||||
go c.runBulkWorker()
|
||||
|
||||
log.Info("Successfully connected to Elasticsearch", map[string]interface{}{
|
||||
"url": config.URL,
|
||||
"index_prefix": config.IndexPrefix,
|
||||
})
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *client) ping(ctx context.Context) error {
|
||||
res, err := c.es.Ping(c.es.Ping.WithContext(ctx))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ping elasticsearch: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.IsError() {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return fmt.Errorf("elasticsearch ping failed: %s", string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) Index(_ context.Context, doc AuditDocument) error {
|
||||
select {
|
||||
case c.queue <- doc:
|
||||
return nil
|
||||
default:
|
||||
c.logger.Warn("Audit log queue full, dropping entry", map[string]interface{}{
|
||||
"action": doc.Action,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) Search(ctx context.Context, filter SearchFilter) (*SearchResult, error) {
|
||||
indexPattern := c.config.IndexPrefix + "-*"
|
||||
query := buildSearchQuery(filter)
|
||||
|
||||
payload, err := json.Marshal(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal search query: %w", err)
|
||||
}
|
||||
|
||||
res, err := c.es.Search(
|
||||
c.es.Search.WithContext(ctx),
|
||||
c.es.Search.WithIndex(indexPattern),
|
||||
c.es.Search.WithBody(bytes.NewReader(payload)),
|
||||
c.es.Search.WithTrackTotalHits(true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search audit logs: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.IsError() {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return nil, fmt.Errorf("elasticsearch search error: %s", string(body))
|
||||
}
|
||||
|
||||
return decodeSearchResponse(res.Body)
|
||||
}
|
||||
|
||||
func (c *client) Close() error {
|
||||
close(c.done)
|
||||
c.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) runBulkWorker() {
|
||||
defer c.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(c.config.BulkFlushPeriod)
|
||||
defer ticker.Stop()
|
||||
|
||||
batch := make([]AuditDocument, 0, 64)
|
||||
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
if err := c.flushBatch(batch); err != nil {
|
||||
c.logger.Error("Failed to flush audit logs to Elasticsearch", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"count": len(batch),
|
||||
})
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
for {
|
||||
select {
|
||||
case doc := <-c.queue:
|
||||
batch = append(batch, doc)
|
||||
default:
|
||||
flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
case doc := <-c.queue:
|
||||
batch = append(batch, doc)
|
||||
if len(batch) >= 64 {
|
||||
flush()
|
||||
}
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) flushBatch(batch []AuditDocument) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
for _, doc := range batch {
|
||||
indexName := indexNameFor(c.config.IndexPrefix, doc.At)
|
||||
meta := fmt.Sprintf(`{"index":{"_index":"%s"}}`, indexName)
|
||||
buf.WriteString(meta)
|
||||
buf.WriteByte('\n')
|
||||
|
||||
body, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit document: %w", err)
|
||||
}
|
||||
buf.Write(body)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.config.RequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := c.es.Bulk(bytes.NewReader(buf.Bytes()), c.es.Bulk.WithContext(ctx))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bulk index audit logs: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.IsError() {
|
||||
responseBody, _ := io.ReadAll(res.Body)
|
||||
return fmt.Errorf("elasticsearch bulk error: %s", string(responseBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func indexNameFor(prefix string, at int64) string {
|
||||
t := time.Unix(at, 0).UTC()
|
||||
return fmt.Sprintf("%s-%04d.%02d", prefix, t.Year(), int(t.Month()))
|
||||
}
|
||||
|
||||
func buildSearchQuery(filter SearchFilter) map[string]interface{} {
|
||||
must := make([]map[string]interface{}, 0)
|
||||
filterClauses := make([]map[string]interface{}, 0)
|
||||
|
||||
addTerm := func(field, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
filterClauses = append(filterClauses, map[string]interface{}{
|
||||
"term": map[string]interface{}{field: value},
|
||||
})
|
||||
}
|
||||
|
||||
addTerm("actor_id", filter.ActorID)
|
||||
addTerm("actor_type", filter.ActorType)
|
||||
addTerm("action", filter.Action)
|
||||
addTerm("target_type", filter.TargetType)
|
||||
addTerm("target_id", filter.TargetID)
|
||||
|
||||
if filter.From > 0 || filter.To > 0 {
|
||||
rangeQuery := map[string]interface{}{}
|
||||
if filter.From > 0 {
|
||||
rangeQuery["gte"] = filter.From
|
||||
}
|
||||
if filter.To > 0 {
|
||||
rangeQuery["lte"] = filter.To
|
||||
}
|
||||
filterClauses = append(filterClauses, map[string]interface{}{
|
||||
"range": map[string]interface{}{"at": rangeQuery},
|
||||
})
|
||||
}
|
||||
|
||||
boolQuery := map[string]interface{}{}
|
||||
if len(must) > 0 {
|
||||
boolQuery["must"] = must
|
||||
}
|
||||
if len(filterClauses) > 0 {
|
||||
boolQuery["filter"] = filterClauses
|
||||
}
|
||||
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"query": map[string]interface{}{
|
||||
"bool": boolQuery,
|
||||
},
|
||||
"sort": []map[string]interface{}{
|
||||
{"at": map[string]interface{}{"order": "desc"}},
|
||||
},
|
||||
"from": offset,
|
||||
"size": limit,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeSearchResponse(body io.Reader) (*SearchResult, error) {
|
||||
var parsed struct {
|
||||
Hits struct {
|
||||
Total struct {
|
||||
Value int64 `json:"value"`
|
||||
} `json:"total"`
|
||||
Hits []struct {
|
||||
Source AuditDocument `json:"_source"`
|
||||
} `json:"hits"`
|
||||
} `json:"hits"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(body).Decode(&parsed); err != nil {
|
||||
return nil, fmt.Errorf("decode search response: %w", err)
|
||||
}
|
||||
|
||||
items := make([]AuditDocument, 0, len(parsed.Hits.Hits))
|
||||
for _, hit := range parsed.Hits.Hits {
|
||||
items = append(items, hit.Source)
|
||||
}
|
||||
|
||||
return &SearchResult{
|
||||
Items: items,
|
||||
TotalCount: parsed.Hits.Total.Value,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package elasticsearch
|
||||
|
||||
import "context"
|
||||
|
||||
type noopClient struct{}
|
||||
|
||||
// NewNoopClient returns a client that performs no Elasticsearch operations.
|
||||
func NewNoopClient() Client {
|
||||
return noopClient{}
|
||||
}
|
||||
|
||||
func (noopClient) Index(context.Context, AuditDocument) error { return nil }
|
||||
|
||||
func (noopClient) Search(context.Context, SearchFilter) (*SearchResult, error) {
|
||||
return &SearchResult{Items: []AuditDocument{}}, nil
|
||||
}
|
||||
|
||||
func (noopClient) Close() error { return nil }
|
||||
@@ -0,0 +1,60 @@
|
||||
package elasticsearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds Elasticsearch connection settings.
|
||||
type Config struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
IndexPrefix string
|
||||
Enabled bool
|
||||
RequestTimeout time.Duration
|
||||
BulkFlushBytes int
|
||||
BulkFlushPeriod time.Duration
|
||||
QueueSize int
|
||||
}
|
||||
|
||||
// AuditDocument is the indexed audit log document.
|
||||
type AuditDocument 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"`
|
||||
}
|
||||
|
||||
// SearchFilter defines audit log search parameters.
|
||||
type SearchFilter struct {
|
||||
ActorID string
|
||||
ActorType string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
From int64
|
||||
To int64
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// SearchResult holds paginated search output.
|
||||
type SearchResult struct {
|
||||
Items []AuditDocument
|
||||
TotalCount int64
|
||||
}
|
||||
|
||||
// Client indexes and searches audit documents.
|
||||
type Client interface {
|
||||
Index(ctx context.Context, doc AuditDocument) error
|
||||
Search(ctx context.Context, filter SearchFilter) (*SearchResult, error)
|
||||
Close() error
|
||||
}
|
||||
Reference in New Issue
Block a user