mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-25 20:36:21 +02:00
354 lines
10 KiB
Go
354 lines
10 KiB
Go
package auth
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Standard tenant errors.
|
|
var (
|
|
ErrTenantNotFound = errors.New("auth: tenant not found")
|
|
ErrTenantExists = errors.New("auth: tenant already exists")
|
|
ErrQuotaExceeded = errors.New("auth: plan quota exceeded")
|
|
)
|
|
|
|
// Plan represents a subscription tier with resource limits.
|
|
type Plan struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
MaxUsers int `json:"max_users"`
|
|
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"`
|
|
OnPremise bool `json:"on_premise"` // Enterprise: on-premise deployment
|
|
PriceMonthCents int `json:"price_month_cents"` // 0 = free, -1 = custom pricing
|
|
}
|
|
|
|
// 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,
|
|
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,
|
|
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,
|
|
MaxScansMonth: -1, // unlimited
|
|
RetentionDays: 365,
|
|
SOCEnabled: true, SLAEnabled: true, SOAREnabled: true, ComplianceEnabled: true,
|
|
OnPremise: true,
|
|
PriceMonthCents: -1, // по запросу
|
|
},
|
|
}
|
|
|
|
// Tenant represents an isolated organization in the multi-tenant system.
|
|
type Tenant struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
PlanID string `json:"plan_id"`
|
|
PaymentCustomerID string `json:"payment_customer_id,omitempty"`
|
|
PaymentSubID string `json:"payment_sub_id,omitempty"`
|
|
OwnerUserID string `json:"owner_user_id"`
|
|
Active bool `json:"active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
EventsThisMonth int `json:"events_this_month"`
|
|
MonthResetAt time.Time `json:"month_reset_at"`
|
|
}
|
|
|
|
// GetPlan returns the tenant's plan configuration.
|
|
func (t *Tenant) GetPlan() Plan {
|
|
if p, ok := DefaultPlans[t.PlanID]; ok {
|
|
return p
|
|
}
|
|
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.
|
|
func (t *Tenant) CanIngestEvent() bool {
|
|
plan := t.GetPlan()
|
|
if plan.MaxEventsMonth < 0 {
|
|
return true // unlimited
|
|
}
|
|
return t.EventsThisMonth < plan.MaxEventsMonth
|
|
}
|
|
|
|
// TenantStore manages tenant records backed by SQLite.
|
|
type TenantStore struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
tenants map[string]*Tenant // id -> Tenant
|
|
}
|
|
|
|
// NewTenantStore creates a tenant store.
|
|
func NewTenantStore(db *sql.DB) *TenantStore {
|
|
s := &TenantStore{
|
|
db: db,
|
|
tenants: make(map[string]*Tenant),
|
|
}
|
|
if db != nil {
|
|
if err := s.migrate(); err != nil {
|
|
slog.Error("tenant store: migration failed", "error", err)
|
|
} else {
|
|
s.loadFromDB()
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *TenantStore) migrate() error {
|
|
_, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS tenants (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
plan_id TEXT NOT NULL DEFAULT 'free',
|
|
stripe_customer_id TEXT DEFAULT '',
|
|
stripe_sub_id TEXT DEFAULT '',
|
|
owner_user_id TEXT NOT NULL,
|
|
active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
events_this_month INTEGER NOT NULL DEFAULT 0,
|
|
month_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add tenant_id column to users if missing
|
|
_, _ = s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id TEXT DEFAULT ''`)
|
|
return nil
|
|
}
|
|
|
|
func (s *TenantStore) loadFromDB() {
|
|
rows, err := s.db.Query(`SELECT id, name, slug, plan_id, stripe_customer_id, stripe_sub_id,
|
|
owner_user_id, active, created_at, events_this_month, month_reset_at FROM tenants`)
|
|
if err != nil {
|
|
slog.Error("load tenants from DB", "error", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for rows.Next() {
|
|
var t Tenant
|
|
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.PlanID, &t.PaymentCustomerID,
|
|
&t.PaymentSubID, &t.OwnerUserID, &t.Active, &t.CreatedAt, &t.EventsThisMonth, &t.MonthResetAt); err != nil {
|
|
slog.Warn("load tenant row scan", "error", err)
|
|
continue
|
|
}
|
|
s.tenants[t.ID] = &t
|
|
}
|
|
slog.Info("tenants loaded from DB", "count", len(s.tenants))
|
|
}
|
|
|
|
func (s *TenantStore) persistTenant(t *Tenant) {
|
|
if s.db == nil {
|
|
return
|
|
}
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO tenants (id, name, slug, plan_id, stripe_customer_id, stripe_sub_id,
|
|
owner_user_id, active, created_at, events_this_month, month_reset_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
slug = EXCLUDED.slug,
|
|
plan_id = EXCLUDED.plan_id,
|
|
stripe_customer_id = EXCLUDED.stripe_customer_id,
|
|
stripe_sub_id = EXCLUDED.stripe_sub_id,
|
|
active = EXCLUDED.active,
|
|
events_this_month = EXCLUDED.events_this_month,
|
|
month_reset_at = EXCLUDED.month_reset_at`,
|
|
t.ID, t.Name, t.Slug, t.PlanID, t.PaymentCustomerID, t.PaymentSubID,
|
|
t.OwnerUserID, t.Active, t.CreatedAt,
|
|
t.EventsThisMonth, t.MonthResetAt,
|
|
)
|
|
if err != nil {
|
|
slog.Error("persist tenant", "id", t.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
// CreateTenant creates a new tenant and assigns an owner.
|
|
func (s *TenantStore) CreateTenant(name, slug, ownerUserID, planID string) (*Tenant, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for _, t := range s.tenants {
|
|
if t.Slug == slug {
|
|
return nil, ErrTenantExists
|
|
}
|
|
}
|
|
|
|
if _, ok := DefaultPlans[planID]; !ok {
|
|
planID = "starter"
|
|
}
|
|
|
|
t := &Tenant{
|
|
ID: generateID("tnt"),
|
|
Name: name,
|
|
Slug: slug,
|
|
PlanID: planID,
|
|
OwnerUserID: ownerUserID,
|
|
Active: true,
|
|
CreatedAt: time.Now(),
|
|
EventsThisMonth: 0,
|
|
MonthResetAt: monthStart(time.Now().AddDate(0, 1, 0)),
|
|
}
|
|
|
|
s.tenants[t.ID] = t
|
|
go s.persistTenant(t)
|
|
return t, nil
|
|
}
|
|
|
|
// GetTenant returns a tenant by ID.
|
|
func (s *TenantStore) GetTenant(id string) (*Tenant, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
t, ok := s.tenants[id]
|
|
if !ok {
|
|
return nil, ErrTenantNotFound
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// GetTenantBySlug returns a tenant by slug.
|
|
func (s *TenantStore) GetTenantBySlug(slug string) (*Tenant, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, t := range s.tenants {
|
|
if t.Slug == slug {
|
|
return t, nil
|
|
}
|
|
}
|
|
return nil, ErrTenantNotFound
|
|
}
|
|
|
|
// ListTenants returns all tenants.
|
|
func (s *TenantStore) ListTenants() []*Tenant {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
result := make([]*Tenant, 0, len(s.tenants))
|
|
for _, t := range s.tenants {
|
|
result = append(result, t)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// UpdatePlan changes a tenant's plan.
|
|
func (s *TenantStore) UpdatePlan(tenantID, planID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tenants[tenantID]
|
|
if !ok {
|
|
return ErrTenantNotFound
|
|
}
|
|
if _, valid := DefaultPlans[planID]; !valid {
|
|
return fmt.Errorf("auth: unknown plan %q", planID)
|
|
}
|
|
t.PlanID = planID
|
|
go s.persistTenant(t)
|
|
return nil
|
|
}
|
|
|
|
// SetStripeIDs saves Stripe customer + subscription IDs.
|
|
func (s *TenantStore) SetStripeIDs(tenantID, customerID, subID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tenants[tenantID]
|
|
if !ok {
|
|
return ErrTenantNotFound
|
|
}
|
|
t.PaymentCustomerID = customerID
|
|
t.PaymentSubID = subID
|
|
go s.persistTenant(t)
|
|
return nil
|
|
}
|
|
|
|
// IncrementEvents increments the monthly event counter. Returns error if quota exceeded.
|
|
func (s *TenantStore) IncrementEvents(tenantID string, count int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tenants[tenantID]
|
|
if !ok {
|
|
return ErrTenantNotFound
|
|
}
|
|
|
|
// Auto-reset if past the reset date
|
|
if time.Now().After(t.MonthResetAt) {
|
|
t.EventsThisMonth = 0
|
|
t.MonthResetAt = monthStart(time.Now().AddDate(0, 1, 0))
|
|
}
|
|
|
|
plan := t.GetPlan()
|
|
if plan.MaxEventsMonth >= 0 && t.EventsThisMonth+count > plan.MaxEventsMonth {
|
|
return ErrQuotaExceeded
|
|
}
|
|
|
|
t.EventsThisMonth += count
|
|
go s.persistTenant(t)
|
|
return nil
|
|
}
|
|
|
|
// DeactivateTenant marks a tenant as inactive (subscription cancelled).
|
|
func (s *TenantStore) DeactivateTenant(tenantID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tenants[tenantID]
|
|
if !ok {
|
|
return ErrTenantNotFound
|
|
}
|
|
t.Active = false
|
|
go s.persistTenant(t)
|
|
return nil
|
|
}
|
|
|
|
func monthStart(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
|
}
|