easyai-ai-gateway/apps/api/internal/store/audit_logs.go

188 lines
6.6 KiB
Go

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
}