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

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