gomcp/internal/infrastructure/auth/tenants.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())
}