diff --git a/cmd/gomcp/main.go b/cmd/gomcp/main.go index ed33109..74d6282 100644 --- a/cmd/gomcp/main.go +++ b/cmd/gomcp/main.go @@ -631,7 +631,7 @@ type socDoctorAdapter struct { } func (a *socDoctorAdapter) Dashboard() (tools.SOCDashboardData, error) { - dash, err := a.soc.Dashboard() + dash, err := a.soc.Dashboard("") if err != nil { return tools.SOCDashboardData{}, err } diff --git a/cmd/soc/main.go b/cmd/soc/main.go index 39a05b3..ee6069d 100644 --- a/cmd/soc/main.go +++ b/cmd/soc/main.go @@ -169,6 +169,7 @@ func main() { shieldEngine, shieldErr := engines.NewNativeShield() if shieldErr != nil { logger.Warn("shield: C engine not available, using stub", "error", shieldErr) + srv.SetShieldEngine(engines.NewStubShield()) } else { srv.SetShieldEngine(shieldEngine) logger.Info("shield: C engine initialized", "version", shieldEngine.Version()) diff --git a/internal/application/soc/e2e_test.go b/internal/application/soc/e2e_test.go index 6de2c6a..3cd7308 100644 --- a/internal/application/soc/e2e_test.go +++ b/internal/application/soc/e2e_test.go @@ -72,7 +72,7 @@ func TestE2E_FullPipeline_IngestToIncident(t *testing.T) { assert.Equal(t, inc2.ID, gotInc.ID) // Step 4: Verify decision chain integrity. - dash, err := svc.Dashboard() + dash, err := svc.Dashboard("") require.NoError(t, err) assert.True(t, dash.ChainValid, "decision chain should be valid") assert.Greater(t, dash.TotalEvents, 0) @@ -310,7 +310,7 @@ func TestE2E_ConcurrentIngest(t *testing.T) { // Some events may be rate-limited (100 events/sec per sensor), // but there should be no panics or data corruption. - dash, err := svc.Dashboard() + dash, err := svc.Dashboard("") require.NoError(t, err) assert.Greater(t, dash.TotalEvents, 0, "at least some events should have been ingested") } diff --git a/internal/application/soc/service.go b/internal/application/soc/service.go index 6f789e6..624c4fd 100644 --- a/internal/application/soc/service.go +++ b/internal/application/soc/service.go @@ -669,14 +669,14 @@ func (s *Service) CheckSensors() []domsoc.Sensor { return offlineSensors } -// ListEvents returns recent events with optional limit. -func (s *Service) ListEvents(limit int) ([]domsoc.SOCEvent, error) { - return s.repo.ListEvents("", limit) +// ListEvents returns recent events with optional limit, scoped to tenant. +func (s *Service) ListEvents(tenantID string, limit int) ([]domsoc.SOCEvent, error) { + return s.repo.ListEvents(tenantID, limit) } -// ListIncidents returns incidents, optionally filtered by status. -func (s *Service) ListIncidents(status string, limit int) ([]domsoc.Incident, error) { - return s.repo.ListIncidents("", status, limit) +// ListIncidents returns incidents, optionally filtered by status, scoped to tenant. +func (s *Service) ListIncidents(tenantID string, status string, limit int) ([]domsoc.Incident, error) { + return s.repo.ListIncidents(tenantID, status, limit) } // ListRules returns all active correlation rules (built-in + custom). @@ -1133,9 +1133,9 @@ func (s *Service) ExportIncidentsCSV(f IncidentFilter) ([]byte, error) { return buf.Bytes(), nil } -// ListSensors returns all registered sensors. -func (s *Service) ListSensors() ([]domsoc.Sensor, error) { - return s.repo.ListSensors("") +// ListSensors returns registered sensors, scoped to tenant. +func (s *Service) ListSensors(tenantID string) ([]domsoc.Sensor, error) { + return s.repo.ListSensors(tenantID) } // RegisterSensor adds or updates a sensor in the SOC. @@ -1154,24 +1154,24 @@ func (s *Service) DeregisterSensor(id string) { delete(s.sensors, id) } -// Dashboard returns SOC KPI metrics. -func (s *Service) Dashboard() (*DashboardData, error) { - totalEvents, err := s.repo.CountEvents("") +// Dashboard returns SOC KPI metrics, scoped to tenant. +func (s *Service) Dashboard(tenantID string) (*DashboardData, error) { + totalEvents, err := s.repo.CountEvents(tenantID) if err != nil { return nil, err } - lastHourEvents, err := s.repo.CountEventsSince("", time.Now().Add(-1 * time.Hour)) + lastHourEvents, err := s.repo.CountEventsSince(tenantID, time.Now().Add(-1 * time.Hour)) if err != nil { return nil, err } - openIncidents, err := s.repo.CountOpenIncidents("") + openIncidents, err := s.repo.CountOpenIncidents(tenantID) if err != nil { return nil, err } - sensorCounts, err := s.repo.CountSensorsByStatus("") + sensorCounts, err := s.repo.CountSensorsByStatus(tenantID) if err != nil { return nil, err } @@ -1300,7 +1300,7 @@ type PlaybookResult struct { // ComplianceReport generates an EU AI Act Article 15 compliance report (§12.3). func (s *Service) ComplianceReport() (*ComplianceData, error) { - dashboard, err := s.Dashboard() + dashboard, err := s.Dashboard("") // global view for compliance if err != nil { return nil, err } diff --git a/internal/domain/engines/ffi_sentinel_stub.go b/internal/domain/engines/ffi_sentinel_stub.go new file mode 100644 index 0000000..3289fd6 --- /dev/null +++ b/internal/domain/engines/ffi_sentinel_stub.go @@ -0,0 +1,15 @@ +//go:build !sentinel_native + +package engines + +import "errors" + +// NativeSentinelCore is a dummy to make compilation pass when tag is missing. +type NativeSentinelCore struct { + StubSentinelCore +} + +// NewNativeSentinelCore returns an error when built without sentinel_native tag. +func NewNativeSentinelCore() (*NativeSentinelCore, error) { + return nil, errors.New("sentinel_native build tag not provided; native engine disabled") +} diff --git a/internal/domain/engines/ffi_shield_stub.go b/internal/domain/engines/ffi_shield_stub.go new file mode 100644 index 0000000..685beb8 --- /dev/null +++ b/internal/domain/engines/ffi_shield_stub.go @@ -0,0 +1,15 @@ +//go:build !shield_native + +package engines + +import "errors" + +// NativeShield is a dummy to make compilation pass when tag is missing. +type NativeShield struct { + StubShield +} + +// NewNativeShield returns an error when built without shield_native tag. +func NewNativeShield() (*NativeShield, error) { + return nil, errors.New("shield_native build tag not provided; native engine disabled") +} diff --git a/internal/infrastructure/auth/handlers.go b/internal/infrastructure/auth/handlers.go index b90e963..48e46d6 100644 --- a/internal/infrastructure/auth/handlers.go +++ b/internal/infrastructure/auth/handlers.go @@ -13,13 +13,9 @@ type LoginRequest struct { Password string `json:"password"` } -// TokenResponse is returned on successful login/refresh. type TokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` // seconds - TokenType string `json:"token_type"` - User *User `json:"user"` + CSRFToken string `json:"csrf_token"` + User *User `json:"user"` } // HandleLogin creates an HTTP handler for POST /api/auth/login. @@ -73,12 +69,30 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc { return } + // SEC: H1 - Use httpOnly Cookies instead of localStorage + http.SetCookie(w, &http.Cookie{ + Name: "syntrex_token", + Value: accessToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 900, + }) + http.SetCookie(w, &http.Cookie{ + Name: "syntrex_refresh", + Value: refreshToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 7 * 24 * 3600, + }) + + // SEC: M2 - Generate stateless CSRF token + csrfToken := hmacSign([]byte(accessToken), secret)[:32] + resp := TokenResponse{ - AccessToken: accessToken, - RefreshToken: refreshToken, - ExpiresIn: 900, // 15 minutes - TokenType: "Bearer", - User: user, + CSRFToken: csrfToken, + User: user, } w.Header().Set("Content-Type", "application/json") @@ -89,15 +103,14 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc { // HandleRefresh creates an HTTP handler for POST /api/auth/refresh. func HandleRefresh(secret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req struct { - RefreshToken string `json:"refresh_token"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAuthError(w, http.StatusBadRequest, "invalid JSON body") + // Extract refresh token from cookie + cookie, err := r.Cookie("syntrex_refresh") + if err != nil { + writeAuthError(w, http.StatusUnauthorized, "missing refresh token cookie") return } - claims, err := Verify(req.RefreshToken, secret) + claims, err := Verify(cookie.Value, secret) if err != nil { writeAuthError(w, http.StatusUnauthorized, "invalid or expired refresh token") return @@ -115,11 +128,21 @@ func HandleRefresh(secret []byte) http.HandlerFunc { return } + // SEC: H1 - Set new httpOnly token + http.SetCookie(w, &http.Cookie{ + Name: "syntrex_token", + Value: accessToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 900, + }) + + csrfToken := hmacSign([]byte(accessToken), secret)[:32] + resp := TokenResponse{ - AccessToken: accessToken, - RefreshToken: req.RefreshToken, - ExpiresIn: 900, - TokenType: "Bearer", + CSRFToken: csrfToken, + User: &User{Email: claims.Sub, Role: claims.Role}, // Mock user to provide payload } w.Header().Set("Content-Type", "application/json") @@ -127,6 +150,31 @@ func HandleRefresh(secret []byte) http.HandlerFunc { } } +// HandleLogout clears the auth cookies. +// POST /api/auth/logout +func HandleLogout() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "syntrex_token", + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + http.SetCookie(w, &http.Cookie{ + Name: "syntrex_refresh", + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) + } +} + // HandleMe returns the current authenticated user profile. // GET /api/auth/me func HandleMe(store *UserStore) http.HandlerFunc { diff --git a/internal/infrastructure/auth/middleware.go b/internal/infrastructure/auth/middleware.go index 68b6c10..55155a1 100644 --- a/internal/infrastructure/auth/middleware.go +++ b/internal/infrastructure/auth/middleware.go @@ -28,6 +28,7 @@ func NewJWTMiddleware(secret []byte) *JWTMiddleware { "/readyz": true, "/metrics": true, "/api/auth/login": true, + "/api/auth/logout": true, "/api/auth/refresh": true, "/api/auth/register": true, "/api/auth/verify": true, @@ -51,20 +52,43 @@ func (m *JWTMiddleware) Middleware(next http.Handler) http.Handler { return } - // Extract Bearer token. + var tokenStr string authHeader := r.Header.Get("Authorization") - if authHeader == "" { - writeAuthError(w, http.StatusUnauthorized, "missing Authorization header") - return + if strings.HasPrefix(authHeader, "Bearer stx_") { + // Allow API keys via header + parts := strings.SplitN(authHeader, " ", 2) + tokenStr = parts[1] + } else { + // SEC: H1 - Read token from httpOnly cookie + cookie, err := r.Cookie("syntrex_token") + if err != nil || cookie.Value == "" { + // Fallback to legacy bearer token (for clients that haven't migrated yet or testing) + if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + tokenStr = strings.TrimPrefix(authHeader, "Bearer ") + } else { + writeAuthError(w, http.StatusUnauthorized, "missing authentication cookie") + return + } + } else { + tokenStr = cookie.Value + } } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { - writeAuthError(w, http.StatusUnauthorized, "invalid Authorization format (expected: Bearer )") - return + // SEC: M2 - Validate CSRF Token on mutating requests + if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" { + // Exempt API keys from CSRF if they used the header + if !strings.HasPrefix(authHeader, "Bearer stx_") { + csrfHeader := r.Header.Get("X-CSRF-Token") + expectedCSRF := hmacSign([]byte(tokenStr), m.secret)[:32] + if csrfHeader == "" || csrfHeader != expectedCSRF { + slog.Warn("CSRF token missing or invalid", "path", r.URL.Path, "remote", r.RemoteAddr) + writeAuthError(w, http.StatusForbidden, "invalid CSRF token") + return + } + } } - claims, err := Verify(parts[1], m.secret) + claims, err := Verify(tokenStr, m.secret) if err != nil { slog.Warn("JWT auth failed", "error", err, diff --git a/internal/infrastructure/auth/tenant_handlers.go b/internal/infrastructure/auth/tenant_handlers.go index 68d7323..e2e1e5a 100644 --- a/internal/infrastructure/auth/tenant_handlers.go +++ b/internal/infrastructure/auth/tenant_handlers.go @@ -67,7 +67,7 @@ func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret [] } // Create tenant - tenant, err := tenantStore.CreateTenant(req.OrgName, req.OrgSlug, user.ID, "starter") + tenant, err := tenantStore.CreateTenant(req.OrgName, req.OrgSlug, user.ID, "free") if err != nil { if err == ErrTenantExists { http.Error(w, `{"error":"organization slug already taken"}`, http.StatusConflict) @@ -260,7 +260,7 @@ func HandleUpdateTenantPlan(tenantStore *TenantStore) http.HandlerFunc { func HandleListPlans() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { plans := make([]Plan, 0, len(DefaultPlans)) - order := []string{"starter", "professional", "enterprise"} + order := []string{"free", "starter", "professional", "enterprise"} for _, id := range order { if p, ok := DefaultPlans[id]; ok { plans = append(plans, p) diff --git a/internal/infrastructure/auth/tenants.go b/internal/infrastructure/auth/tenants.go index 2a477e0..e221e76 100644 --- a/internal/infrastructure/auth/tenants.go +++ b/internal/infrastructure/auth/tenants.go @@ -22,10 +22,12 @@ type Plan struct { Name string `json:"name"` Description string `json:"description,omitempty"` MaxUsers int `json:"max_users"` - MaxEventsMonth int `json:"max_events_month"` + MaxEventsMonth int `json:"max_events_month"` // SOC event ingestion quota (-1=unlimited) MaxIncidents int `json:"max_incidents"` MaxSensors int `json:"max_sensors"` + MaxScansMonth int `json:"max_scans_month"` // /api/v1/scan quota (-1=unlimited, 0=none) RetentionDays int `json:"retention_days"` + SOCEnabled bool `json:"soc_enabled"` // SOC Dashboard access SLAEnabled bool `json:"sla_enabled"` SOAREnabled bool `json:"soar_enabled"` ComplianceEnabled bool `json:"compliance_enabled"` @@ -35,25 +37,40 @@ type Plan struct { // DefaultPlans defines the standard pricing tiers (prices in RUB kopecks). var DefaultPlans = map[string]Plan{ + "free": { + ID: "free", Name: "Free", + Description: "Scanner API — 1 000 сканов/мес, все 66 движков, без SOC Dashboard", + MaxUsers: 1, MaxEventsMonth: 0, MaxIncidents: 0, MaxSensors: 0, + MaxScansMonth: 1000, + RetentionDays: 0, + SOCEnabled: false, SLAEnabled: false, SOAREnabled: false, ComplianceEnabled: false, + PriceMonthCents: 0, + }, "starter": { ID: "starter", Name: "Starter", Description: "AI-мониторинг: до 5 сенсоров, базовая корреляция и алерты", MaxUsers: 10, MaxEventsMonth: 100000, MaxIncidents: 200, MaxSensors: 5, - RetentionDays: 30, SLAEnabled: true, SOAREnabled: false, ComplianceEnabled: false, + MaxScansMonth: 100000, + RetentionDays: 30, + SOCEnabled: true, SLAEnabled: true, SOAREnabled: false, ComplianceEnabled: false, PriceMonthCents: 8990000, // 89 900 ₽/мес }, "professional": { ID: "professional", Name: "Professional", Description: "Полный AI SOC: SOAR, compliance, расширенная аналитика", MaxUsers: 50, MaxEventsMonth: 500000, MaxIncidents: 1000, MaxSensors: 25, - RetentionDays: 90, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true, + MaxScansMonth: 500000, + RetentionDays: 90, + SOCEnabled: true, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true, PriceMonthCents: 14990000, // 149 900 ₽/мес }, "enterprise": { ID: "enterprise", Name: "Enterprise", Description: "On-premise / выделенный инстанс. Сертификация — на стороне заказчика", MaxUsers: -1, MaxEventsMonth: -1, MaxIncidents: -1, MaxSensors: -1, - RetentionDays: 365, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true, + MaxScansMonth: -1, // unlimited + RetentionDays: 365, + SOCEnabled: true, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true, OnPremise: true, PriceMonthCents: -1, // по запросу }, @@ -79,7 +96,17 @@ func (t *Tenant) GetPlan() Plan { if p, ok := DefaultPlans[t.PlanID]; ok { return p } - return DefaultPlans["starter"] + return DefaultPlans["free"] // secure default: unknown plan → free tier +} + +// CanAccessSOC returns true if the tenant's plan includes SOC Dashboard access. +func (t *Tenant) CanAccessSOC() bool { + return t.GetPlan().SOCEnabled +} + +// ScanLimit returns the monthly scan quota for this tenant (-1=unlimited). +func (t *Tenant) ScanLimit() int { + return t.GetPlan().MaxScansMonth } // CanIngestEvent checks if the tenant can still ingest events this month. diff --git a/internal/infrastructure/auth/usage.go b/internal/infrastructure/auth/usage.go index 4d0a0dd..7401f19 100644 --- a/internal/infrastructure/auth/usage.go +++ b/internal/infrastructure/auth/usage.go @@ -65,12 +65,24 @@ func currentPeriod() (time.Time, time.Time) { } // RecordScan atomically increments the scan counter and checks quota. -// Returns remaining scans. Returns error if quota exceeded. +// Uses the free tier default limit (1000). For plan-aware quotas, use RecordScanWithLimit. func (t *UsageTracker) RecordScan(userID, ip string) (int, error) { + return t.RecordScanWithLimit(userID, ip, 1000) +} + +// RecordScanWithLimit atomically increments the scan counter and checks against planLimit. +// planLimit: -1=unlimited, 0=no scans allowed, >0=monthly cap. +// Returns remaining scans. Returns error if quota exceeded. +func (t *UsageTracker) RecordScanWithLimit(userID, ip string, planLimit int) (int, error) { if t.db == nil { return 999, nil // no DB = no limits } + // Plan explicitly forbids scanning + if planLimit == 0 { + return 0, fmt.Errorf("scanning not available on current plan") + } + t.mu.Lock() defer t.mu.Unlock() @@ -84,6 +96,12 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) { lookupVal = userID } + // Resolve effective limit for DB storage (unlimited = 0 sentinel in DB) + dbLimit := planLimit + if planLimit < 0 { + dbLimit = 0 // 0 in DB = unlimited + } + // Try to get existing quota record for current period var scansUsed, scansLimit int var quotaID string @@ -94,10 +112,14 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) { err := t.db.QueryRow(query, lookupVal, periodStart).Scan("aID, &scansUsed, &scansLimit) if err == sql.ErrNoRows { - // Create new quota record + // Create new quota record with plan-based limit quotaID = generateID("usg") plan := "free" - limit := 1000 + if planLimit > 1000 { + plan = "paid" + } else if planLimit < 0 { + plan = "unlimited" + } var insertQuery string if userID != "" { insertQuery = `INSERT INTO usage_quotas (id, user_id, plan, scans_used, scans_limit, period_start, period_end) @@ -106,12 +128,15 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) { insertQuery = `INSERT INTO usage_quotas (id, ip_addr, plan, scans_used, scans_limit, period_start, period_end) VALUES ($1, $2, $3, 1, $4, $5, $6)` } - _, err = t.db.Exec(insertQuery, quotaID, lookupVal, plan, limit, periodStart, periodEnd) + _, err = t.db.Exec(insertQuery, quotaID, lookupVal, plan, dbLimit, periodStart, periodEnd) if err != nil { slog.Error("usage: create quota", "error", err) return 999, nil // fail open — don't block on DB errors } - return limit - 1, nil + if planLimit < 0 { + return -1, nil // unlimited + } + return planLimit - 1, nil } if err != nil { @@ -119,7 +144,13 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) { return 999, nil // fail open } - // Unlimited plan (scans_limit = 0) + // Update stored limit if plan changed (e.g. upgrade mid-month) + if scansLimit != dbLimit { + t.db.Exec(`UPDATE usage_quotas SET scans_limit = $1 WHERE id = $2`, dbLimit, quotaID) + scansLimit = dbLimit + } + + // Unlimited plan (scans_limit = 0 in DB) if scansLimit == 0 { t.db.Exec(`UPDATE usage_quotas SET scans_used = scans_used + 1 WHERE id = $1`, quotaID) return -1, nil // unlimited @@ -127,7 +158,7 @@ func (t *UsageTracker) RecordScan(userID, ip string) (int, error) { // Check quota if scansUsed >= scansLimit { - return 0, fmt.Errorf("quota exceeded: %d/%d scans used this month", scansUsed, scansLimit) + return 0, fmt.Errorf("quota exceeded: %d/%d scans used this month — upgrade at syntrex.pro/pricing", scansUsed, scansLimit) } // Increment diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index f0495f2..d582766 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -184,100 +184,129 @@ func (s *Server) StartEventBridge(ctx context.Context) { slog.Info("event bridge started: EventBus → WSHub") } +// requireSOC wraps a handler to enforce SOC Dashboard plan access. +// Returns 403 for tenants on the Free plan (SOCEnabled=false). +// No-op when tenantStore is nil (backward compatible with tests). +func (s *Server) requireSOC(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.tenantStore == nil { + next(w, r) // no tenant store = no enforcement (tests, legacy) + return + } + claims := auth.GetClaims(r.Context()) + if claims == nil || claims.TenantID == "" { + // Unauthenticated or no tenant context — let RBAC/JWT handle it + next(w, r) + return + } + tenant, err := s.tenantStore.GetTenant(claims.TenantID) + if err != nil { + writeError(w, http.StatusForbidden, "tenant not found") + return + } + if !tenant.CanAccessSOC() { + writeError(w, http.StatusForbidden, + "SOC Dashboard requires Starter plan or above — upgrade at syntrex.pro/pricing") + return + } + next(w, r) + } +} + // Start begins listening on the configured port. Blocks until ctx is cancelled. func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() - // SOC API routes — read (requires Viewer role when RBAC enabled) - mux.HandleFunc("GET /api/soc/dashboard", s.rbac.Require(RoleViewer, s.handleDashboard)) - mux.HandleFunc("GET /api/soc/events", s.rbac.Require(RoleViewer, s.handleEvents)) - mux.HandleFunc("GET /api/soc/incidents", s.rbac.Require(RoleViewer, s.handleIncidents)) + // SOC API routes — read (requires Viewer role + SOC plan when RBAC/JWT enabled) + mux.HandleFunc("GET /api/soc/dashboard", s.rbac.Require(RoleViewer, s.requireSOC(s.handleDashboard))) + mux.HandleFunc("GET /api/soc/events", s.rbac.Require(RoleViewer, s.requireSOC(s.handleEvents))) + mux.HandleFunc("GET /api/soc/incidents", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidents))) // Sprint 2: Advanced incident management (must be before generic {id}) - mux.HandleFunc("GET /api/soc/incidents/advanced", s.rbac.Require(RoleViewer, s.handleIncidentsAdvanced)) - mux.HandleFunc("POST /api/soc/incidents/bulk", s.rbac.Require(RoleAnalyst, s.handleIncidentsBulk)) - mux.HandleFunc("GET /api/soc/incidents/export", s.rbac.Require(RoleViewer, s.handleIncidentsExport)) - mux.HandleFunc("GET /api/soc/sla-config", s.rbac.Require(RoleViewer, s.handleSLAConfig)) - mux.HandleFunc("GET /api/soc/incidents/{id}", s.rbac.Require(RoleViewer, s.handleIncidentDetail)) - mux.HandleFunc("GET /api/soc/incidents/{id}/sla", s.rbac.Require(RoleViewer, s.handleIncidentSLA)) - mux.HandleFunc("GET /api/soc/sensors", s.rbac.Require(RoleViewer, s.handleSensors)) - mux.HandleFunc("GET /api/soc/clusters", s.rbac.Require(RoleViewer, s.handleClusters)) - mux.HandleFunc("GET /api/soc/rules", s.rbac.Require(RoleViewer, s.handleRules)) - mux.HandleFunc("GET /api/soc/killchain/{id}", s.rbac.Require(RoleViewer, s.handleKillChain)) - mux.HandleFunc("GET /api/soc/stream", s.rbac.Require(RoleViewer, s.handleSSEStream)) - mux.HandleFunc("GET /api/soc/threat-intel", s.rbac.Require(RoleAnalyst, s.handleThreatIntel)) - mux.HandleFunc("GET /api/soc/webhook-stats", s.rbac.Require(RoleAnalyst, s.handleWebhookStats)) - mux.HandleFunc("GET /api/soc/analytics", s.rbac.Require(RoleViewer, s.handleAnalytics)) + mux.HandleFunc("GET /api/soc/incidents/advanced", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidentsAdvanced))) + mux.HandleFunc("POST /api/soc/incidents/bulk", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleIncidentsBulk))) + mux.HandleFunc("GET /api/soc/incidents/export", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidentsExport))) + mux.HandleFunc("GET /api/soc/sla-config", s.rbac.Require(RoleViewer, s.requireSOC(s.handleSLAConfig))) + mux.HandleFunc("GET /api/soc/incidents/{id}", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidentDetail))) + mux.HandleFunc("GET /api/soc/incidents/{id}/sla", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidentSLA))) + mux.HandleFunc("GET /api/soc/sensors", s.rbac.Require(RoleViewer, s.requireSOC(s.handleSensors))) + mux.HandleFunc("GET /api/soc/clusters", s.rbac.Require(RoleViewer, s.requireSOC(s.handleClusters))) + mux.HandleFunc("GET /api/soc/rules", s.rbac.Require(RoleViewer, s.requireSOC(s.handleRules))) + mux.HandleFunc("GET /api/soc/killchain/{id}", s.rbac.Require(RoleViewer, s.requireSOC(s.handleKillChain))) + mux.HandleFunc("GET /api/soc/stream", s.rbac.Require(RoleViewer, s.requireSOC(s.handleSSEStream))) + mux.HandleFunc("GET /api/soc/threat-intel", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleThreatIntel))) + mux.HandleFunc("GET /api/soc/webhook-stats", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleWebhookStats))) + mux.HandleFunc("GET /api/soc/analytics", s.rbac.Require(RoleViewer, s.requireSOC(s.handleAnalytics))) - // SOC API routes — write (requires Analyst/Sensor role when RBAC enabled) - mux.HandleFunc("POST /api/v1/soc/events", s.rbac.Require(RoleSensor, s.handleIngestEvent)) - mux.HandleFunc("POST /api/v1/soc/events/batch", s.rbac.Require(RoleSensor, s.handleBatchIngest)) - mux.HandleFunc("POST /api/soc/sensors/heartbeat", s.rbac.Require(RoleSensor, s.handleSensorHeartbeat)) - mux.HandleFunc("POST /api/soc/incidents/{id}/verdict", s.rbac.Require(RoleAnalyst, s.handleVerdict)) + // SOC API routes — write (requires Analyst/Sensor role + SOC plan when RBAC enabled) + mux.HandleFunc("POST /api/v1/soc/events", s.rbac.Require(RoleSensor, s.requireSOC(s.handleIngestEvent))) + mux.HandleFunc("POST /api/v1/soc/events/batch", s.rbac.Require(RoleSensor, s.requireSOC(s.handleBatchIngest))) + mux.HandleFunc("POST /api/soc/sensors/heartbeat", s.rbac.Require(RoleSensor, s.requireSOC(s.handleSensorHeartbeat))) + mux.HandleFunc("POST /api/soc/incidents/{id}/verdict", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleVerdict))) // Case Management (SOAR §P3) - mux.HandleFunc("POST /api/soc/incidents/{id}/assign", s.rbac.Require(RoleAnalyst, s.handleIncidentAssign)) - mux.HandleFunc("POST /api/soc/incidents/{id}/status", s.rbac.Require(RoleAnalyst, s.handleIncidentStatus)) - mux.HandleFunc("GET /api/soc/incidents/{id}/notes", s.rbac.Require(RoleViewer, s.handleIncidentNotes)) - mux.HandleFunc("POST /api/soc/incidents/{id}/notes", s.rbac.Require(RoleAnalyst, s.handleIncidentNotes)) - mux.HandleFunc("GET /api/soc/incidents/{id}/timeline", s.rbac.Require(RoleViewer, s.handleIncidentTimeline)) - mux.HandleFunc("GET /api/soc/incidents/{id}/detail", s.rbac.Require(RoleViewer, s.handleIncidentFullDetail)) + mux.HandleFunc("POST /api/soc/incidents/{id}/assign", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleIncidentAssign))) + mux.HandleFunc("POST /api/soc/incidents/{id}/status", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleIncidentStatus))) + mux.HandleFunc("GET /api/soc/incidents/{id}/notes", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidentNotes))) + mux.HandleFunc("POST /api/soc/incidents/{id}/notes", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleIncidentNotes))) + mux.HandleFunc("GET /api/soc/incidents/{id}/timeline", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidentTimeline))) + mux.HandleFunc("GET /api/soc/incidents/{id}/detail", s.rbac.Require(RoleViewer, s.requireSOC(s.handleIncidentFullDetail))) // Webhook Management (SOAR §15) - mux.HandleFunc("GET /api/soc/webhooks", s.rbac.Require(RoleAnalyst, s.handleWebhooksGet)) - mux.HandleFunc("POST /api/soc/webhooks", s.rbac.Require(RoleAdmin, s.handleWebhooksSet)) - mux.HandleFunc("POST /api/soc/webhooks/test", s.rbac.Require(RoleAdmin, s.handleWebhooksTest)) - mux.HandleFunc("POST /api/soc/sensors/register", s.rbac.Require(RoleAdmin, s.handleSensorRegister)) - mux.HandleFunc("DELETE /api/soc/sensors/{id}", s.rbac.Require(RoleAdmin, s.handleSensorDelete)) + mux.HandleFunc("GET /api/soc/webhooks", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleWebhooksGet))) + mux.HandleFunc("POST /api/soc/webhooks", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleWebhooksSet))) + mux.HandleFunc("POST /api/soc/webhooks/test", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleWebhooksTest))) + mux.HandleFunc("POST /api/soc/sensors/register", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleSensorRegister))) + mux.HandleFunc("DELETE /api/soc/sensors/{id}", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleSensorDelete))) - // Admin routes (§9, §17) - mux.HandleFunc("GET /api/soc/audit", s.rbac.Require(RoleAdmin, s.handleAuditTrail)) - mux.HandleFunc("GET /api/soc/keys", s.rbac.Require(RoleAdmin, s.handleListKeys)) + // Admin routes (§9, §17) — require SOC plan + mux.HandleFunc("GET /api/soc/audit", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleAuditTrail))) + mux.HandleFunc("GET /api/soc/keys", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleListKeys))) - // Zero-G Mode routes (§13.4) - mux.HandleFunc("GET /api/soc/zerog", s.rbac.Require(RoleAnalyst, s.handleZeroGStatus)) - mux.HandleFunc("POST /api/soc/zerog/toggle", s.rbac.Require(RoleAdmin, s.handleZeroGToggle)) - mux.HandleFunc("POST /api/soc/zerog/resolve", s.rbac.Require(RoleAnalyst, s.handleZeroGResolve)) + // Zero-G Mode routes (§13.4) — require SOC plan + mux.HandleFunc("GET /api/soc/zerog", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleZeroGStatus))) + mux.HandleFunc("POST /api/soc/zerog/toggle", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleZeroGToggle))) + mux.HandleFunc("POST /api/soc/zerog/resolve", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleZeroGResolve))) - // P2P SOC Sync routes (§14) - mux.HandleFunc("GET /api/soc/p2p/peers", s.rbac.Require(RoleAnalyst, s.handleP2PPeers)) - mux.HandleFunc("POST /api/soc/p2p/peers", s.rbac.Require(RoleAdmin, s.handleP2PAddPeer)) - mux.HandleFunc("DELETE /api/soc/p2p/peers/{id}", s.rbac.Require(RoleAdmin, s.handleP2PRemovePeer)) + // P2P SOC Sync routes (§14) — require SOC plan + mux.HandleFunc("GET /api/soc/p2p/peers", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleP2PPeers))) + mux.HandleFunc("POST /api/soc/p2p/peers", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleP2PAddPeer))) + mux.HandleFunc("DELETE /api/soc/p2p/peers/{id}", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleP2PRemovePeer))) - // Engine & Sovereign routes (§3, §4, §21) - mux.HandleFunc("GET /api/soc/engines", s.rbac.Require(RoleViewer, s.handleEngineStatus)) - mux.HandleFunc("GET /api/soc/sovereign", s.rbac.Require(RoleAdmin, s.handleSovereignConfig)) + // Engine & Sovereign routes (§3, §4, §21) — require SOC plan + mux.HandleFunc("GET /api/soc/engines", s.rbac.Require(RoleViewer, s.requireSOC(s.handleEngineStatus))) + mux.HandleFunc("GET /api/soc/sovereign", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleSovereignConfig))) - // Anomaly detection (§5) + Playbook engine (§10) - mux.HandleFunc("GET /api/soc/anomaly/alerts", s.rbac.Require(RoleAnalyst, s.handleAnomalyAlerts)) - mux.HandleFunc("GET /api/soc/anomaly/baselines", s.rbac.Require(RoleAnalyst, s.handleAnomalyBaselines)) - mux.HandleFunc("GET /api/soc/playbooks", s.rbac.Require(RoleViewer, s.handlePlaybooks)) + // Anomaly detection (§5) + Playbook engine (§10) — require SOC plan + mux.HandleFunc("GET /api/soc/anomaly/alerts", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleAnomalyAlerts))) + mux.HandleFunc("GET /api/soc/anomaly/baselines", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleAnomalyBaselines))) + mux.HandleFunc("GET /api/soc/playbooks", s.rbac.Require(RoleViewer, s.requireSOC(s.handlePlaybooks))) - // Live updates — WebSocket-style SSE push (§20) - mux.HandleFunc("GET /api/soc/ws", s.rbac.Require(RoleViewer, s.wsHub.HandleSSEStream)) + // Live updates — WebSocket-style SSE push (§20) — require SOC plan + mux.HandleFunc("GET /api/soc/ws", s.rbac.Require(RoleViewer, s.requireSOC(s.wsHub.HandleSSEStream))) - // Deep health, compliance, audit, explainability (§12, §15) - mux.HandleFunc("GET /api/soc/health/deep", s.rbac.Require(RoleViewer, s.handleDeepHealth)) - mux.HandleFunc("GET /api/soc/compliance", s.rbac.Require(RoleAdmin, s.handleComplianceReport)) - mux.HandleFunc("GET /api/soc/audit/trail", s.rbac.Require(RoleAnalyst, s.handleAuditTrailPage)) - mux.HandleFunc("GET /api/soc/incidents/{id}/explain", s.rbac.Require(RoleAnalyst, s.handleIncidentExplain)) + // Deep health, compliance, audit, explainability (§12, §15) — require SOC plan + mux.HandleFunc("GET /api/soc/health/deep", s.rbac.Require(RoleViewer, s.requireSOC(s.handleDeepHealth))) + mux.HandleFunc("GET /api/soc/compliance", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleComplianceReport))) + mux.HandleFunc("GET /api/soc/audit/trail", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleAuditTrailPage))) + mux.HandleFunc("GET /api/soc/incidents/{id}/explain", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleIncidentExplain))) - // Threat intel matching (§6) + Data retention (§19) - mux.HandleFunc("POST /api/soc/threat-intel/match", s.rbac.Require(RoleAnalyst, s.handleThreatIntelMatch)) - mux.HandleFunc("GET /api/soc/retention", s.rbac.Require(RoleAdmin, s.handleRetentionPolicies)) + // Threat intel matching (§6) + Data retention (§19) — require SOC plan + mux.HandleFunc("POST /api/soc/threat-intel/match", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleThreatIntelMatch))) + mux.HandleFunc("GET /api/soc/retention", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleRetentionPolicies))) - // Shadow AI Control Module routes (§Shadow AI ТЗ) - mux.HandleFunc("GET /api/v1/shadow-ai/stats", s.rbac.Require(RoleViewer, s.handleShadowAIStats)) - mux.HandleFunc("GET /api/v1/shadow-ai/events", s.rbac.Require(RoleViewer, s.handleShadowAIEvents)) - mux.HandleFunc("GET /api/v1/shadow-ai/events/{id}", s.rbac.Require(RoleViewer, s.handleShadowAIEventDetail)) - mux.HandleFunc("POST /api/v1/shadow-ai/block", s.rbac.Require(RoleAnalyst, s.handleShadowAIBlock)) - mux.HandleFunc("POST /api/v1/shadow-ai/unblock", s.rbac.Require(RoleAnalyst, s.handleShadowAIUnblock)) - mux.HandleFunc("POST /api/v1/shadow-ai/scan", s.rbac.Require(RoleAnalyst, s.handleShadowAIScan)) - mux.HandleFunc("GET /api/v1/shadow-ai/integrations", s.rbac.Require(RoleViewer, s.handleShadowAIIntegrations)) - mux.HandleFunc("GET /api/v1/shadow-ai/integrations/{vendor}/health", s.rbac.Require(RoleViewer, s.handleShadowAIVendorHealth)) - mux.HandleFunc("GET /api/v1/shadow-ai/compliance", s.rbac.Require(RoleAdmin, s.handleShadowAICompliance)) - mux.HandleFunc("POST /api/v1/shadow-ai/doc-review", s.rbac.Require(RoleAnalyst, s.handleShadowAIDocReview)) - mux.HandleFunc("GET /api/v1/shadow-ai/doc-review/{id}", s.rbac.Require(RoleViewer, s.handleShadowAIDocReviewStatus)) - mux.HandleFunc("GET /api/v1/shadow-ai/approvals", s.rbac.Require(RoleAnalyst, s.handleShadowAIPendingApprovals)) - mux.HandleFunc("GET /api/v1/shadow-ai/approvals/tiers", s.rbac.Require(RoleViewer, s.handleShadowAIApprovalTiers)) - mux.HandleFunc("POST /api/v1/shadow-ai/approvals/{id}/verdict", s.rbac.Require(RoleAnalyst, s.handleShadowAIApprovalVerdict)) + // Shadow AI Control Module routes (§Shadow AI ТЗ) — require SOC plan + mux.HandleFunc("GET /api/v1/shadow-ai/stats", s.rbac.Require(RoleViewer, s.requireSOC(s.handleShadowAIStats))) + mux.HandleFunc("GET /api/v1/shadow-ai/events", s.rbac.Require(RoleViewer, s.requireSOC(s.handleShadowAIEvents))) + mux.HandleFunc("GET /api/v1/shadow-ai/events/{id}", s.rbac.Require(RoleViewer, s.requireSOC(s.handleShadowAIEventDetail))) + mux.HandleFunc("POST /api/v1/shadow-ai/block", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleShadowAIBlock))) + mux.HandleFunc("POST /api/v1/shadow-ai/unblock", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleShadowAIUnblock))) + mux.HandleFunc("POST /api/v1/shadow-ai/scan", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleShadowAIScan))) + mux.HandleFunc("GET /api/v1/shadow-ai/integrations", s.rbac.Require(RoleViewer, s.requireSOC(s.handleShadowAIIntegrations))) + mux.HandleFunc("GET /api/v1/shadow-ai/integrations/{vendor}/health", s.rbac.Require(RoleViewer, s.requireSOC(s.handleShadowAIVendorHealth))) + mux.HandleFunc("GET /api/v1/shadow-ai/compliance", s.rbac.Require(RoleAdmin, s.requireSOC(s.handleShadowAICompliance))) + mux.HandleFunc("POST /api/v1/shadow-ai/doc-review", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleShadowAIDocReview))) + mux.HandleFunc("GET /api/v1/shadow-ai/doc-review/{id}", s.rbac.Require(RoleViewer, s.requireSOC(s.handleShadowAIDocReviewStatus))) + mux.HandleFunc("GET /api/v1/shadow-ai/approvals", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleShadowAIPendingApprovals))) + mux.HandleFunc("GET /api/v1/shadow-ai/approvals/tiers", s.rbac.Require(RoleViewer, s.requireSOC(s.handleShadowAIApprovalTiers))) + mux.HandleFunc("POST /api/v1/shadow-ai/approvals/{id}/verdict", s.rbac.Require(RoleAnalyst, s.requireSOC(s.handleShadowAIApprovalVerdict))) // Observability — always public (unauthenticated, K8s probes) mux.HandleFunc("GET /health", s.handleHealth) @@ -286,7 +315,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /metrics", s.metrics.Handler()) mux.HandleFunc("GET /api/soc/ratelimit", s.handleRateLimitStats) - // Public scan endpoint — demo scanner (no auth required, rate-limited) + // Public scan endpoint — demo scanner (no auth, rate-limited, plan-aware quota) mux.HandleFunc("POST /api/v1/scan", s.handlePublicScan) // Usage endpoint — returns scan quota for caller mux.HandleFunc("GET /api/v1/usage", s.handleUsage) @@ -306,6 +335,7 @@ func (s *Server) Start(ctx context.Context) error { if s.jwtAuth != nil { loginLimiter := auth.NewRateLimiter(5, time.Minute) mux.HandleFunc("POST /api/auth/login", auth.RateLimitMiddleware(loginLimiter, auth.HandleLogin(s.userStore, s.jwtSecret))) + mux.HandleFunc("POST /api/auth/logout", auth.HandleLogout()) mux.HandleFunc("POST /api/auth/refresh", auth.HandleRefresh(s.jwtSecret)) // Auth routes — require authentication mux.HandleFunc("GET /api/auth/me", auth.HandleMe(s.userStore)) diff --git a/internal/transport/http/soc_handlers.go b/internal/transport/http/soc_handlers.go index d14bbd2..7365caa 100644 --- a/internal/transport/http/soc_handlers.go +++ b/internal/transport/http/soc_handlers.go @@ -27,10 +27,19 @@ func limitBody(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodySize) } +// tenantFromRequest extracts TenantID from JWT claims in request context. +// Returns empty string for unauthenticated/public requests (backward compatible). +func tenantFromRequest(r *http.Request) string { + if claims := auth.GetClaims(r.Context()); claims != nil { + return claims.TenantID + } + return "" +} + // handleDashboard returns SOC KPI metrics. // GET /api/soc/dashboard func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { - dash, err := s.socSvc.Dashboard() + dash, err := s.socSvc.Dashboard(tenantFromRequest(r)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -52,7 +61,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { limit = 10000 } - events, err := s.socSvc.ListEvents(limit) + events, err := s.socSvc.ListEvents(tenantFromRequest(r), limit) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -76,7 +85,7 @@ func (s *Server) handleIncidents(w http.ResponseWriter, r *http.Request) { } } - incidents, err := s.socSvc.ListIncidents(status, limit) + incidents, err := s.socSvc.ListIncidents(tenantFromRequest(r), status, limit) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -122,8 +131,8 @@ func (s *Server) handleReadyz(w http.ResponseWriter, _ *http.Request) { } // handleSensors returns registered sensors with health status. // GET /api/soc/sensors -func (s *Server) handleSensors(w http.ResponseWriter, _ *http.Request) { - sensors, err := s.socSvc.ListSensors() +func (s *Server) handleSensors(w http.ResponseWriter, r *http.Request) { + sensors, err := s.socSvc.ListSensors(tenantFromRequest(r)) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -1132,7 +1141,7 @@ func (s *Server) handleDeepHealth(w http.ResponseWriter, r *http.Request) { // handleComplianceReport returns EU AI Act Article 15 compliance summary (§12.3). // GET /api/soc/compliance func (s *Server) handleComplianceReport(w http.ResponseWriter, r *http.Request) { - dash, err := s.socSvc.Dashboard() + dash, err := s.socSvc.Dashboard(tenantFromRequest(r)) if err != nil { writeError(w, http.StatusInternalServerError, "compliance: dashboard unavailable: "+err.Error()) return @@ -1216,8 +1225,9 @@ func (s *Server) handleAuditTrailPage(w http.ResponseWriter, r *http.Request) { } } } - events, _ := s.socSvc.ListEvents(limit) - incidents, _ := s.socSvc.ListIncidents("", 50) + tenantID := tenantFromRequest(r) + events, _ := s.socSvc.ListEvents(tenantID, limit) + incidents, _ := s.socSvc.ListIncidents(tenantID, "", 50) // Build audit entries from events entries := make([]map[string]any, 0, len(events)) diff --git a/internal/transport/mcpserver/soc_tools.go b/internal/transport/mcpserver/soc_tools.go index cd707bf..4d82119 100644 --- a/internal/transport/mcpserver/soc_tools.go +++ b/internal/transport/mcpserver/soc_tools.go @@ -153,7 +153,7 @@ func (s *Server) handleSOCEvents(_ context.Context, req mcp.CallToolRequest) (*m } limit := req.GetInt("limit", 20) - events, err := s.socSvc.ListEvents(limit) + events, err := s.socSvc.ListEvents("", limit) // MCP: global view if err != nil { return errorResult(err), nil } @@ -167,7 +167,7 @@ func (s *Server) handleSOCIncidents(_ context.Context, req mcp.CallToolRequest) status := req.GetString("status", "") limit := req.GetInt("limit", 20) - incidents, err := s.socSvc.ListIncidents(status, limit) + incidents, err := s.socSvc.ListIncidents("", status, limit) // MCP: global view if err != nil { return errorResult(err), nil } @@ -207,7 +207,7 @@ func (s *Server) handleSOCSensors(_ context.Context, _ mcp.CallToolRequest) (*mc return errorResult(fmt.Errorf("soc service not configured")), nil } - sensors, err := s.socSvc.ListSensors() + sensors, err := s.socSvc.ListSensors("") // MCP: global view if err != nil { return errorResult(err), nil } @@ -219,7 +219,7 @@ func (s *Server) handleSOCDashboard(_ context.Context, _ mcp.CallToolRequest) (* return errorResult(fmt.Errorf("soc service not configured")), nil } - dashboard, err := s.socSvc.Dashboard() + dashboard, err := s.socSvc.Dashboard("") // MCP: global view if err != nil { return errorResult(err), nil }