329 lines
12 KiB
Go
329 lines
12 KiB
Go
// Изменено: 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-<hex>: детерминированный из email для JWT-тенантов, случайный для manual)
|
|
Name string // человекочитаемое имя
|
|
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
|
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
|
|
}
|