From 928a3fb85d85bad8293c9e56bbb32546ff51ac4f Mon Sep 17 00:00:00 2001 From: Repinoid Date: Tue, 6 Oct 2026 18:03:40 +0300 Subject: [PATCH] =?UTF-8?q?P0.1:=20=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D1=80=D0=B5=D0=B4=D1=8B=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=D0=BD=D1=82=D0=B0=20=E2=80=94=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D0=B4=D0=B5=D1=82=D0=B5=D1=80=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=B8=D0=B7=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateFromJWT больше не выводит ID/AccessKey/SecretKey из email через SHA256 с захардкоженной солью (это позволяло вычислить чужие креды по публичному email). Креды генерируются случайно через crypto/rand. Удалены tenantIDFromEmail, accessKeyFromEmail, secretKeyFromEmail, secretKeySalt и ветка legacy-пересчёта. Идемпотентность по email сохранена (повторный логин возвращает тот же тенант). --- app/tenant/tenant_store.go | 92 +++++++++++++++----------------------- 1 file changed, 36 insertions(+), 56 deletions(-) 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()