v0.1.28: email-идентичность — детерминированные креды из email, credentials-эндпоинт, UI модалка
This commit is contained in:
+67
-44
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user