Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp). Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"evobgp/internal/audit"
|
|
"evobgp/internal/store"
|
|
)
|
|
|
|
func (s *Server) registerAuditRoutes(m *http.ServeMux) {
|
|
m.HandleFunc("GET /audit", s.handleListAudit)
|
|
}
|
|
|
|
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
|
|
return
|
|
}
|
|
cursor := r.URL.Query().Get("cursor")
|
|
limit := parseLimitQuery(r, 20, 200)
|
|
filter := store.AuditListFilter{
|
|
Action: strings.TrimSpace(r.URL.Query().Get("action")),
|
|
Severity: strings.TrimSpace(r.URL.Query().Get("severity")),
|
|
}
|
|
if filter.Severity != "" && !store.ValidAuditSeverity(filter.Severity) {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid severity")
|
|
return
|
|
}
|
|
items, next, hasMore, err := s.store.ListAudit(a.TenantID, cursor, limit, filter)
|
|
if err != nil {
|
|
writeInternalError(w, "audit_list", err)
|
|
return
|
|
}
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, row := range items {
|
|
out = append(out, auditEntryJSON(row))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
|
|
}
|
|
|
|
func auditEntryJSON(row *store.AuditEntry) map[string]any {
|
|
if row == nil {
|
|
return map[string]any{}
|
|
}
|
|
m := map[string]any{
|
|
"id": row.ID,
|
|
"tenant_id": row.TenantID,
|
|
"event_id": row.EventID,
|
|
"source_app": row.SourceApp,
|
|
"action": row.Action,
|
|
"severity": row.Severity,
|
|
"actor_user_id": strPtrOrNull(row.ActorUserID),
|
|
"actor_email": strPtrOrNull(row.ActorEmail),
|
|
"actor_name": strPtrOrNull(row.ActorName),
|
|
"actor_api_key_prefix": strPtrOrNull(row.ActorAPIKeyPrefix),
|
|
"target_type": strPtrOrNull(row.TargetType),
|
|
"target_id": strPtrOrNull(row.TargetID),
|
|
"summary": row.Summary,
|
|
"details": row.Details,
|
|
"ip": strPtrOrNull(row.IP),
|
|
"created_at": row.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"portal_pushed_at": nil,
|
|
}
|
|
if row.PortalPushedAt != nil {
|
|
m["portal_pushed_at"] = row.PortalPushedAt.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if m["details"] == nil {
|
|
m["details"] = nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (s *Server) recordCRUDAudit(r *http.Request, a Auth, action, summary, targetID string, details map[string]any) {
|
|
if s == nil || s.store == nil {
|
|
return
|
|
}
|
|
in := store.AuditAppendInput{
|
|
TenantID: a.TenantID,
|
|
Action: action,
|
|
Severity: store.AuditSeverityInfo,
|
|
TargetType: store.AuditTargetAppResource,
|
|
TargetID: targetID,
|
|
Summary: summary,
|
|
Details: details,
|
|
IP: clientIP(r),
|
|
}
|
|
fillAuditActor(&in, a)
|
|
entry, err := s.store.AppendAudit(in)
|
|
if err != nil {
|
|
log.Printf("httpapi: audit append action=%s: %v", action, err)
|
|
return
|
|
}
|
|
s.pushAuditToPortal(entry)
|
|
}
|
|
|
|
func fillAuditActor(in *store.AuditAppendInput, a Auth) {
|
|
if in == nil {
|
|
return
|
|
}
|
|
if a.Kind == AuthKindJWT {
|
|
in.ActorUserID = strings.TrimSpace(a.UserID)
|
|
in.ActorEmail = strings.TrimSpace(a.Email)
|
|
if in.ActorEmail != "" {
|
|
in.ActorName = in.ActorEmail
|
|
}
|
|
return
|
|
}
|
|
prefix := actorPrefix(a)
|
|
in.ActorAPIKeyPrefix = prefix
|
|
if prefix != "" {
|
|
in.ActorName = "apikey:" + prefix
|
|
}
|
|
}
|
|
|
|
func (s *Server) pushAuditToPortal(entry *store.AuditEntry) {
|
|
if s == nil || s.auditPusher == nil || entry == nil {
|
|
return
|
|
}
|
|
pusher := s.auditPusher
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
if err := pusher.PushEvent(ctx, entry); err != nil {
|
|
log.Printf("httpapi: audit portal push event_id=%s: %v", entry.EventID, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
|
|
parts := strings.Split(xff, ",")
|
|
if len(parts) > 0 {
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
}
|
|
if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
|
|
return xrip
|
|
}
|
|
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
|
if err != nil {
|
|
return strings.TrimSpace(r.RemoteAddr)
|
|
}
|
|
return host
|
|
}
|
|
|
|
// initAuditPusher wires portal push when URL and secret are configured.
|
|
func (s *Server) initAuditPusher(portalURL, ingestSecret string) {
|
|
base := strings.TrimSpace(portalURL)
|
|
secret := strings.TrimSpace(ingestSecret)
|
|
if base == "" || secret == "" {
|
|
return
|
|
}
|
|
s.auditPusher = &audit.PortalPusher{
|
|
BaseURL: base,
|
|
Secret: secret,
|
|
MarkPushed: func(id string) error {
|
|
if s.store == nil {
|
|
return nil
|
|
}
|
|
return s.store.MarkAuditPortalPushed(id)
|
|
},
|
|
}
|
|
}
|