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:
Naeel
2026-04-10 18:35:19 +03:00
parent 6fb160f8ae
commit b9d434bcc5
9 changed files with 854 additions and 25 deletions
+81 -2
View File
@@ -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()