From 572c617f4461df7e27f5f2de3c25592411ee7bfc Mon Sep 17 00:00:00 2001 From: Repinoid Date: Thu, 8 Oct 2026 15:19:15 +0300 Subject: [PATCH] =?UTF-8?q?fix(review):=20TOCTOU=20=D0=BB=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D1=82=D0=B5=D0=BD=D0=B0=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20+=20=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81=D1=82=20Nubes?= =?UTF-8?q?Sub=20+=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BD=D1=8B=D0=B9=20ht?= =?UTF-8?q?tp.Client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. TenantStore.Create: повторная проверка глобального лимита тенантов под Lock (ранняя была под RLock — гонка могла превысить MaxTenantsGlobal). 2. CreateFromJWT: обновлённый NubesSub теперь персистится в Redis (раньше сохранялся только внутри ветки смены MaxQueues и терялся при рестарте); запись в Redis только при реальном изменении. 3. auth.PingNubesAPI: общий http.Client (keep-alive) вместо нового на каждый вызов. --- app/auth/jwt.go | 7 +++++-- app/tenant/tenant_store.go | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/auth/jwt.go b/app/auth/jwt.go index 5a904c6..4459a68 100644 --- a/app/auth/jwt.go +++ b/app/auth/jwt.go @@ -78,6 +78,10 @@ var nubesAPIEndpoints = []string{ "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc", } +// nubesHTTPClient — общий клиент для валидации токенов: переиспользует +// соединения (keep-alive) вместо создания нового клиента на каждый запрос. +var nubesHTTPClient = &http.Client{Timeout: 10 * time.Second} + // PingNubesAPI — проверяет валидность токена перебором стендов Nubes API. // endpoint (если задан через NUBES_ENDPOINT) пробуется первым. // Логика: токен принимается ТОЛЬКО при явном HTTP 2xx от стенда. @@ -104,8 +108,7 @@ func PingNubesAPI(ctx context.Context, endpoint, token string) error { req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("User-Agent", "Mozilla/5.0") - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + resp, err := nubesHTTPClient.Do(req) if err != nil { lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err) continue diff --git a/app/tenant/tenant_store.go b/app/tenant/tenant_store.go index 70a921b..56db184 100644 --- a/app/tenant/tenant_store.go +++ b/app/tenant/tenant_store.go @@ -86,6 +86,12 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { } s.mu.Lock() + // Повторная проверка лимита ПОД Lock: ранняя проверка выше была под RLock, + // между ней и взятием Lock другой поток мог создать тенанта (TOCTOU). + if len(s.byID) >= MaxTenantsGlobal { + s.mu.Unlock() + return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal) + } s.byID[t.ID] = t s.byAccessKey[t.AccessKey] = t if t.Email != "" { @@ -221,15 +227,21 @@ func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, // Идемпотентность: тенант с таким email уже есть — возвращаем как есть, // креды НЕ пересчитываем и НЕ меняем (они случайны и неизменны). if existing, ok := s.byEmail[email]; ok { - if sub != "" { + // Персистим только если что-то реально изменилось: иначе лишние записи + // в Redis на каждый логин, а обновлённый NubesSub раньше вообще терялся + // (сохранялся только внутри ветки смены MaxQueues). + changed := false + if sub != "" && existing.NubesSub != sub { existing.NubesSub = sub + changed = true } // Обновляем лимит очередей на актуальное значение из кода при каждом - // логине и персистим в Redis — иначе после рестарта пода из Redis - // восстановится старый лимит (было: тенант навсегда оставался с лимитом, - // заданным при первом логине). + // логине — иначе после рестарта пода из Redis восстановится старый лимит. if existing.MaxQueues != maxQueues { existing.MaxQueues = maxQueues + changed = true + } + if changed { if data, err := json.Marshal(existing); err == nil { persistence.SaveTenantRaw(existing.ID, data) }