P0.1: случайные креды тенанта — убран детерминированный вывод из email

CreateFromJWT больше не выводит ID/AccessKey/SecretKey из email через SHA256
с захардкоженной солью (это позволяло вычислить чужие креды по публичному email).
Креды генерируются случайно через crypto/rand. Удалены tenantIDFromEmail,
accessKeyFromEmail, secretKeyFromEmail, secretKeySalt и ветка legacy-пересчёта.
Идемпотентность по email сохранена (повторный логин возвращает тот же тенант).
This commit is contained in:
Repinoid
2026-10-06 18:03:40 +03:00
parent 6e96a4d007
commit 928a3fb85d
+36 -56
View File
@@ -4,11 +4,9 @@ package tenant
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
@@ -26,7 +24,7 @@ const DefaultTenantMaxQueues = 50
// Tenant — модель тенанта shared-sqs. // Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header. // AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct { type Tenant struct {
ID string // уникальный идентификатор тенанта (t-<hex>: детерминированный из email для JWT-тенантов, случайный для manual) ID string // уникальный идентификатор тенанта (t-<hex>: случайный, генерируется через crypto/rand)
Name string // человекочитаемое имя Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>) AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars) SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
@@ -196,54 +194,57 @@ func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) {
} }
// CreateFromJWT — auto-provisioning тенанта из JWT claims. // CreateFromJWT — auto-provisioning тенанта из JWT claims.
// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email. // Идентичность — email: тенант ищется/создаётся по email (индекс byEmail).
// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле. // Креды (ID/AccessKey/SecretKey) генерируются СЛУЧАЙНО через crypto/rand и
// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается: // НЕ выводятся из email — иначе креды можно было бы вычислить по публичному email
// старые индексы и Redis-запись удаляются, создаётся детерминированная запись. // (SHA256(email+соль) при известной соли = полный доступ к чужим очередям).
// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются // Тот же email при повторном логине → тот же тенант и те же креды (из индекса/Redis).
// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей).
func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) { func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) {
if email == "" { if email == "" {
return nil, fmt.Errorf("JWT email is required for tenant provisioning") return nil, fmt.Errorf("JWT email is required for tenant provisioning")
} }
tenantID := tenantIDFromEmail(email)
accessKey := accessKeyFromEmail(email)
secretKey := secretKeyFromEmail(email)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
// Идемпотентность: тенант с таким email уже есть // Идемпотентность: тенант с таким email уже есть — возвращаем как есть,
// креды НЕ пересчитываем и НЕ меняем (они случайны и неизменны).
if existing, ok := s.byEmail[email]; ok { if existing, ok := s.byEmail[email]; ok {
// Полное совпадение — возвращаем как есть if sub != "" {
if existing.ID == tenantID && existing.AccessKey == accessKey { existing.NubesSub = sub
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) // логине и персистим в Redis — иначе после рестарта пода из Redis
delete(s.byAccessKey, existing.AccessKey) // восстановится старый лимит (было: тенант навсегда оставался с лимитом,
delete(s.byEmail, existing.Email) // заданным при первом логине).
persistence.DeleteTenant(existing.ID) 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 { if len(s.byID) >= MaxTenantsGlobal {
return nil, fmt.Errorf("global tenant limit reached (%d)", 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{ t := &Tenant{
ID: tenantID, ID: id,
Name: email, Name: email,
AccessKey: accessKey, AccessKey: accessKey,
SecretKey: secretKey, SecretKey: secretKey,
@@ -266,27 +267,6 @@ func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant,
return t, nil 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). // List — список всех тенантов (для admin GET /tenants).
func (s *TenantStore) List() []*Tenant { func (s *TenantStore) List() []*Tenant {
s.mu.RLock() s.mu.RLock()