v0.1.16: JWT auth via nubes API, auto-provisioning, UI login
- app/auth/jwt.go: ParseJWTClaims, TenantIDFromSub (sless-compatible SHA256), PingNubesAPI - app/admin/admin.go: POST /ui/api/auth endpoint, jwtMiddleware for /ui/api/* - app/tenant/tenant_store.go: NubesSub/Email fields, GetBySub, CreateFromJWT - app/ui/index.html: login page, email in navbar, JWT session in localStorage - deployments/k8s/deployment.yaml: v0.1.16, NUBES_ENDPOINT env - doc/decisions/resource-limits-plan.md: 20 vulnerabilities audit - Fix: /ui/api/auth moved to subrouter (gorilla/mux PathPrefix conflict)
This commit is contained in:
@@ -16,21 +16,24 @@ import (
|
||||
// Tenant — модель тенанта shared-sqs.
|
||||
// AccessKey используется как идентификатор в AWS Authorization header.
|
||||
type Tenant struct {
|
||||
ID string // уникальный идентификатор тенанта (t-<hex>)
|
||||
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
|
||||
Name string // человекочитаемое имя
|
||||
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
||||
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
||||
MaxQueues int // лимит очередей (0 = безлимит)
|
||||
CreatedAt time.Time
|
||||
Active bool
|
||||
NubesSub string `json:"nubes_sub,omitempty"` // JWT sub claim (UUID пользователя nubes). Пусто для legacy тенантов.
|
||||
Email string `json:"email,omitempty"` // Email из JWT. Для отображения в UI.
|
||||
}
|
||||
|
||||
// TenantStore — потокобезопасное in-memory хранилище тенантов.
|
||||
// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware).
|
||||
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
|
||||
type TenantStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*Tenant
|
||||
byAccessKey map[string]*Tenant
|
||||
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
|
||||
}
|
||||
|
||||
// NewTenantStore — создаёт пустое хранилище тенантов.
|
||||
@@ -38,6 +41,7 @@ func NewTenantStore() *TenantStore {
|
||||
return &TenantStore{
|
||||
byID: make(map[string]*Tenant),
|
||||
byAccessKey: make(map[string]*Tenant),
|
||||
bySub: make(map[string]*Tenant),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +73,9 @@ 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
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса
|
||||
@@ -139,6 +146,9 @@ 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)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Удаляем из Redis асинхронно
|
||||
@@ -153,9 +163,78 @@ 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
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetBySub — поиск тенанта по NubesSub (JWT sub claim).
|
||||
// Используется при JWT-авторизации через UI.
|
||||
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
|
||||
s.mu.RLock()
|
||||
t, ok := s.bySub[sub]
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
// Идемпотентность: если тенант с таким sub уже есть — возвращаем
|
||||
if existing, ok := s.bySub[sub]; ok {
|
||||
// Обновляем email если изменился
|
||||
if email != "" && existing.Email != email {
|
||||
existing.Email = email
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
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,
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
MaxQueues: maxQueues,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Active: true,
|
||||
NubesSub: sub,
|
||||
Email: email,
|
||||
}
|
||||
|
||||
s.byID[t.ID] = t
|
||||
s.byAccessKey[t.AccessKey] = t
|
||||
s.bySub[t.NubesSub] = t
|
||||
s.mu.Unlock()
|
||||
|
||||
// Сохраняем в Redis
|
||||
if data, err := json.Marshal(t); err == nil {
|
||||
persistence.SaveTenantRaw(t.ID, data)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// List — список всех тенантов (для admin GET /tenants).
|
||||
func (s *TenantStore) List() []*Tenant {
|
||||
s.mu.RLock()
|
||||
|
||||
Reference in New Issue
Block a user