// Изменено: 2026-04-10 — добавлена Redis-персистентность через пакет persistence // Tenant model и in-memory хранилище тенантов для shared-sqs. package tenant import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "sync" "time" "shared-sqs/app/persistence" ) // MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12) const MaxTenantsGlobal = 1000 // Tenant — модель тенанта shared-sqs. // AccessKey используется как идентификатор в AWS Authorization header. type Tenant struct { ID string // уникальный идентификатор тенанта (sless- из JWT sub, или t- для legacy) Name string // человекочитаемое имя AccessKey string // аналог AWS AccessKeyId (SSAK-) SecretKey string // аналог AWS SecretAccessKey (64 hex chars) MaxQueues int // лимит очередей (0 = безлимит) CreatedAt time.Time Active bool NubesSub string `json:"nubes_sub,omitempty"` // JWT sub claim (UUID пользователя nubes). Пусто для legacy тенантов. Email string `json:"email,omitempty"` // Email из JWT. Для отображения в UI. } // TenantStore — потокобезопасное in-memory хранилище тенантов. // Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth). type TenantStore struct { mu sync.RWMutex byID map[string]*Tenant byAccessKey map[string]*Tenant bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim) } // NewTenantStore — создаёт пустое хранилище тенантов. func NewTenantStore() *TenantStore { return &TenantStore{ byID: make(map[string]*Tenant), byAccessKey: make(map[string]*Tenant), bySub: make(map[string]*Tenant), } } // Create — создаёт нового тенанта, генерирует ключи, сохраняет в оба индекса. // Fix #12: глобальный лимит тенантов — защита от OOM при массовом создании. func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { s.mu.RLock() tenantCount := len(s.byID) s.mu.RUnlock() if tenantCount >= MaxTenantsGlobal { return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal) } id, err := generateTenantID() if err != nil { return nil, fmt.Errorf("generate tenant id: %w", err) } accessKey, err := generateAccessKey() if err != nil { return nil, fmt.Errorf("generate access key: %w", err) } secretKey, err := generateSecretKey() if err != nil { return nil, fmt.Errorf("generate secret key: %w", err) } t := &Tenant{ ID: id, Name: name, AccessKey: accessKey, SecretKey: secretKey, MaxQueues: maxQueues, CreatedAt: time.Now().UTC(), Active: true, } s.mu.Lock() s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t if t.NubesSub != "" { s.bySub[t.NubesSub] = t } s.mu.Unlock() // Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса if data, err := json.Marshal(t); err == nil { persistence.SaveTenantRaw(t.ID, data) } return t, nil } // CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo). // Используется только при инициализации демо-данных; не вызывается из user-facing API. func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) { s.mu.Lock() if _, exists := s.byID[tenantID]; exists { s.mu.Unlock() return nil, fmt.Errorf("tenant with id %s already exists", tenantID) } if _, exists := s.byAccessKey[accessKey]; exists { s.mu.Unlock() return nil, fmt.Errorf("tenant with access key %s already exists", accessKey) } t := &Tenant{ ID: tenantID, Name: name, AccessKey: accessKey, SecretKey: secretKey, MaxQueues: maxQueues, CreatedAt: time.Now().UTC(), Active: true, } s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t s.mu.Unlock() // Сохраняем в Redis асинхронно — seed-данные тоже персистируем if data, err := json.Marshal(t); err == nil { persistence.SaveTenantRaw(t.ID, data) } return t, nil } // GetByAccessKey — поиск тенанта по AccessKeyId (используется в auth middleware). func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) { s.mu.RLock() t, ok := s.byAccessKey[accessKey] s.mu.RUnlock() return t, ok } // GetByID — поиск тенанта по ID (используется в admin API). func (s *TenantStore) GetByID(id string) (*Tenant, bool) { s.mu.RLock() t, ok := s.byID[id] s.mu.RUnlock() return t, ok } // Delete — удаляет тенанта из ОБОИХ индексов. // Ловушка #2: если удалить только из одного индекса — orphaned данные и memory leak. func (s *TenantStore) Delete(id string) bool { s.mu.Lock() t, ok := s.byID[id] if !ok { s.mu.Unlock() return false } delete(s.byID, t.ID) delete(s.byAccessKey, t.AccessKey) if t.NubesSub != "" { delete(s.bySub, t.NubesSub) } s.mu.Unlock() // Удаляем из Redis асинхронно persistence.DeleteTenant(id) return true } // LoadTenant — добавляет тенанта в хранилище без сохранения в Redis. // Используется ТОЛЬКО при старте сервиса для восстановления состояния из Redis. // Не вызывать из user-facing кода — нет дедупликации ключей. func (s *TenantStore) LoadTenant(t *Tenant) { s.mu.Lock() s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t if t.NubesSub != "" { s.bySub[t.NubesSub] = t } s.mu.Unlock() } // GetBySub — поиск тенанта по NubesSub (JWT sub claim). // Используется при JWT-авторизации через UI. func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) { s.mu.RLock() t, ok := s.bySub[sub] s.mu.RUnlock() return t, ok } // CreateFromJWT — auto-provisioning тенанта из JWT claims. // ID = TenantIDFromSub(sub) — совместим с sless namespace. // Если тенант с таким sub уже существует — возвращает его (идемпотентно). func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) { s.mu.Lock() // Идемпотентность: если тенант с таким sub уже есть — возвращаем if existing, ok := s.bySub[sub]; ok { // Обновляем email если изменился if email != "" && existing.Email != email { existing.Email = email } s.mu.Unlock() return existing, nil } if len(s.byID) >= MaxTenantsGlobal { s.mu.Unlock() return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal) } accessKey, err := generateAccessKey() if err != nil { s.mu.Unlock() return nil, fmt.Errorf("generate access key: %w", err) } secretKey, err := generateSecretKey() if err != nil { s.mu.Unlock() return nil, fmt.Errorf("generate secret key: %w", err) } // Имя тенанта — email или sub (если email пустой) name := email if name == "" { name = sub } t := &Tenant{ ID: tenantID, Name: name, AccessKey: accessKey, SecretKey: secretKey, MaxQueues: maxQueues, CreatedAt: time.Now().UTC(), Active: true, NubesSub: sub, Email: email, } s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t s.bySub[t.NubesSub] = t s.mu.Unlock() // Сохраняем в Redis if data, err := json.Marshal(t); err == nil { persistence.SaveTenantRaw(t.ID, data) } return t, nil } // List — список всех тенантов (для admin GET /tenants). func (s *TenantStore) List() []*Tenant { s.mu.RLock() result := make([]*Tenant, 0, len(s.byID)) for _, t := range s.byID { result = append(result, t) } s.mu.RUnlock() return result } // generateTenantID — генерирует уникальный ID тенанта в формате t-<12 hex bytes>. func generateTenantID() (string, error) { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { return "", err } return "t-" + hex.EncodeToString(b), nil } // generateAccessKey — генерирует AccessKey в формате SSAK-<12 hex bytes>. // SSAK = Shared SQS Access Key. Используем crypto/rand (ловушка #1: не math/rand). func generateAccessKey() (string, error) { b := make([]byte, 12) if _, err := rand.Read(b); err != nil { return "", err } return "SSAK-" + hex.EncodeToString(b), nil } // generateSecretKey — генерирует SecretKey как 64 hex символа (32 random bytes). // Используем crypto/rand (ловушка #1). func generateSecretKey() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }