// Изменено: 2026-04-10 — добавлена Redis-персистентность через пакет persistence // Tenant model и in-memory хранилище тенантов для shared-sqs. package tenant import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "strings" "sync" "time" "shared-sqs/app/persistence" ) // MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12) const MaxTenantsGlobal = 1000 // DefaultTenantMaxQueues — лимит очередей для JWT-тенантов (auto-provisioning). // Используется в app/admin при логине; при каждом логине лимит существующего // тенанта обновляется до этого значения (см. CreateFromJWT). const DefaultTenantMaxQueues = 50 // Tenant — модель тенанта shared-sqs. // AccessKey используется как идентификатор в AWS Authorization header. type Tenant struct { ID string // уникальный идентификатор тенанта (t-: детерминированный из email для JWT-тенантов, случайный для manual) 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), по Email (JWT auth). type TenantStore struct { mu sync.RWMutex byID map[string]*Tenant byAccessKey map[string]*Tenant byEmail map[string]*Tenant // индекс по Email (JWT claim). Тенанты без email сюда НЕ попадают. } // NewTenantStore — создаёт пустое хранилище тенантов. func NewTenantStore() *TenantStore { return &TenantStore{ byID: make(map[string]*Tenant), byAccessKey: make(map[string]*Tenant), byEmail: 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.Email != "" { s.byEmail[t.Email] = 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.Email != "" { delete(s.byEmail, t.Email) } 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.Email != "" { s.byEmail[t.Email] = t } s.mu.Unlock() } // GetByEmail — поиск тенанта по Email (JWT claim). // Используется при JWT-авторизации через UI. func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) { s.mu.RLock() t, ok := s.byEmail[email] s.mu.RUnlock() return t, ok } // CreateFromJWT — auto-provisioning тенанта из JWT claims. // Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email. // Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле. // Легаси-тенант (старые случайные ключи / другой ID) пересчитывается: // старые индексы и Redis-запись удаляются, создаётся детерминированная запись. // ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются // в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей). func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) { if email == "" { return nil, fmt.Errorf("JWT email is required for tenant provisioning") } tenantID := tenantIDFromEmail(email) accessKey := accessKeyFromEmail(email) secretKey := secretKeyFromEmail(email) s.mu.Lock() defer s.mu.Unlock() // Идемпотентность: тенант с таким email уже есть if existing, ok := s.byEmail[email]; ok { // Полное совпадение — возвращаем как есть if existing.ID == tenantID && existing.AccessKey == accessKey { if sub != "" { existing.NubesSub = sub } // Обновляем лимит очередей на актуальное значение из кода при каждом // логине и персистим в Redis — иначе после рестарта пода из Redis // восстановится старый лимит (было: тенант навсегда оставался с лимитом, // заданным при первом логине). if existing.MaxQueues != maxQueues { existing.MaxQueues = maxQueues if data, err := json.Marshal(existing); err == nil { persistence.SaveTenantRaw(existing.ID, data) } } return existing, nil } // Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую delete(s.byID, existing.ID) delete(s.byAccessKey, existing.AccessKey) delete(s.byEmail, existing.Email) persistence.DeleteTenant(existing.ID) } if len(s.byID) >= MaxTenantsGlobal { return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal) } t := &Tenant{ ID: tenantID, Name: email, 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.byEmail[t.Email] = t // Сохраняем в Redis if data, err := json.Marshal(t); err == nil { persistence.SaveTenantRaw(t.ID, data) } return t, nil } // tenantIDFromEmail — детерминированный ID тенанта из email: "t-{16 hex}". func tenantIDFromEmail(email string) string { hash := sha256.Sum256([]byte(strings.ToLower(email))) return "t-" + hex.EncodeToString(hash[:8]) } // accessKeyFromEmail — детерминированный AccessKey: "SSAK-{24 hex}". func accessKeyFromEmail(email string) string { hash := sha256.Sum256([]byte(strings.ToLower(email))) return "SSAK-" + hex.EncodeToString(hash[:12]) } // secretKeySalt — соль для детерминированного SecretKey email-тенантов. const secretKeySalt = "shared-sqs:secret-key:v1" // secretKeyFromEmail — детерминированный SecretKey: 64 hex = SHA256(email + соль). func secretKeyFromEmail(email string) string { h := sha256.Sum256([]byte(strings.ToLower(email) + ":" + secretKeySalt)) return hex.EncodeToString(h[:]) } // 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 }