mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-06-08 14:55:13 +02:00
chore: Apply dashboard audit remediations, sync engine counts, update APIs
This commit is contained in:
parent
53c87c972d
commit
5ddfa74771
14 changed files with 354 additions and 153 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
15
internal/domain/engines/ffi_sentinel_stub.go
Normal file
15
internal/domain/engines/ffi_sentinel_stub.go
Normal 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")
|
||||
}
|
||||
15
internal/domain/engines/ffi_shield_stub.go
Normal file
15
internal/domain/engines/ffi_shield_stub.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue