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:
@@ -83,6 +83,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
|
||||
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
|
||||
s.registerCRUDRoutes(m)
|
||||
s.registerAuditRoutes(m)
|
||||
s.registerPostgresMonitoringRoutes(m)
|
||||
s.registerPostgresMaintenanceRoutes(m)
|
||||
s.registerMaintenanceRoutes(m)
|
||||
|
||||
@@ -131,6 +131,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
out := apiKeyJSON(&created.APIKey)
|
||||
out["token"] = created.Token
|
||||
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+created.Name, created.ID, map[string]any{"api_key_id": created.ID, "role": created.Role})
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
@@ -187,6 +188,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.api_key.update", "Updated API key "+k.Name, k.ID, map[string]any{"api_key_id": k.ID, "role": k.Role})
|
||||
writeJSON(w, http.StatusOK, apiKeyJSON(k))
|
||||
}
|
||||
|
||||
@@ -195,7 +197,8 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
|
||||
keyID := r.PathValue("id")
|
||||
if err := s.store.RevokeAPIKey(a.TenantID, keyID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -203,6 +206,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.api_key.revoke", "Revoked API key", keyID, map[string]any{"api_key_id": keyID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -222,5 +226,6 @@ func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
out := apiKeyJSON(&rotated.APIKey)
|
||||
out["token"] = rotated.Token
|
||||
s.recordCRUDAudit(r, a, "bgp.api_key.rotate", "Rotated API key "+rotated.Name, rotated.ID, map[string]any{"api_key_id": rotated.ID})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestHandleListAudit(t *testing.T) {
|
||||
mem := store.NewMemory()
|
||||
mem.SeedDemo()
|
||||
tenant, _, _, _, _ := mem.DemoIDs()
|
||||
|
||||
srv, err := New(Options{SeedDemo: false, InsecureDev: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.store = mem
|
||||
|
||||
_, err = mem.AppendAudit(store.AuditAppendInput{
|
||||
TenantID: tenant,
|
||||
Action: "bgp.module.create",
|
||||
Summary: "Created module demo",
|
||||
TargetID: "mod-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/audit", nil)
|
||||
req.Header.Set("Authorization", "Bearer dev")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body.Items) != 1 {
|
||||
t.Fatalf("items=%d", len(body.Items))
|
||||
}
|
||||
if body.Items[0]["action"] != "bgp.module.create" {
|
||||
t.Fatalf("action=%v", body.Items[0]["action"])
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -113,6 +114,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.module.create", "Created module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "type": mod.Type, "name": mod.Name})
|
||||
writeJSON(w, http.StatusCreated, moduleJSON(mod))
|
||||
}
|
||||
|
||||
@@ -174,6 +176,7 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.module.update", "Updated module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "name": mod.Name})
|
||||
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.module.delete", "Deleted module", moduleID, map[string]any{"module_id": moduleID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -369,6 +373,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.cdn_source.create", "Created CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID, "url": x.URL})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
|
||||
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
|
||||
}
|
||||
@@ -399,6 +404,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.cdn_source.update", "Updated CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
|
||||
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
|
||||
}
|
||||
@@ -409,10 +415,12 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
if err := s.store.DeleteCDNSource(a.TenantID, mid, r.PathValue("source_id")); err != nil {
|
||||
sourceID := r.PathValue("source_id")
|
||||
if err := s.store.DeleteCDNSource(a.TenantID, mid, sourceID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.cdn_source.delete", "Deleted CDN source", sourceID, map[string]any{"module_id": mid, "source_id": sourceID})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -471,6 +479,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.as_entry.create", "Created AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
|
||||
writeJSON(w, http.StatusCreated, asEntryJSON(x))
|
||||
}
|
||||
@@ -491,6 +500,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.as_entry.update", "Updated AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
|
||||
writeJSON(w, http.StatusOK, asEntryJSON(x))
|
||||
}
|
||||
@@ -501,10 +511,12 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
if err := s.store.DeleteASEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
||||
entryID := r.PathValue("entry_id")
|
||||
if err := s.store.DeleteASEntry(a.TenantID, mid, entryID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.as_entry.delete", "Deleted AS entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -548,6 +560,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.domain_entry.create", "Created domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
|
||||
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
|
||||
}
|
||||
@@ -568,6 +581,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.domain_entry.update", "Updated domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
|
||||
writeJSON(w, http.StatusOK, domainEntryJSON(x))
|
||||
}
|
||||
@@ -578,10 +592,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
if err := s.store.DeleteDomainEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
||||
entryID := r.PathValue("entry_id")
|
||||
if err := s.store.DeleteDomainEntry(a.TenantID, mid, entryID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.domain_entry.delete", "Deleted domain entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -625,6 +641,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.ip_range.create", "Created IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create")
|
||||
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
|
||||
}
|
||||
@@ -645,6 +662,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.ip_range.update", "Updated IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch")
|
||||
writeJSON(w, http.StatusOK, ipRangeJSON(x))
|
||||
}
|
||||
@@ -655,10 +673,12 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
||||
entryID := r.PathValue("entry_id")
|
||||
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, entryID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.ip_range.delete", "Deleted IP range entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -850,6 +870,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.doh_profile.create", "Created DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
|
||||
writeJSON(w, http.StatusCreated, dohJSON(x))
|
||||
}
|
||||
|
||||
@@ -868,6 +889,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.doh_profile.update", "Updated DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
|
||||
writeJSON(w, http.StatusOK, dohJSON(x))
|
||||
}
|
||||
|
||||
@@ -876,10 +898,12 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
|
||||
profileID := r.PathValue("id")
|
||||
if err := s.store.DeleteDohProfile(a.TenantID, profileID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.doh_profile.delete", "Deleted DoH profile", profileID, map[string]any{"profile_id": profileID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -936,6 +960,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.community.create", "Created community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
|
||||
writeJSON(w, http.StatusCreated, commJSON(x))
|
||||
}
|
||||
|
||||
@@ -954,6 +979,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.community.update", "Updated community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
|
||||
writeJSON(w, http.StatusOK, commJSON(x))
|
||||
}
|
||||
|
||||
@@ -962,10 +988,12 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
|
||||
commID := r.PathValue("id")
|
||||
if err := s.store.DeleteCommunity(a.TenantID, commID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.community.delete", "Deleted community", commID, map[string]any{"community_id": commID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -988,6 +1016,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.peer.create", "Created BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
|
||||
s.enqueuePeerReconcile(a.TenantID, "peer_create")
|
||||
writeJSON(w, http.StatusCreated, peerJSON(x))
|
||||
}
|
||||
@@ -1031,6 +1060,7 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.peer.update", "Updated BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
|
||||
s.enqueuePeerReconcile(a.TenantID, "peer_patch")
|
||||
writeJSON(w, http.StatusOK, peerJSON(x))
|
||||
}
|
||||
@@ -1051,6 +1081,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.peer.delete", "Deleted BGP peer", peerID, map[string]any{"peer_id": peerID})
|
||||
s.enqueuePeerReconcile(a.TenantID, "peer_delete")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1074,6 +1105,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.speaker.create", "Created speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
|
||||
resp := speakerJSONFromStore(s.store, x)
|
||||
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
||||
resp["agent_secret"] = meta.AgentSecret
|
||||
@@ -1109,6 +1141,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.speaker.update", "Updated speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
@@ -1117,10 +1150,12 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
|
||||
speakerID := r.PathValue("speaker_id")
|
||||
if err := s.store.DeleteSpeaker(a.TenantID, speakerID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.speaker.delete", "Deleted speaker", speakerID, map[string]any{"speaker_id": speakerID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1187,9 +1222,22 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.settings.update", "Updated tenant settings", a.TenantID, map[string]any{"keys": settingsAuditKeys(body)})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func settingsAuditKeys(body map[string]any) []string {
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(body))
|
||||
for k := range body {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func parseRevisionRetentionMinutes(v any) (int, bool) {
|
||||
const minMinutes = 15
|
||||
const maxMinutes = 30 * 24 * 60
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/audit"
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/maintenance"
|
||||
"evobgp/internal/pgmonitor"
|
||||
@@ -38,11 +39,13 @@ type Server struct {
|
||||
mux *http.ServeMux
|
||||
|
||||
// Portal / dual-auth (JWT) configuration.
|
||||
jwtSecret string
|
||||
authIssuer string
|
||||
authPortalURL string
|
||||
portalTenantID string
|
||||
authRequired bool
|
||||
jwtSecret string
|
||||
authIssuer string
|
||||
authPortalURL string
|
||||
portalTenantID string
|
||||
authRequired bool
|
||||
auditIngestSecret string
|
||||
auditPusher *audit.PortalPusher
|
||||
}
|
||||
|
||||
// Options configures the API server.
|
||||
@@ -63,6 +66,8 @@ type Options struct {
|
||||
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
|
||||
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
|
||||
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
|
||||
// AuditIngestSecret — AUTH_AUDIT_INGEST_SECRET for portal push (optional).
|
||||
AuditIngestSecret string
|
||||
}
|
||||
|
||||
// New constructs Server and wiring for async jobs.
|
||||
@@ -123,10 +128,12 @@ func New(opts Options) (*Server, error) {
|
||||
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
|
||||
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
|
||||
authRequired: opts.AuthRequired,
|
||||
auditIngestSecret: strings.TrimSpace(opts.AuditIngestSecret),
|
||||
}
|
||||
if s.authIssuer == "" {
|
||||
s.authIssuer = "https://auth.shnt.top"
|
||||
}
|
||||
s.initAuditPusher(s.authPortalURL, s.auditIngestSecret)
|
||||
s.mux = http.NewServeMux()
|
||||
s.registerRoutes()
|
||||
return s, nil
|
||||
|
||||
Reference in New Issue
Block a user