Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp). Co-authored-by: Cursor <[email protected]>
118 lines
3.0 KiB
Go
118 lines
3.0 KiB
Go
package store
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func (m *Memory) AppendAudit(in AuditAppendInput) (*AuditEntry, error) {
|
|
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
|
|
return nil, ErrInvalidInput
|
|
}
|
|
sev := strings.TrimSpace(in.Severity)
|
|
if sev == "" {
|
|
sev = AuditSeverityInfo
|
|
}
|
|
if !ValidAuditSeverity(sev) {
|
|
return nil, ErrInvalidInput
|
|
}
|
|
now := time.Now().UTC()
|
|
row := &AuditEntry{
|
|
ID: uuid.NewString(),
|
|
TenantID: strings.TrimSpace(in.TenantID),
|
|
EventID: "bgp-" + uuid.NewString(),
|
|
SourceApp: 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: now,
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.auditLog = append(m.auditLog, row)
|
|
return cloneAuditEntry(row), nil
|
|
}
|
|
|
|
func (m *Memory) ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var filtered []*AuditEntry
|
|
for _, row := range m.auditLog {
|
|
if row.TenantID != tenantID {
|
|
continue
|
|
}
|
|
if a := strings.TrimSpace(filter.Action); a != "" && row.Action != a {
|
|
continue
|
|
}
|
|
if s := strings.TrimSpace(filter.Severity); s != "" && row.Severity != s {
|
|
continue
|
|
}
|
|
filtered = append(filtered, row)
|
|
}
|
|
sort.Slice(filtered, func(i, j int) bool {
|
|
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
|
|
return filtered[i].ID > filtered[j].ID
|
|
}
|
|
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
|
})
|
|
off := parseMaintCursor(cursor)
|
|
end := off + limit
|
|
next := ""
|
|
hasMore := false
|
|
if end > len(filtered) {
|
|
end = len(filtered)
|
|
} else if end < len(filtered) {
|
|
hasMore = true
|
|
next = formatMaintCursor(end)
|
|
}
|
|
if off >= len(filtered) {
|
|
return nil, "", false, nil
|
|
}
|
|
out := make([]*AuditEntry, end-off)
|
|
for i := off; i < end; i++ {
|
|
out[i-off] = cloneAuditEntry(filtered[i])
|
|
}
|
|
return out, next, hasMore, nil
|
|
}
|
|
|
|
func (m *Memory) MarkAuditPortalPushed(id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, row := range m.auditLog {
|
|
if row.ID == id {
|
|
now := time.Now().UTC()
|
|
row.PortalPushedAt = &now
|
|
return nil
|
|
}
|
|
}
|
|
return ErrNotFound
|
|
}
|
|
|
|
func cloneAuditEntry(row *AuditEntry) *AuditEntry {
|
|
if row == nil {
|
|
return nil
|
|
}
|
|
cp := *row
|
|
if row.Details != nil {
|
|
cp.Details = make(map[string]any, len(row.Details))
|
|
for k, v := range row.Details {
|
|
cp.Details[k] = v
|
|
}
|
|
}
|
|
return &cp
|
|
}
|