v0.1.28: email-идентичность — детерминированные креды из email, credentials-эндпоинт, UI модалка

This commit is contained in:
“Naeel”
2026-08-14 09:43:36 +04:00
parent 87ecf3b5ca
commit e19f63abe4
5 changed files with 125 additions and 72 deletions
+67 -44
View File
@@ -4,9 +4,11 @@ package tenant
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
@@ -19,7 +21,7 @@ const MaxTenantsGlobal = 1000
// Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct {
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
ID string // уникальный идентификатор тенанта (t-<hex>: детерминированный из email для JWT-тенантов, случайный для manual)
Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
@@ -31,12 +33,12 @@ type Tenant struct {
}
// TenantStore — потокобезопасное in-memory хранилище тенантов.
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по Email (JWT auth).
type TenantStore struct {
mu sync.RWMutex
byID map[string]*Tenant
byAccessKey map[string]*Tenant
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
byEmail map[string]*Tenant // индекс по Email (JWT claim). Тенанты без email сюда НЕ попадают.
}
// NewTenantStore — создаёт пустое хранилище тенантов.
@@ -44,7 +46,7 @@ func NewTenantStore() *TenantStore {
return &TenantStore{
byID: make(map[string]*Tenant),
byAccessKey: make(map[string]*Tenant),
bySub: make(map[string]*Tenant),
byEmail: make(map[string]*Tenant),
}
}
@@ -83,8 +85,8 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" {
s.bySub[t.NubesSub] = t
if t.Email != "" {
s.byEmail[t.Email] = t
}
s.mu.Unlock()
@@ -156,8 +158,8 @@ func (s *TenantStore) Delete(id string) bool {
}
delete(s.byID, t.ID)
delete(s.byAccessKey, t.AccessKey)
if t.NubesSub != "" {
delete(s.bySub, t.NubesSub)
if t.Email != "" {
delete(s.byEmail, t.Email)
}
s.mu.Unlock()
@@ -173,60 +175,61 @@ 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
if t.Email != "" {
s.byEmail[t.Email] = t
}
s.mu.Unlock()
}
// GetBySub — поиск тенанта по NubesSub (JWT sub claim).
// GetByEmail — поиск тенанта по Email (JWT claim).
// Используется при JWT-авторизации через UI.
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) {
s.mu.RLock()
t, ok := s.bySub[sub]
t, ok := s.byEmail[email]
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) {
// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email.
// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле.
// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается:
// старые индексы и Redis-запись удаляются, создаётся детерминированная запись.
// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются
// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей).
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()
// Идемпотентность: если тенант с таким sub уже есть — возвращаем
if existing, ok := s.bySub[sub]; ok {
// Обновляем email если изменился
if email != "" && existing.Email != email {
existing.Email = email
defer s.mu.Unlock()
// Идемпотентность: тенант с таким email уже есть
if existing, ok := s.byEmail[email]; ok {
// Полное совпадение — возвращаем как есть
if existing.ID == tenantID && existing.AccessKey == accessKey {
if sub != "" {
existing.NubesSub = sub
}
return existing, nil
}
s.mu.Unlock()
return existing, nil
// Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую
delete(s.byID, existing.ID)
delete(s.byAccessKey, existing.AccessKey)
delete(s.byEmail, existing.Email)
persistence.DeleteTenant(existing.ID)
}
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,
Name: email,
AccessKey: accessKey,
SecretKey: secretKey,
MaxQueues: maxQueues,
@@ -238,8 +241,7 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int)
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
s.bySub[t.NubesSub] = t
s.mu.Unlock()
s.byEmail[t.Email] = t
// Сохраняем в Redis
if data, err := json.Marshal(t); err == nil {
@@ -249,6 +251,27 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int)
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()