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]>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user