Add demo UI showcase mode
This commit is contained in:
+142
-18
@@ -1,12 +1,14 @@
|
||||
// app/admin/admin.go
|
||||
// Admin API handlers for shared-sqs management
|
||||
// Created: 2026-04-09
|
||||
// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов
|
||||
// Updated: 2026-04-12 09:28 MSK — demo UI token и изоляция UI API одним tenant-ом
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -22,6 +24,20 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type uiContextKey string
|
||||
|
||||
const (
|
||||
uiTenantContextKey uiContextKey = "ui-tenant"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUIDemoToken = "demo-ui-shared-sqs-ngcloud-2026"
|
||||
defaultUIDemoTenantID = "t-demo-shared-sqs-ngcloud"
|
||||
defaultUIDemoEmail = "demo@shared-sqs.ngcloud"
|
||||
)
|
||||
|
||||
var errUIDemoTokenMismatch = errors.New("demo token mismatch")
|
||||
|
||||
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
||||
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
||||
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||
@@ -51,6 +67,61 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
||||
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
||||
}
|
||||
|
||||
// uiDemoToken — возвращает публичный demo token для UI, если он не переопределён через env.
|
||||
func (h *Handler) uiDemoToken() string {
|
||||
if token := os.Getenv("SHARED_SQS_UI_DEMO_TOKEN"); token != "" {
|
||||
return token
|
||||
}
|
||||
return defaultUIDemoToken
|
||||
}
|
||||
|
||||
// uiDemoTenantID — возвращает tenant ID, к которому привязан demo token.
|
||||
func (h *Handler) uiDemoTenantID() string {
|
||||
if tenantID := os.Getenv("SHARED_SQS_UI_DEMO_TENANT_ID"); tenantID != "" {
|
||||
return tenantID
|
||||
}
|
||||
return defaultUIDemoTenantID
|
||||
}
|
||||
|
||||
// uiDemoEmail — возвращает отображаемый email для demo UI session.
|
||||
func (h *Handler) uiDemoEmail() string {
|
||||
if email := os.Getenv("SHARED_SQS_UI_DEMO_EMAIL"); email != "" {
|
||||
return email
|
||||
}
|
||||
return defaultUIDemoEmail
|
||||
}
|
||||
|
||||
// authenticateUIDemoToken — маппит публичный demo token на заранее сидированный demo tenant.
|
||||
func (h *Handler) authenticateUIDemoToken(token string) (*tenant.Tenant, string, error) {
|
||||
demoToken := h.uiDemoToken()
|
||||
if demoToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(demoToken)) != 1 {
|
||||
return nil, "", errUIDemoTokenMismatch
|
||||
}
|
||||
demoTenant, ok := h.store.GetByID(h.uiDemoTenantID())
|
||||
if !ok {
|
||||
return nil, "", errors.New("demo tenant unavailable — enable SHARED_SQS_SEED_DEMO=true")
|
||||
}
|
||||
return demoTenant, h.uiDemoEmail(), nil
|
||||
}
|
||||
|
||||
// currentUITenant — возвращает tenant, авторизованный через UI middleware.
|
||||
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
|
||||
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// tenantListItemFromTenant — строит публичный JSON-ответ без SecretKey.
|
||||
func tenantListItemFromTenant(t *tenant.Tenant) tenantListItem {
|
||||
return tenantListItem{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
AccessKey: t.AccessKey,
|
||||
MaxQueues: t.MaxQueues,
|
||||
CreatedAt: t.CreatedAt,
|
||||
Active: t.Active,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||
@@ -118,6 +189,24 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if demoTenant, demoEmail, err := h.authenticateUIDemoToken(req.Token); err == nil {
|
||||
log.Infof("ui auth: authenticated demo tenant=%s", demoTenant.ID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"email": demoEmail,
|
||||
"tenant_id": demoTenant.ID,
|
||||
"access_key": demoTenant.AccessKey,
|
||||
"secret_key": demoTenant.SecretKey,
|
||||
"max_queues": demoTenant.MaxQueues,
|
||||
"token": req.Token,
|
||||
})
|
||||
return
|
||||
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
||||
log.Warnf("ui auth: demo login unavailable: %v", err)
|
||||
jsonErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Парсим JWT claims
|
||||
claims, err := auth.ParseJWTClaims(req.Token)
|
||||
if err != nil {
|
||||
@@ -182,6 +271,19 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
token := parts[1]
|
||||
|
||||
if demoTenant, _, err := h.authenticateUIDemoToken(token); err == nil {
|
||||
if pathTenantID, exists := mux.Vars(r)["id"]; exists && pathTenantID != "" && pathTenantID != demoTenant.ID {
|
||||
jsonErr(w, http.StatusForbidden, "forbidden tenant access")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), uiTenantContextKey, demoTenant)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
||||
jsonErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := auth.ParseJWTClaims(token)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||
@@ -210,7 +312,8 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
uiCtx := context.WithValue(r.Context(), uiTenantContextKey, jwtTenant)
|
||||
next.ServeHTTP(w, r.WithContext(uiCtx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,6 +346,10 @@ type tenantListItem struct {
|
||||
|
||||
// createTenant — POST /admin/tenants
|
||||
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := currentUITenant(r); ok {
|
||||
jsonErr(w, http.StatusForbidden, "tenant creation via UI is disabled")
|
||||
return
|
||||
}
|
||||
var req createTenantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -279,17 +386,15 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// listTenants — GET /admin/tenants
|
||||
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
|
||||
if uiTenant, ok := currentUITenant(r); ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]tenantListItem{tenantListItemFromTenant(uiTenant)})
|
||||
return
|
||||
}
|
||||
tenants := h.store.List()
|
||||
items := make([]tenantListItem, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
items = append(items, tenantListItem{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
AccessKey: t.AccessKey,
|
||||
MaxQueues: t.MaxQueues,
|
||||
CreatedAt: t.CreatedAt,
|
||||
Active: t.Active,
|
||||
})
|
||||
items = append(items, tenantListItemFromTenant(t))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(items)
|
||||
@@ -307,19 +412,16 @@ func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tenantListItem{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
AccessKey: t.AccessKey,
|
||||
MaxQueues: t.MaxQueues,
|
||||
CreatedAt: t.CreatedAt,
|
||||
Active: t.Active,
|
||||
})
|
||||
json.NewEncoder(w).Encode(tenantListItemFromTenant(t))
|
||||
}
|
||||
|
||||
// deleteTenant — DELETE /admin/tenants/{id}
|
||||
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
|
||||
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := currentUITenant(r); ok {
|
||||
jsonErr(w, http.StatusForbidden, "tenant deletion via UI is disabled")
|
||||
return
|
||||
}
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
t, ok := h.store.GetByID(id)
|
||||
@@ -626,6 +728,28 @@ type adminHealthDetail struct {
|
||||
|
||||
// detailedHealth — GET /admin/health
|
||||
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if uiTenant, ok := currentUITenant(r); ok {
|
||||
prefix := uiTenant.AccessKey + ":"
|
||||
queueCount := 0
|
||||
msgCount := 0
|
||||
models.SyncQueues.RLock()
|
||||
for key, q := range models.SyncQueues.Queues {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
queueCount++
|
||||
msgCount += len(q.Messages)
|
||||
}
|
||||
}
|
||||
models.SyncQueues.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(adminHealthDetail{
|
||||
Status: "ok",
|
||||
TenantCount: 1,
|
||||
QueueCount: queueCount,
|
||||
MessageCount: msgCount,
|
||||
})
|
||||
return
|
||||
}
|
||||
tenants := h.store.List()
|
||||
models.SyncQueues.RLock()
|
||||
queueCount := len(models.SyncQueues.Queues)
|
||||
|
||||
Reference in New Issue
Block a user