// 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 тенантов package admin import ( "context" "encoding/json" "net/http" "os" "strings" "time" "shared-sqs/app/auth" "shared-sqs/app/models" "shared-sqs/app/persistence" "shared-sqs/app/tenant" "github.com/google/uuid" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" ) // ─── вспомогательная функция: найти очередь тенанта по имени ─────────────── // findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено. func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) { key := tenantAccessKey + ":" + queueName models.SyncQueues.RLock() q, ok := models.SyncQueues.Queues[key] models.SyncQueues.RUnlock() if !ok { return "", nil } return key, q } // Handler — admin API handler, holds TenantStore и admin token type Handler struct { store *tenant.TenantStore adminToken string nubesEndpoint string // URL nubes API для валидации JWT (напр. https://deck-api-test.ngcloud.ru/api/v1) } // NewHandler — создаёт admin handler func NewHandler(store *tenant.TenantStore, adminToken string) *Handler { nubesEndpoint := os.Getenv("NUBES_ENDPOINT") if nubesEndpoint == "" { nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1" } return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint} } // RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth) func (h *Handler) RegisterRoutes(r *mux.Router) { adminRouter := r.PathPrefix("/admin").Subrouter() adminRouter.Use(h.bearerAuthMiddleware) adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST") adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET") adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET") adminRouter.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE") adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET") } // RegisterPublicRoutes — маршруты для UI console. // POST /ui/api/auth — единственный публичный endpoint (принимает JWT, возвращает session). // Остальные /ui/api/* — защищены JWT middleware (токен в Authorization header). // ВАЖНО: все UI API routes на ОДНОМ subrouter PathPrefix("/ui/api") — иначе через nginx ingress // root-router HandleFunc конфликтует с PathPrefix subrouter (404 на POST). func (h *Handler) RegisterPublicRoutes(r *mux.Router) { ui := r.PathPrefix("/ui/api").Subrouter() // jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint ui.Use(h.jwtMiddleware) ui.HandleFunc("/auth", h.jwtAuth).Methods("POST") ui.HandleFunc("/health", h.detailedHealth).Methods("GET") ui.HandleFunc("/tenants", h.listTenants).Methods("GET") ui.HandleFunc("/tenants", h.createTenant).Methods("POST") ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET") ui.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST") ui.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE") ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET") ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST") ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE") } // bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12) func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") expected := "Bearer " + h.adminToken if authHeader != expected { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) return } next.ServeHTTP(w, r) }) } // jwtAuth — POST /ui/api/auth — принимает JWT токен, валидирует через nubes API, // auto-provision тенанта если не существует, возвращает email + tenant info. // Это единственный публичный endpoint UI API. func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) { var req struct { Token string `json:"token"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" { jsonErr(w, http.StatusBadRequest, "token is required") return } // Парсим JWT claims claims, err := auth.ParseJWTClaims(req.Token) if err != nil { log.Warnf("jwt auth: parse error: %v", err) jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error()) return } // Валидируем через nubes API ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, req.Token); err != nil { log.Warnf("jwt auth: nubes rejected token for sub=%s: %v", claims.Sub, err) jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error()) return } // Auto-provisioning: создаём тенанта если не существует tenantID := auth.TenantIDFromSub(claims.Sub) t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10) if err != nil { log.Errorf("jwt auth: failed to create tenant: %v", err) jsonErr(w, http.StatusInternalServerError, "failed to provision tenant") return } log.Infof("jwt auth: authenticated sub=%s email=%s tenant=%s", claims.Sub, claims.Email, t.ID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "email": claims.Email, "tenant_id": t.ID, "access_key": t.AccessKey, "secret_key": t.SecretKey, "max_queues": t.MaxQueues, "token": req.Token, // возвращаем для использования в последующих запросах }) } // jwtMiddleware — middleware для /ui/api/* endpoints. // Пропускает /ui/api/auth (публичный endpoint авторизации). // Проверяет Authorization: Bearer заголовок. // Парсит JWT, проверяет что sub соответствует существующему тенанту. // НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth. func (h *Handler) jwtMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // /ui/api/auth — публичный endpoint, пропускаем без проверки JWT if strings.HasSuffix(r.URL.Path, "/auth") { next.ServeHTTP(w, r) return } authHeader := r.Header.Get("Authorization") if authHeader == "" { jsonErr(w, http.StatusUnauthorized, "authorization required") return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { jsonErr(w, http.StatusUnauthorized, "invalid authorization format") return } token := parts[1] claims, err := auth.ParseJWTClaims(token) if err != nil { jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error()) return } // Повторно валидируем токен в nubes API на каждый UI API запрос, // чтобы отозванные токены не оставались валидными до повторного логина. ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, token); err != nil { jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error()) return } // Проверяем что тенант существует (был создан при /ui/api/auth) jwtTenant, ok := h.store.GetBySub(claims.Sub) if !ok { jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth") return } // IDOR защита: для /ui/api/tenants/{id}/... разрешаем доступ только к своему tenant ID. if pathTenantID, exists := mux.Vars(r)["id"]; exists && pathTenantID != "" && pathTenantID != jwtTenant.ID { jsonErr(w, http.StatusForbidden, "forbidden tenant access") return } next.ServeHTTP(w, r) }) } // createTenantRequest — тело запроса POST /admin/tenants type createTenantRequest struct { Name string `json:"name"` MaxQueues int `json:"max_queues"` } // tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании) type tenantCreateResponse struct { ID string `json:"id"` Name string `json:"name"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` MaxQueues int `json:"max_queues"` CreatedAt time.Time `json:"created_at"` Active bool `json:"active"` } // tenantListItem — данные тенанта без secret_key (для List/Get) type tenantListItem struct { ID string `json:"id"` Name string `json:"name"` AccessKey string `json:"access_key"` MaxQueues int `json:"max_queues"` CreatedAt time.Time `json:"created_at"` Active bool `json:"active"` } // createTenant — POST /admin/tenants func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) { var req createTenantRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) return } if req.Name == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "name is required"}) return } t, err := h.store.Create(req.Name, req.MaxQueues) if err != nil { log.Errorf("admin: failed to create tenant: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"}) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(tenantCreateResponse{ ID: t.ID, Name: t.Name, AccessKey: t.AccessKey, SecretKey: t.SecretKey, MaxQueues: t.MaxQueues, CreatedAt: t.CreatedAt, Active: t.Active, }) } // listTenants — GET /admin/tenants func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) { 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, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(items) } // getTenant — GET /admin/tenants/{id} func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] t, ok := h.store.GetByID(id) if !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) 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, }) } // deleteTenant — DELETE /admin/tenants/{id} // Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak) func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] t, ok := h.store.GetByID(id) if !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) return } // Удаляем все очереди тенанта из SyncQueues prefix := t.AccessKey + ":" deletedQueueKeys := make([]string, 0) models.SyncQueues.Lock() for key := range models.SyncQueues.Queues { if strings.HasPrefix(key, prefix) { deletedQueueKeys = append(deletedQueueKeys, key) delete(models.SyncQueues.Queues, key) } } models.SyncQueues.Unlock() for _, queueKey := range deletedQueueKeys { persistence.DeleteQueue(queueKey) } h.store.Delete(id) w.WriteHeader(http.StatusNoContent) } // listTenantQueues — GET /admin/tenants/{id}/queues // Возвращает список очередей тенанта с количеством сообщений. func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] t, ok := h.store.GetByID(id) if !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) return } prefix := t.AccessKey + ":" type queueInfo struct { Name string `json:"name"` Messages int `json:"messages"` NotVisible int `json:"not_visible"` VisibilityTimeout int `json:"visibility_timeout"` MaxMessageSize int `json:"max_message_size"` RetentionPeriod int `json:"retention_period"` } queues := make([]queueInfo, 0) models.SyncQueues.RLock() for key, q := range models.SyncQueues.Queues { if strings.HasPrefix(key, prefix) { name := strings.TrimPrefix(key, prefix) visible := 0 notVisible := 0 for _, msg := range q.Messages { if msg.ReceiptHandle != "" { notVisible++ } else { visible++ } } queues = append(queues, queueInfo{ Name: name, Messages: visible, NotVisible: notVisible, VisibilityTimeout: q.VisibilityTimeout, MaxMessageSize: q.MaximumMessageSize, RetentionPeriod: q.MessageRetentionPeriod, }) } } models.SyncQueues.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(queues) } // ─── QUEUE MANAGEMENT HANDLERS ──────────────────────────────────────────── // createQueueRequest — тело запроса POST .../queues type createQueueRequest struct { Name string `json:"name"` } // createTenantQueue — POST /admin/tenants/{id}/queues // Создаёт новую очередь для тенанта прямо в SyncQueues (без SQS-протокола). // Проверяет лимит MaxQueues тенанта и уникальность имени. func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid := vars["id"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } var req createQueueRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { jsonErr(w, http.StatusBadRequest, "name is required") return } key := t.AccessKey + ":" + req.Name models.SyncQueues.Lock() if _, exists := models.SyncQueues.Queues[key]; exists { models.SyncQueues.Unlock() jsonErr(w, http.StatusConflict, "queue already exists") return } // Проверяем лимит очередей тенанта count := 0 for k := range models.SyncQueues.Queues { if strings.HasPrefix(k, t.AccessKey+":") { count++ } } if t.MaxQueues > 0 && count >= t.MaxQueues { models.SyncQueues.Unlock() jsonErr(w, http.StatusForbidden, "queue limit exceeded") return } models.SyncQueues.Queues[key] = &models.Queue{ Name: req.Name, VisibilityTimeout: 30, MaximumMessageSize: 262144, MessageRetentionPeriod: 345600, Messages: []models.SqsMessage{}, Duplicates: make(map[string]time.Time), } persistence.SaveQueue(key, models.SyncQueues.Queues[key]) models.SyncQueues.Unlock() log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"name": req.Name, "status": "created"}) } // deleteTenantQueue — DELETE /admin/tenants/{id}/queues/{queue} // Удаляет очередь тенанта из SyncQueues вместе со всеми её сообщениями. func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } key := t.AccessKey + ":" + queueName models.SyncQueues.Lock() if _, exists := models.SyncQueues.Queues[key]; !exists { models.SyncQueues.Unlock() jsonErr(w, http.StatusNotFound, "queue not found") return } delete(models.SyncQueues.Queues, key) models.SyncQueues.Unlock() persistence.DeleteQueue(key) log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID) w.WriteHeader(http.StatusNoContent) } // peekMessageItem — одно сообщение в ответе peekQueueMessages (без receipt handle) type peekMessageItem struct { ID string `json:"id"` Body string `json:"body"` MD5 string `json:"md5"` SentAt string `json:"sent_at"` Receives int `json:"receives"` InFlight bool `json:"in_flight"` } // peekQueueMessages — GET /admin/tenants/{id}/queues/{queue}/messages?limit=50 // Peek-просмотр сообщений: НЕ удаляет, НЕ выставляет ReceiptHandle — только чтение. // Это принципиальное отличие от SQS ReceiveMessage (который скрывает сообщения). func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } _, q := findQueue(t.AccessKey, queueName) if q == nil { jsonErr(w, http.StatusNotFound, "queue not found") return } // Лимит по умолчанию 50, максимум 1000 limit := 50 if lv := r.URL.Query().Get("limit"); lv != "" { if n := 0; len(lv) > 0 { for _, c := range lv { if c < '0' || c > '9' { n = -1 break } n = n*10 + int(c-'0') } if n > 0 && n <= 1000 { limit = n } } } models.SyncQueues.RLock() result := make([]peekMessageItem, 0, len(q.Messages)) for i, msg := range q.Messages { if i >= limit { break } result = append(result, peekMessageItem{ ID: msg.Uuid, Body: msg.MessageBody, MD5: msg.MD5OfMessageBody, SentAt: msg.SentTime.Format(time.RFC3339), Receives: msg.NumberOfReceives, InFlight: msg.ReceiptHandle != "", }) } models.SyncQueues.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // sendMessageRequest — тело запроса POST .../messages type sendMessageRequest struct { Body string `json:"body"` } // sendMessageToQueue — POST /admin/tenants/{id}/queues/{queue}/messages // Отправляет сообщение напрямую в очередь минуя SQS-протокол. // Используется только из UI console — для prod нужен нормальный SQS send. func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } key, q := findQueue(t.AccessKey, queueName) if q == nil { jsonErr(w, http.StatusNotFound, "queue not found") return } var req sendMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Body == "" { jsonErr(w, http.StatusBadRequest, "body is required") return } msg := models.SqsMessage{ MessageBody: req.Body, Uuid: uuid.NewString(), SentTime: time.Now(), } models.SyncQueues.Lock() models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) persistence.SaveQueue(key, models.SyncQueues.Queues[key]) models.SyncQueues.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"}) } // purgeQueue — DELETE /admin/tenants/{id}/queues/{queue}/messages // Удаляет все сообщения из очереди (purge). Сама очередь остаётся. func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } key, q := findQueue(t.AccessKey, queueName) if q == nil { jsonErr(w, http.StatusNotFound, "queue not found") return } models.SyncQueues.Lock() models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0] persistence.SaveQueue(key, models.SyncQueues.Queues[key]) models.SyncQueues.Unlock() log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID) w.WriteHeader(http.StatusNoContent) } // jsonErr — вспомогательная функция: ответ с ошибкой в JSON func jsonErr(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } // adminHealthDetail — ответ GET /admin/health type adminHealthDetail struct { Status string `json:"status"` TenantCount int `json:"tenant_count"` QueueCount int `json:"queue_count"` MessageCount int `json:"message_count"` } // detailedHealth — GET /admin/health func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) { tenants := h.store.List() models.SyncQueues.RLock() queueCount := len(models.SyncQueues.Queues) msgCount := 0 for _, q := range models.SyncQueues.Queues { msgCount += len(q.Messages) } models.SyncQueues.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(adminHealthDetail{ Status: "ok", TenantCount: len(tenants), QueueCount: queueCount, MessageCount: msgCount, }) }