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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
2e3e1493f5
commit
738d2e2256
23 files changed
+1102
-13
No files matched your search
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AuditSourceAppBGP = "bgp"
|
||||
AuditTargetAppResource = "app_resource"
|
||||
AuditSeverityInfo = "info"
|
||||
AuditSeverityWarning = "warning"
|
||||
AuditSeverityCritical = "critical"
|
||||
)
|
||||
|
||||
// AuditEntry is a persisted CRUD / settings audit row (local + portal ingest).
|
||||
type AuditEntry struct {
|
||||
ID string
|
||||
TenantID string
|
||||
EventID string
|
||||
SourceApp string
|
||||
Action string
|
||||
Severity string
|
||||
ActorUserID string
|
||||
ActorEmail string
|
||||
ActorName string
|
||||
ActorAPIKeyPrefix string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Summary string
|
||||
Details map[string]any
|
||||
IP string
|
||||
CreatedAt time.Time
|
||||
PortalPushedAt *time.Time
|
||||
}
|
||||
|
||||
// AuditAppendInput is input for AppendAudit.
|
||||
type AuditAppendInput struct {
|
||||
TenantID string
|
||||
Action string
|
||||
Severity string
|
||||
ActorUserID string
|
||||
ActorEmail string
|
||||
ActorName string
|
||||
ActorAPIKeyPrefix string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Summary string
|
||||
Details map[string]any
|
||||
IP string
|
||||
}
|
||||
|
||||
// AuditListFilter optional query filters for ListAudit.
|
||||
type AuditListFilter struct {
|
||||
Action string
|
||||
Severity string
|
||||
}
|
||||
|
||||
// ValidAuditSeverity reports whether s is an allowed severity.
|
||||
func ValidAuditSeverity(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case AuditSeverityInfo, AuditSeverityWarning, AuditSeverityCritical:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,11 @@ type Backend interface {
|
||||
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
|
||||
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
|
||||
|
||||
// CRUD audit log (tenant-scoped; optional portal ingest push from httpapi).
|
||||
AppendAudit(in AuditAppendInput) (*AuditEntry, error)
|
||||
ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error)
|
||||
MarkAuditPortalPushed(id string) error
|
||||
|
||||
// Firewall blocklist clients and policy rules.
|
||||
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
|
||||
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
|
||||
|
||||
@@ -50,6 +50,7 @@ type Memory struct {
|
||||
maintenancePolicies map[string]*MaintenancePolicy
|
||||
maintConfigAudit []*MaintenancePolicyConfigAudit
|
||||
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
||||
auditLog []*AuditEntry
|
||||
|
||||
// DemoIDs valid after SeedDemo()
|
||||
demoTenantID string
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMemoryAppendAndListAudit(t *testing.T) {
|
||||
m := NewMemory()
|
||||
tenantA := "tenant-a"
|
||||
tenantB := "tenant-b"
|
||||
|
||||
entry, err := m.AppendAudit(AuditAppendInput{
|
||||
TenantID: tenantA,
|
||||
Action: "bgp.module.create",
|
||||
Summary: "Created module test",
|
||||
TargetID: "mod-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry == nil || entry.EventID == "" || entry.SourceApp != AuditSourceAppBGP {
|
||||
t.Fatalf("unexpected entry: %+v", entry)
|
||||
}
|
||||
|
||||
if _, err := m.AppendAudit(AuditAppendInput{
|
||||
TenantID: tenantB,
|
||||
Action: "bgp.peer.delete",
|
||||
Summary: "Deleted peer",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
items, _, hasMore, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || hasMore {
|
||||
t.Fatalf("items=%d hasMore=%v", len(items), hasMore)
|
||||
}
|
||||
if items[0].Action != "bgp.module.create" {
|
||||
t.Fatalf("action=%s", items[0].Action)
|
||||
}
|
||||
|
||||
filtered, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{Action: "bgp.peer.delete"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(filtered) != 0 {
|
||||
t.Fatalf("expected empty filter result, got %d", len(filtered))
|
||||
}
|
||||
|
||||
if err := m.MarkAuditPortalPushed(entry.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items2, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items2[0].PortalPushedAt == nil {
|
||||
t.Fatal("expected portal_pushed_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryAppendAuditValidation(t *testing.T) {
|
||||
m := NewMemory()
|
||||
if _, err := m.AppendAudit(AuditAppendInput{}); err != ErrInvalidInput {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := m.AppendAudit(AuditAppendInput{TenantID: "t", Action: "x", Summary: "s", Severity: "bad"}); err != ErrInvalidInput {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user