diff --git a/app/tenant/tenant_store.go b/app/tenant/tenant_store.go index bf14ec4..e8f3d9b 100644 --- a/app/tenant/tenant_store.go +++ b/app/tenant/tenant_store.go @@ -4,11 +4,9 @@ package tenant import ( "crypto/rand" - "crypto/sha256" "encoding/hex" "encoding/json" "fmt" - "strings" "sync" "time" @@ -26,7 +24,7 @@ const DefaultTenantMaxQueues = 50 // Tenant — модель тенанта shared-sqs. // AccessKey используется как идентификатор в AWS Authorization header. type Tenant struct { - ID string // уникальный идентификатор тенанта (t-: детерминированный из email для JWT-тенантов, случайный для manual) + ID string // уникальный идентификатор тенанта (t-: случайный, генерируется через crypto/rand) Name string // человекочитаемое имя AccessKey string // аналог AWS AccessKeyId (SSAK-) SecretKey string // аналог AWS SecretAccessKey (64 hex chars) @@ -196,54 +194,57 @@ func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) { } // CreateFromJWT — auto-provisioning тенанта из JWT claims. -// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email. -// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле. -// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается: -// старые индексы и Redis-запись удаляются, создаётся детерминированная запись. -// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются -// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей). +// Идентичность — email: тенант ищется/создаётся по email (индекс byEmail). +// Креды (ID/AccessKey/SecretKey) генерируются СЛУЧАЙНО через crypto/rand и +// НЕ выводятся из email — иначе креды можно было бы вычислить по публичному email +// (SHA256(email+соль) при известной соли = полный доступ к чужим очередям). +// Тот же email при повторном логине → тот же тенант и те же креды (из индекса/Redis). 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 уже есть + // Идемпотентность: тенант с таким 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 + if sub != "" { + existing.NubesSub = sub } - // Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую - delete(s.byID, existing.ID) - delete(s.byAccessKey, existing.AccessKey) - delete(s.byEmail, existing.Email) - persistence.DeleteTenant(existing.ID) + // Обновляем лимит очередей на актуальное значение из кода при каждом + // логине и персистим в 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 } + if len(s.byID) >= 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: tenantID, + ID: id, Name: email, AccessKey: accessKey, SecretKey: secretKey, @@ -266,27 +267,6 @@ func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, 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()