package store import ( "context" "encoding/json" "strings" "time" ) type AuditLog struct { ID string `json:"id"` Category string `json:"category"` Action string `json:"action"` ActorGatewayUserID string `json:"actorGatewayUserId,omitempty"` ActorUserID string `json:"actorUserId,omitempty"` ActorUsername string `json:"actorUsername,omitempty"` ActorSource string `json:"actorSource,omitempty"` ActorRoles []string `json:"actorRoles,omitempty"` TargetType string `json:"targetType"` TargetID string `json:"targetId"` TargetGatewayUserID string `json:"targetGatewayUserId,omitempty"` TargetGatewayTenantID string `json:"targetGatewayTenantId,omitempty"` RequestIP string `json:"requestIp,omitempty"` UserAgent string `json:"userAgent,omitempty"` BeforeState map[string]any `json:"beforeState,omitempty"` AfterState map[string]any `json:"afterState,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` } type AuditLogInput struct { Category string `json:"category"` Action string `json:"action"` ActorGatewayUserID string `json:"actorGatewayUserId"` ActorUserID string `json:"actorUserId"` ActorUsername string `json:"actorUsername"` ActorSource string `json:"actorSource"` ActorRoles []string `json:"actorRoles"` TargetType string `json:"targetType"` TargetID string `json:"targetId"` TargetGatewayUserID string `json:"targetGatewayUserId"` TargetGatewayTenantID string `json:"targetGatewayTenantId"` RequestIP string `json:"requestIp"` UserAgent string `json:"userAgent"` BeforeState map[string]any `json:"beforeState"` AfterState map[string]any `json:"afterState"` Metadata map[string]any `json:"metadata"` } type AuditLogFilter struct { Category string Action string TargetType string TargetID string Limit int } func (s *Store) RecordAuditLog(ctx context.Context, input AuditLogInput) (AuditLog, error) { return s.RecordAuditLogTx(ctx, s.pool, input) } func (s *Store) RecordAuditLogTx(ctx context.Context, tx Tx, input AuditLogInput) (AuditLog, error) { input = normalizeAuditLogInput(input) actorRolesJSON, _ := json.Marshal(input.ActorRoles) beforeJSON, _ := json.Marshal(emptyObjectIfNil(input.BeforeState)) afterJSON, _ := json.Marshal(emptyObjectIfNil(input.AfterState)) metadataJSON, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanAuditLog(tx.QueryRow(ctx, ` INSERT INTO gateway_audit_logs ( category, action, actor_gateway_user_id, actor_user_id, actor_username, actor_source, actor_roles, target_type, target_id, target_gateway_user_id, target_gateway_tenant_id, request_ip, user_agent, before_state, after_state, metadata ) VALUES ( $1, $2, NULLIF($3, '')::uuid, NULLIF($4, ''), NULLIF($5, ''), NULLIF($6, ''), $7::jsonb, $8, $9, NULLIF($10, '')::uuid, NULLIF($11, '')::uuid, NULLIF($12, ''), NULLIF($13, ''), $14::jsonb, $15::jsonb, $16::jsonb ) RETURNING id::text, category, action, COALESCE(actor_gateway_user_id::text, ''), COALESCE(actor_user_id, ''), COALESCE(actor_username, ''), COALESCE(actor_source, ''), actor_roles, target_type, target_id, COALESCE(target_gateway_user_id::text, ''), COALESCE(target_gateway_tenant_id::text, ''), COALESCE(request_ip, ''), COALESCE(user_agent, ''), before_state, after_state, metadata, created_at`, input.Category, input.Action, input.ActorGatewayUserID, input.ActorUserID, input.ActorUsername, input.ActorSource, string(actorRolesJSON), input.TargetType, input.TargetID, input.TargetGatewayUserID, input.TargetGatewayTenantID, input.RequestIP, input.UserAgent, string(beforeJSON), string(afterJSON), string(metadataJSON), )) } func (s *Store) ListAuditLogs(ctx context.Context, filter AuditLogFilter) ([]AuditLog, error) { limit := filter.Limit if limit <= 0 { limit = 100 } if limit > 500 { limit = 500 } rows, err := s.pool.Query(ctx, ` SELECT id::text, category, action, COALESCE(actor_gateway_user_id::text, ''), COALESCE(actor_user_id, ''), COALESCE(actor_username, ''), COALESCE(actor_source, ''), actor_roles, target_type, target_id, COALESCE(target_gateway_user_id::text, ''), COALESCE(target_gateway_tenant_id::text, ''), COALESCE(request_ip, ''), COALESCE(user_agent, ''), before_state, after_state, metadata, created_at FROM gateway_audit_logs WHERE (NULLIF($1, '') IS NULL OR category = $1) AND (NULLIF($2, '') IS NULL OR action = $2) AND (NULLIF($3, '') IS NULL OR target_type = $3) AND (NULLIF($4, '') IS NULL OR target_id = $4) ORDER BY created_at DESC LIMIT $5`, strings.TrimSpace(filter.Category), strings.TrimSpace(filter.Action), strings.TrimSpace(filter.TargetType), strings.TrimSpace(filter.TargetID), limit, ) if err != nil { return nil, err } defer rows.Close() items := make([]AuditLog, 0) for rows.Next() { item, err := scanAuditLog(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func normalizeAuditLogInput(input AuditLogInput) AuditLogInput { input.Category = firstNonEmpty(strings.TrimSpace(input.Category), "system") input.Action = strings.TrimSpace(input.Action) input.TargetType = strings.TrimSpace(input.TargetType) input.TargetID = strings.TrimSpace(input.TargetID) input.ActorSource = firstNonEmpty(strings.TrimSpace(input.ActorSource), "gateway") return input } func scanAuditLog(row scanner) (AuditLog, error) { var item AuditLog var actorRoles []byte var beforeState []byte var afterState []byte var metadata []byte if err := row.Scan( &item.ID, &item.Category, &item.Action, &item.ActorGatewayUserID, &item.ActorUserID, &item.ActorUsername, &item.ActorSource, &actorRoles, &item.TargetType, &item.TargetID, &item.TargetGatewayUserID, &item.TargetGatewayTenantID, &item.RequestIP, &item.UserAgent, &beforeState, &afterState, &metadata, &item.CreatedAt, ); err != nil { return AuditLog{}, err } item.ActorRoles = decodeStringArray(actorRoles) item.BeforeState = decodeObject(beforeState) item.AfterState = decodeObject(afterState) item.Metadata = decodeObject(metadata) return item, nil }