Add demo UI showcase mode

This commit is contained in:
Naeel
2026-04-12 09:58:32 +03:00
parent dd08427424
commit d7e9d90453
6 changed files with 325 additions and 33 deletions
+142 -18
View File
@@ -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)
+84
View File
@@ -0,0 +1,84 @@
// app/admin/admin_test.go
// Focused tests for UI auth and scoping
// Created: 2026-04-12 09:28 MSK
// Updated: 2026-04-12 09:28 MSK — demo UI token and tenant-scoped UI responses
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"shared-sqs/app/tenant"
)
// TestJWTAuth_AllowsDemoToken verifies that the public demo token authenticates into the seeded demo tenant.
func TestJWTAuth_AllowsDemoToken(t *testing.T) {
t.Setenv("SHARED_SQS_UI_DEMO_TOKEN", "demo-token-for-test")
t.Setenv("SHARED_SQS_UI_DEMO_TENANT_ID", "t-demo-test")
store := tenant.NewTenantStore()
demoTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
if err != nil {
t.Fatalf("CreateFixed(): %v", err)
}
h := NewHandler(store, "admin-token")
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{"token":"demo-token-for-test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.jwtAuth(w, req)
if w.Code != http.StatusOK {
t.Fatalf("jwtAuth() status = %d, body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := resp["tenant_id"]; got != demoTenant.ID {
t.Fatalf("tenant_id = %v, want %s", got, demoTenant.ID)
}
if got := resp["access_key"]; got != demoTenant.AccessKey {
t.Fatalf("access_key = %v, want %s", got, demoTenant.AccessKey)
}
}
// TestListTenants_UIContextReturnsOnlyOwnTenant verifies that UI API listing is scoped to the authenticated tenant.
func TestListTenants_UIContextReturnsOnlyOwnTenant(t *testing.T) {
store := tenant.NewTenantStore()
firstTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
if err != nil {
t.Fatalf("CreateFixed(first): %v", err)
}
if _, err := store.CreateFixed("other-service", 10, "t-other-test", "SSAK-other-test", "other-secret"); err != nil {
t.Fatalf("CreateFixed(second): %v", err)
}
h := NewHandler(store, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/ui/api/tenants", nil)
req = req.WithContext(context.WithValue(req.Context(), uiTenantContextKey, firstTenant))
w := httptest.NewRecorder()
h.listTenants(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listTenants() status = %d, body = %s", w.Code, w.Body.String())
}
var resp []map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp) != 1 {
t.Fatalf("len(response) = %d, want 1", len(resp))
}
if got := resp[0]["id"]; got != firstTenant.ID {
t.Fatalf("response[0].id = %v, want %s", got, firstTenant.ID)
}
}
+11 -15
View File
@@ -3,9 +3,9 @@
app/ui/index.html
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
Created: 2026-04-10
Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning
Updated: 2026-04-12 09:28 MSK — demo token login и UI только для собственного tenant-а
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия.
Режим: UI принимает либо nubes JWT, либо публичный demo token для seeded demo tenant.
-->
<html lang="ru">
<head>
@@ -330,13 +330,14 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<h1>SQS CONSOLE</h1>
<div class="form-group">
<label for="login-token">API Token (nubes JWT)</label>
<input id="login-token" type="password" placeholder="eyJhbGciOiJSUzI1NiIs...">
<label for="login-token">API Token или Demo Token</label>
<input id="login-token" type="password" placeholder="JWT или demo-ui-shared-sqs-ngcloud-2026">
</div>
<div id="login-error" class="login-error"></div>
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
Токен можно получить в панели управления облаком Nubes
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
Для личного tenant-а используйте API token из панели Nubes.
</p>
</div>
</div>
@@ -500,10 +501,10 @@ function showLogin() {
function doLogin() {
const token = document.getElementById('login-token').value.trim();
if (!token) {
document.getElementById('login-error').textContent = 'Введите токен';
document.getElementById('login-error').textContent = 'Введите API token или demo token';
return;
}
document.getElementById('login-error').textContent = 'Проверка токена...';
document.getElementById('login-error').textContent = 'Проверка доступа...';
fetch(BASE + '/ui/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -611,12 +612,11 @@ function renderDashboard(health, tenants) {
</div>
<div class="card">
<div class="toolbar">
<h2>Тенанты</h2>
<h2>Мой tenant</h2>
<div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh">
<span>⟳ 10с</span>
</div>
<button class="btn btn-primary btn-sm" onclick="openModal()">+ Создать</button>
</div>
</div>
<div class="table-wrap">
@@ -628,7 +628,6 @@ function renderDashboard(health, tenants) {
<th>Макс. очередей</th>
<th>Статус</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -641,12 +640,9 @@ function renderDashboard(health, tenants) {
? '<span class="badge badge-active">active</span>'
: '<span class="badge badge-inactive">inactive</span>'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteTenant('${esc(t.id)}','${esc(t.name)}')">✕</button>
</td>
</tr>
`).join('')}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет тенантов</td></tr>' : ''}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:32px">Tenant не найден</td></tr>' : ''}
</tbody>
</table>
</div>
@@ -681,7 +677,7 @@ function renderTenant(tenant, queues) {
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
el.innerHTML = `
<div class="breadcrumb">
<a href="#" onclick="event.preventDefault();showDashboard()">Тенанты</a>
<a href="#" onclick="event.preventDefault();showDashboard()">Мой tenant</a>
<span></span>
${esc(tenant.name)}
</div>