c091ad7aed
Stand up an internal ops-api service with operator account, auth, and audit log subsystems backed by a dedicated migrations_ops migration set. Wire Makefile targets and the migrate Dockerfile stage so the new schema ships alongside the existing tenant + monitoring databases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
128 lines
3.2 KiB
Go
128 lines
3.2 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
|
)
|
|
|
|
type AuditRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewAuditRepository(pool *pgxpool.Pool) *AuditRepository {
|
|
return &AuditRepository{pool: pool}
|
|
}
|
|
|
|
func (r *AuditRepository) Append(ctx context.Context, e domain.AuditEvent) error {
|
|
var metaBytes []byte
|
|
if e.Metadata != nil {
|
|
b, err := json.Marshal(e.Metadata)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal audit metadata: %w", err)
|
|
}
|
|
metaBytes = b
|
|
}
|
|
|
|
var (
|
|
operatorName = nullableString(e.OperatorName)
|
|
targetType = nullableString(e.TargetType)
|
|
targetID = nullableString(e.TargetID)
|
|
ip = nullableString(e.IP)
|
|
ua = nullableString(e.UserAgent)
|
|
reqID = nullableString(e.RequestID)
|
|
)
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO ops.audit_logs
|
|
(operator_id, operator_name, action, target_type, target_id, metadata, ip, user_agent, request_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
e.OperatorID, operatorName, e.Action, targetType, targetID, metaBytes, ip, ua, reqID)
|
|
return err
|
|
}
|
|
|
|
func (r *AuditRepository) List(ctx context.Context, f domain.AuditLogFilter) ([]domain.AuditLog, int64, error) {
|
|
args := []any{}
|
|
where := "1=1"
|
|
|
|
if f.OperatorID != nil {
|
|
args = append(args, *f.OperatorID)
|
|
where += fmt.Sprintf(" AND operator_id = $%d", len(args))
|
|
}
|
|
if f.Action != "" {
|
|
args = append(args, f.Action)
|
|
where += fmt.Sprintf(" AND action = $%d", len(args))
|
|
}
|
|
if f.StartAt != nil {
|
|
args = append(args, *f.StartAt)
|
|
where += fmt.Sprintf(" AND created_at >= $%d", len(args))
|
|
}
|
|
if f.EndAt != nil {
|
|
args = append(args, *f.EndAt)
|
|
where += fmt.Sprintf(" AND created_at < $%d", len(args))
|
|
}
|
|
|
|
var total int64
|
|
if err := r.pool.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM ops.audit_logs WHERE "+where, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
limit := f.Limit
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 50
|
|
}
|
|
offset := f.Offset
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
args = append(args, limit, offset)
|
|
limitArg := len(args) - 1
|
|
offsetArg := len(args)
|
|
|
|
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
|
|
SELECT id, operator_id, operator_name, action, target_type, target_id,
|
|
metadata, ip, user_agent, request_id, created_at
|
|
FROM ops.audit_logs
|
|
WHERE %s
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT $%d OFFSET $%d`, where, limitArg, offsetArg), args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]domain.AuditLog, 0, limit)
|
|
for rows.Next() {
|
|
var (
|
|
log domain.AuditLog
|
|
metaRaw []byte
|
|
)
|
|
if err := rows.Scan(
|
|
&log.ID, &log.OperatorID, &log.OperatorName, &log.Action,
|
|
&log.TargetType, &log.TargetID, &metaRaw, &log.IP, &log.UserAgent,
|
|
&log.RequestID, &log.CreatedAt,
|
|
); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if len(metaRaw) > 0 {
|
|
if err := json.Unmarshal(metaRaw, &log.Metadata); err != nil {
|
|
return nil, 0, fmt.Errorf("decode audit metadata: %w", err)
|
|
}
|
|
}
|
|
out = append(out, log)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
func nullableString(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|