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,117 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user