chore: Apply dashboard audit remediations, sync engine counts, update APIs

This commit is contained in:
DmitrL-dev 2026-03-27 16:54:18 +10:00
parent 53c87c972d
commit 5ddfa74771
14 changed files with 354 additions and 153 deletions

View file

@ -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
}

View file

@ -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())

View file

@ -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")
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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 {

View file

@ -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 <token>)")
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,

View file

@ -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)

View file

@ -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.

View file

@ -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(&quotaID, &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

View file

@ -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))

View file

@ -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))

View file

@ -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
}