291 lines
9.2 KiB
Go
291 lines
9.2 KiB
Go
// Изменено: 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-<hex> из JWT sub, или t-<hex> для legacy)
|
||
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), по 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
|
||
}
|