Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp). Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
5.3 KiB
Go
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
|
|
}
|