diff --git a/app/admin/admin.go b/app/admin/admin.go index dc699d9..d514567 100644 --- a/app/admin/admin.go +++ b/app/admin/admin.go @@ -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) diff --git a/app/admin/admin_test.go b/app/admin/admin_test.go new file mode 100644 index 0000000..6b2c87b --- /dev/null +++ b/app/admin/admin_test.go @@ -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) + } +} diff --git a/app/ui/index.html b/app/ui/index.html index d2c8be9..f9d7470 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -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. --> @@ -330,13 +330,14 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); } Nubes

SQS CONSOLE

- - + +

- Токен можно получить в панели управления облаком Nubes + Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.
+ Для личного tenant-а используйте API token из панели Nubes.

@@ -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) {
-

Тенанты

+

Мой tenant

⟳ 10с
-
@@ -628,7 +628,6 @@ function renderDashboard(health, tenants) { Макс. очередей Статус Создан - @@ -641,12 +640,9 @@ function renderDashboard(health, tenants) { ? 'active' : 'inactive'} ${fmtDate(t.created_at)} - - - `).join('')} - ${(!tenants || tenants.length === 0) ? 'Нет тенантов' : ''} + ${(!tenants || tenants.length === 0) ? 'Tenant не найден' : ''}
@@ -681,7 +677,7 @@ function renderTenant(tenant, queues) { const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0); el.innerHTML = ` diff --git a/doc/decisions/demo-ui-showcase-2026-04-12.md b/doc/decisions/demo-ui-showcase-2026-04-12.md new file mode 100644 index 0000000..a30e4e2 --- /dev/null +++ b/doc/decisions/demo-ui-showcase-2026-04-12.md @@ -0,0 +1,36 @@ +# Решение: demo UI режим для showcase + +Дата: 2026-04-12 09:57 MSK +Агент: GitHub Copilot (GPT-5.4) + +## Контекст + +Нужно показать заказчику два сценария на одном стенде: + +1. Быстрый demo-вход без подготовки. +2. Реальный пользовательский вход по настоящему Nubes token. + +До изменения UI принимал только реальный JWT и при этом использовал admin handlers слишком широко, из-за чего demo-сценарий был неудобным, а UI-поведение было ближе к admin console, чем к пользовательской витрине. + +## Решение + +Принят временный showcase-режим: + +- добавить публичный UI demo token `demo-ui-shared-sqs-ngcloud-2026`; +- привязать его к уже существующему seeded demo tenant `t-demo-shared-sqs-ngcloud`; +- сохранить реальный JWT flow без изменений; +- ограничить UI API текущим tenant-ом; +- запретить создание и удаление tenant-а через UI. + +## Почему так + +- Это позволяет быстро показать сервис без подготовки аккаунта. +- Это сохраняет реальный пользовательский сценарий: заказчик может ввести настоящий token и попасть в свой tenant. +- Это убирает из demo UI лишний обзор всей системы и снижает риск случайной демонстрации чужих данных. +- Это минимальное изменение, которое можно позже убрать без ломки основной JWT-модели. + +## Границы решения + +- Решение предназначено для demo/showcase, не для production security model. +- В production demo token должен быть удалён вместе с seeded demo tenant. +- Основным постоянным сценарием остаётся вход по реальному Nubes JWT. \ No newline at end of file diff --git a/doc/progress.md b/doc/progress.md index 56c8231..97990ce 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -2,6 +2,15 @@ ## Версия v0.1.x +### v0.1.22-dev (2026-04-12) — demo UI login + UI tenant scoping +- ✅ Добавлен публичный UI demo token: `demo-ui-shared-sqs-ngcloud-2026` +- ✅ Demo token маппится на уже сидированный demo tenant `t-demo-shared-sqs-ngcloud` +- ✅ UI API больше не показывает чужие tenant-ы: `GET /ui/api/tenants` возвращает только текущий tenant +- ✅ UI health для авторизованного пользователя считает только его очереди и сообщения +- ✅ Создание и удаление tenant-а через UI отключены, чтобы demo/login-console не выглядела как admin panel +- ✅ Узкая валидация: `go test ./app/admin` PASS +- ✅ Публичный showcase README синхронизирован с новым demo UI token + ### v0.1.21 (2026-04-11) — Redis schema v2 + per-message persistence - ✅ Redis schema v2: metadata в HASH `ssq:queues`, сообщения в отдельных HASH `ssq:msg:{queueKey}` - ✅ Per-message persistence: каждая операция (send/receive/delete/visibility) пишет только затронутое сообщение diff --git a/doc/thinking/2026-04-12.md b/doc/thinking/2026-04-12.md index 9745e71..ecc7920 100644 --- a/doc/thinking/2026-04-12.md +++ b/doc/thinking/2026-04-12.md @@ -238,6 +238,49 @@ Проблема 64KB+ payload у shared-sqs оказалась не багом Go-сервиса и не проблемой Redis/persistence, а ограничением ingress path в кластере штурвала: объект ingress у приложения настроен корректно, часть аннотаций применяется, но именно `proxy-request-buffering` platform controller в итоговый nginx.conf не прокидывает, при этом upstream ingress-nginx такую аннотацию поддерживает. Поэтому вводить hard-limit 32KB в коде я считаю неправильным; правильное практическое решение на текущий момент — оставить сервис без искусственного code-level ограничения, а 32KB считать безопасным эксплуатационным размером и документировать это как известную особенность инфраструктуры. +--- +# Agent: GitHub Copilot (GPT-5.4) + +## Задача: дать обычному demo-пользователю понятный вход в UI без личного Nubes JWT + +### Контекст +После переработки публичного showcase-репозитория осталась продуктовая дыра: AWS CLI уже имел публичные demo credentials, а UI по-прежнему требовал личный nubes JWT. Для обычного пользователя это ломало демо-сценарий: CLI можно попробовать сразу, а UI нет. + +### Локальная гипотеза +Если в коде уже существует сидированный demo tenant с фиксированными AWS credentials, то самый дешёвый и чистый путь — не придумывать новую сущность, а добавить отдельный UI demo token, который логинит ровно в этот tenant. Тогда CLI и UI будут опираться на один и тот же демонстрационный контур. + +### Что проверил перед правкой +1. В `app/admin/admin.go` единственный публичный UI login endpoint `POST /ui/api/auth` принимал только JWT, парсил claims и всегда вызывал `PingNubesAPI`. +2. В `app/ui/index.html` логин-форма явно требовала `API Token (nubes JWT)`. +3. В `app/cmd/seed.go` уже существует seeded demo tenant: + - tenant ID: `t-demo-shared-sqs-ngcloud` + - access key: `SSAK-demo-shared-sqs` + - secret key: `demo-secret-key-shared-sqs-ngcloud-2026` +4. Дополнительно нашёл соседний дефект: UI routes переиспользовали admin handlers и `GET /ui/api/tenants` возвращал весь список tenant-ов, а `/ui/api/health` показывал глобальные счётчики сервиса. Для demo-login это недопустимо. + +### Решение +Сделал один узкий срез: +1. Добавил публичный UI demo token `demo-ui-shared-sqs-ngcloud-2026` с возможностью переопределения через env. +2. Привязал его к уже существующему seeded demo tenant. +3. Оставил существующий JWT flow без изменения для реальных пользователей. +4. Начал класть авторизованный UI tenant в request context. +5. Ограничил UI API текущим tenant-ом: + - `GET /ui/api/tenants` возвращает только своего tenant-а; + - `GET /ui/api/health` считает только свои очереди и сообщения; + - создание и удаление tenant-а через UI запрещены. +6. Обновил встроенный UI: форма логина теперь прямо подсказывает demo token и больше не выглядит как админская панель для управления всеми tenant-ами. + +### Почему именно так +- Это минимальное изменение с хорошим ROI: один новый demo token закрывает UX-проблему без нового storage, без нового auth-service и без изменения AWS credentials. +- Demo-пользователь теперь видит только demo tenant и не получает случайный обзор всей системы. +- CLI и UI сходятся на одной и той же demo-учётке, то есть продуктовая история становится понятной. + +### Что сознательно НЕ делал +- Не убирал JWT flow. +- Не строил отдельную demo role model. +- Не менял SQS auth path для AWS CLI. +- Не пытался превращать UI в полноценную admin console и пользовательскую console одновременно: для UI выбрал явный user/demo режим с одним tenant-ом. + --- ## Внешние ссылки для возврата к теме 64KB+