Files
SQS-service/app/tenant/tenant_store.go
T

291 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Изменено: 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
}