Files
EvoBGP/internal/repository/postgres_audit.go
T
DenozordecandCursor 738d2e2256
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 32s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m56s
feat(httpapi): add local audit log with portal dual-write
Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp).

Co-authored-by: Cursor <[email protected]>
2026-07-21 13:24:54 +07:00

187 lines
5.3 KiB
Go

package repository
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"evobgp/internal/store"
)
// AppendAudit inserts a tenant-scoped audit row.
func (p *Postgres) AppendAudit(in store.AuditAppendInput) (*store.AuditEntry, error) {
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
return nil, store.ErrInvalidInput
}
sev := strings.TrimSpace(in.Severity)
if sev == "" {
sev = store.AuditSeverityInfo
}
if !store.ValidAuditSeverity(sev) {
return nil, store.ErrInvalidInput
}
ctx := context.Background()
id := uuid.NewString()
eventID := "bgp-" + uuid.NewString()
var detailJSON []byte
if in.Details != nil {
detailJSON, _ = json.Marshal(in.Details)
}
var createdAt time.Time
err := p.pool.QueryRow(ctx, `
INSERT INTO audit_log
(id, tenant_id, event_id, source_app, action, severity,
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
target_type, target_id, summary, details_json, ip, created_at)
VALUES ($1, $2, $3, 'bgp', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, now())
RETURNING created_at`,
id, strings.TrimSpace(in.TenantID), eventID, strings.TrimSpace(in.Action), sev,
nullIfEmpty(in.ActorUserID), nullIfEmpty(in.ActorEmail), nullIfEmpty(in.ActorName),
nullIfEmpty(in.ActorAPIKeyPrefix), nullIfEmpty(in.TargetType), nullIfEmpty(in.TargetID),
strings.TrimSpace(in.Summary), nullJSONBytes(detailJSON), nullIfEmpty(in.IP),
).Scan(&createdAt)
if err != nil {
return nil, err
}
return &store.AuditEntry{
ID: id,
TenantID: strings.TrimSpace(in.TenantID),
EventID: eventID,
SourceApp: store.AuditSourceAppBGP,
Action: strings.TrimSpace(in.Action),
Severity: sev,
ActorUserID: strings.TrimSpace(in.ActorUserID),
ActorEmail: strings.TrimSpace(in.ActorEmail),
ActorName: strings.TrimSpace(in.ActorName),
ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix),
TargetType: strings.TrimSpace(in.TargetType),
TargetID: strings.TrimSpace(in.TargetID),
Summary: strings.TrimSpace(in.Summary),
Details: in.Details,
IP: strings.TrimSpace(in.IP),
CreatedAt: createdAt.UTC(),
}, nil
}
// ListAudit returns paginated audit rows for a tenant.
func (p *Postgres) ListAudit(tenantID, cursor string, limit int, filter store.AuditListFilter) ([]*store.AuditEntry, string, bool, error) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
args := []any{tenantID}
where := "tenant_id = $1"
argN := 2
if a := strings.TrimSpace(filter.Action); a != "" {
where += " AND action = $" + strconv.Itoa(argN)
args = append(args, a)
argN++
}
if s := strings.TrimSpace(filter.Severity); s != "" {
where += " AND severity = $" + strconv.Itoa(argN)
args = append(args, s)
argN++
}
args = append(args, limit+1, off)
q := `
SELECT id, tenant_id, event_id, source_app, action, severity,
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
target_type, target_id, summary, details_json, ip, created_at, portal_pushed_at
FROM audit_log
WHERE ` + where + `
ORDER BY created_at DESC, id DESC
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
rows, err := p.pool.Query(ctx, q, args...)
if err != nil {
return nil, "", false, err
}
defer rows.Close()
var out []*store.AuditEntry
for rows.Next() {
row, err := scanAuditEntry(rows.Scan)
if err != nil {
return nil, "", false, err
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, "", false, err
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = strconv.Itoa(off + limit)
}
return out, next, more, nil
}
// MarkAuditPortalPushed sets portal_pushed_at for a row.
func (p *Postgres) MarkAuditPortalPushed(id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `UPDATE audit_log SET portal_pushed_at = now() WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func scanAuditEntry(scan func(dest ...any) error) (*store.AuditEntry, error) {
var row store.AuditEntry
var actorUserID, actorEmail, actorName, actorPrefix, targetType, targetID, ip *string
var detailRaw []byte
var portalPushed *time.Time
if err := scan(
&row.ID, &row.TenantID, &row.EventID, &row.SourceApp, &row.Action, &row.Severity,
&actorUserID, &actorEmail, &actorName, &actorPrefix,
&targetType, &targetID, &row.Summary, &detailRaw, &ip, &row.CreatedAt, &portalPushed,
); err != nil {
return nil, err
}
row.CreatedAt = row.CreatedAt.UTC()
if actorUserID != nil {
row.ActorUserID = *actorUserID
}
if actorEmail != nil {
row.ActorEmail = *actorEmail
}
if actorName != nil {
row.ActorName = *actorName
}
if actorPrefix != nil {
row.ActorAPIKeyPrefix = *actorPrefix
}
if targetType != nil {
row.TargetType = *targetType
}
if targetID != nil {
row.TargetID = *targetID
}
if ip != nil {
row.IP = *ip
}
if len(detailRaw) > 0 {
_ = json.Unmarshal(detailRaw, &row.Details)
}
if portalPushed != nil {
t := portalPushed.UTC()
row.PortalPushedAt = &t
}
return &row, nil
}