v0.1.16: JWT auth via nubes API, auto-provisioning, UI login

- app/auth/jwt.go: ParseJWTClaims, TenantIDFromSub (sless-compatible SHA256), PingNubesAPI
- app/admin/admin.go: POST /ui/api/auth endpoint, jwtMiddleware for /ui/api/*
- app/tenant/tenant_store.go: NubesSub/Email fields, GetBySub, CreateFromJWT
- app/ui/index.html: login page, email in navbar, JWT session in localStorage
- deployments/k8s/deployment.yaml: v0.1.16, NUBES_ENDPOINT env
- doc/decisions/resource-limits-plan.md: 20 vulnerabilities audit
- Fix: /ui/api/auth moved to subrouter (gorilla/mux PathPrefix conflict)
This commit is contained in:
Naeel
2026-04-10 18:35:19 +03:00
parent 6fb160f8ae
commit b9d434bcc5
9 changed files with 854 additions and 25 deletions
+262
View File
@@ -88,3 +88,265 @@ Latency (ok) : min=0.023s median=0.044s p95=0.332s max=3.920s
- H2 и H1 работают одинаково хорошо
**Вывод: харбор стабилен.** Апгрейд ресурсов полностью устранил проблему с зависаниями. Harbor пригоден для использования как registry для kaniko push.
---
## Сессия 2 — Анализ защиты от ресурсного исчерпания
# Agent: GitHub Copilot (Claude Opus 4.6)
### Задача
Полный аудит shared-sqs на уязвимости типа DoS / resource exhaustion.
Создать план защиты: один тенант не должен мочь положить сервис для всех.
### Ход анализа
#### Что проверял
Все SQS handlers (`app/gosqs/*.go`), admin API (`app/admin/admin.go`), модели (`app/models/`), persistence (`app/persistence/redis.go`), auth (`app/auth/`), tenant store (`app/tenant/`).
#### Гипотезы и что нашёл
**Гипотеза 1: кросс-тенантный доступ возможен?**
→ НЕТ. Изоляция через составной ключ `{accessKey}:{queueName}` работает корректно. Все handlers извлекают tenant из context (auth middleware), строят ключ через `tenantQueueKey()`. Обойти нельзя — accessKey проверяется в middleware, ключ строится на стороне сервера.
**Гипотеза 2: можно ли через URL path `/{account}/` получить доступ к чужим данным?**
→ НЕТ. `{account}` из URL НЕ используется для поиска очереди. Handler всегда берёт tenant из context (middleware), игнорируя path segment. Но `{account}` не валидируется — можно подставить чужой ID, что загрязнит логи.
**Гипотеза 3: DoS через неограниченное создание ресурсов?**
→ ДА. Критическая проблема:
- QueueName: нет валидации длины/символов (AWS ограничивает 80 chars, [a-zA-Z0-9_-])
- Messages per queue: без лимита
- Message body size: проверяется ТОЛЬКО в SendMessageV1, НЕ проверяется в SendMessageBatchV1
- Message attributes: без лимита на количество и размер (AWS: макс 10 атрибутов, общий размер ≤256KB)
- Tenant creation: без лимита (и PUBLIC API `/ui/api/tenants` без auth!)
- Long polling: WaitTimeSeconds без верхней границы (AWS: макс 20 сек)
**Гипотеза 4: можно ли исчерпать Redis?**
→ ДА. `SaveQueue()` сериализует всю очередь (включая ВСЕ сообщения) в один JSON → один ключ в Redis HASH. Очередь с 1M сообщений = один JSON ~1GB.
**Гипотеза 5: FIFO lock можно заблокировать навсегда?**
→ ДА. `LockGroup()` вызывается при ReceiveMessage. Если клиент получил сообщение и не вызвал DeleteMessage, group ID заблокирован до перезагрузки. Нет таймаута на lock.
**Гипотеза 6: data race в handlers?**
→ ДА. `GetQueueUrlV1` читает `SyncQueues.Queues[key]` без RLock — race condition при concurrent write.
**Гипотеза 7: Duplicates map утечка памяти?**
→ ДА. `Duplicates map[string]time.Time` в FIFO очередях растёт без ограничений. AWS очищает через 5 минут.
#### Что отбросил
- Атака через ReceiptHandle — формат `uuid#uuid`, перебор нереален (2^244)
- Атака через Authorization header — парсится корректно, плохой формат = 403
- Redis injection — go-redis использует протокол RESP, не строки; инъекция невозможна
### Найденные уязвимости (20 штук)
#### CRITICAL (2)
1. **Публичный Admin API**`/ui/api/*` без auth, полный доступ к CRUD тенантов/очередей/сообщений
2. **Batch message size bypass** — SendMessageBatchV1 не проверяет размер тела каждого сообщения
#### HIGH (8)
3. QueueName без валидации длины/символов
4. WaitTimeSeconds без верхней границы (должно быть ≤20)
5. ReceiveMessageWaitTimeSeconds атрибут без верхней границы
6. DelaySeconds без верхней границы (AWS макс 900)
7. VisibilityTimeout без верхней границы (AWS макс 43200)
8. MaxNumberOfMessages без верхней границы (AWS макс 10)
9. Message attributes: без лимита на количество и размер
10. Data race в GetQueueUrlV1 (нет RLock)
#### MEDIUM (8)
11. Нет лимита на количество сообщений в очереди
12. Нет лимита на создание тенантов
13. FIFO group lock без таймаута
14. Duplicates map без очистки
15. BatchEntryId без валидации длины
16. DeduplicationID без валидации длины (AWS макс 128)
17. GroupID без валидации длины (AWS макс 128)
18. Redis serialization без ограничения размера
#### LOW (2)
19. `{account}` в URL не валидируется
20. ReceiptHandle не валидируется по формату перед поиском
### План защиты — приоритизация
Принцип: начать с самого опасного и дешёвого в реализации.
**Фаза 1 — Критическое (блокирует production)**
1. Убрать или защитить `/ui/api/*` маршруты
2. Добавить валидацию размера тела в SendMessageBatchV1
3. Добавить RLock в GetQueueUrlV1
**Фаза 2 — AWS-совместимые лимиты (валидация параметров)**
4. QueueName: макс 80 chars, regex `^[a-zA-Z0-9_-]+(.fifo)?$`
5. WaitTimeSeconds: 0-20
6. ReceiveMessageWaitTimeSeconds: 0-20
7. DelaySeconds: 0-900
8. VisibilityTimeout: 0-43200
9. MaxNumberOfMessages: 1-10
10. Message attributes: макс 10, общий размер ≤256KB
11. DeduplicationID: макс 128 chars
12. GroupID: макс 128 chars
**Фаза 3 — Per-tenant resource limits**
13. Макс сообщений в очереди (per queue, напр. 100K)
14. Макс общий размер сообщений per tenant (напр. 1GB)
15. Rate limiting per tenant (напр. 100 req/sec)
16. Макс тенантов в системе (глобальный лимит)
**Фаза 4 — Стабильность**
17. FIFO group lock timeout (= VisibilityTimeout)
18. Duplicates map cleanup (goroutine, TTL 5 мин)
19. Redis: ограничить размер сериализации / разбить на chunks
20. `{account}` в URL: валидировать = tenant ID из context
---
## Сессия 3 — Контроль доступа через nubes JWT
# Agent: GitHub Copilot (Claude Opus 4.6)
### Задача
Заменить текущий auth (AccessKey/SecretKey per tenant → in-memory TenantStore) на JWT-токен nubes.
Пользователь вводит токен в UI → токен валидируется через `https://deck-api-test.ngcloud.ru/api/v1`.
Email из токена показывается в UI справа вверху.
### Разведка sless проекта
Изучил `~/terra/sless/` — соседний проект, где эта схема уже работает.
#### Структура JWT токена nubes (реальный пример):
```json
{
"iss": "auth-api",
"sub": "0199e325-1cdf-7cda-9319-e5302a85e291", // UUID пользователя
"exp": 1786932675,
"email": "tazet@narod.ru", // Email — показывать в UI
"email_verified": false,
"name": "",
"preferred_username": "",
"realm_access": {"roles": null},
"resource_access": {"account": {"roles": null}}
}
```
#### Как sless это делает:
1. **JWT parsing** (`client.go`): `SubFromJWT(token)` → декодирует JWT payload → возвращает `sub` (UUID)
2. **Namespace** (`client.go`): `NamespaceFromSub(sub)``SHA256(sub)[:8]``"sless-{16hex}"`
3. **Валидация** (`client.go`): `PingNubesAPI(endpoint, token)` → GET к `deck-api-test.ngcloud.ru/api/v1` с Bearer → 401/403 = отклонён
4. **Auth middleware** (`middleware/auth.go`): проверяет `Authorization: Bearer <token>` → в тестовом режиме принимает любую строку
#### Ключевые решения sless:
- Подпись JWT НЕ проверяется (нет JWKS endpoint nubes) — "trusted perimeter"
- Валидация токена = запрос к nubes API (PingNubesAPI) — если API вернул не 401/403, значит токен живой
- sub пользователя (UUID) хешируется для namespace — чтобы не показывать реальный ID наружу
### Размышления для SQS-service
**Вопрос 1: нужен ли namespace из хеша для SQS?**
Пользователь сказал "для SQS самого как очереди может и не надо". И правда:
- В sless namespace нужен для k8s: каждый пользователь = свой namespace с функциями/подами
- В SQS очереди живут в in-memory map, изоляция через составной ключ `{accessKey}:{queueName}`
- НО в общей конфигурации IoT + sless + funcs + SQS — единый namespace пользователя нужен
**Решение**: вычислять namespace НО использовать его как tenantID (а не k8s namespace).
Формула та же: `SHA256(sub)[:8]``"sless-{16hex}"` — совместимость с sless.
**Вопрос 2: что делать с текущим TenantStore (AccessKey/SecretKey)?**
Текущая система: admin создаёт тенанта → получает credentials → вводит в AWS CLI.
Новая система: пользователь вводит JWT → auto-provisioning тенанта.
Варианты:
- A) Полностью заменить → ломает существующих тестовых пользователей
- B) Добавить JWT как второй путь auth → оба работают
- C) JWT через UI → auto-create tenant с AccessKey → AWS CLI использует AccessKey
Вариант C самый логичный: JWT auth в UI/admin, AccessKey auth в SQS API (AWS SDK совместимость).
**Вопрос 3: Email в UI?**
Из JWT: `claims.email` → показать в правом верхнем углу UI.
### План (предварительный, ждём подтверждения)
1. Добавить JWT-парсинг (аналог sless SubFromJWT + EmailFromJWT)
2. Добавить PingNubesAPI для валидации токена
3. UI: окно ввода токена → при вводе → автоматически создаётся tenant
4. UI: показать email в правом верхнем углу
5. Совместимость: SQS API по-прежнему через AccessKey (AWS SDK), JWT — только для UI/admin
### Реализация (выполнено)
#### Новые файлы:
- `app/auth/jwt.go` — ParseJWTClaims, TenantIDFromSub (SHA256 совместимый с sless), PingNubesAPI
#### Изменённые файлы:
- `app/tenant/tenant_store.go`:
- Добавлены поля `NubesSub`, `Email` в Tenant struct
- Третий индекс `bySub` в TenantStore
- Метод `GetBySub(sub)` для поиска по JWT sub
- Метод `CreateFromJWT(tenantID, sub, email, maxQueues)` — идемпотентный auto-provisioning
- Все операции (Create, Delete, LoadTenant) обновлены для bySub индекса
- `app/admin/admin.go`:
- Добавлен `nubesEndpoint` в Handler (из env `NUBES_ENDPOINT`, default `https://deck-api-test.ngcloud.ru/api/v1`)
- `POST /ui/api/auth` — публичный endpoint: принимает JWT → валидирует через nubes → auto-provision tenant → ответ с email/credentials
- `jwtMiddleware` — middleware для защиты остальных /ui/api/* endpoints
- RegisterPublicRoutes теперь использует jwtMiddleware (кроме /ui/api/auth)
- `app/ui/index.html`:
- Добавлена login page с вводом JWT токена
- Email отображается в navbar справа вверху
- Сессия сохраняется в localStorage (token + email)
- Все API запросы теперь с `Authorization: Bearer <jwt>` header
- При 401/403 → автоматический выход на login page
- Кнопка "Выйти" очищает сессию
#### Результат компиляции:
`go build ./...` — PASS (без ошибок).
`go test ./...` — pre-existing failures (missing fixtures, Topics) — не связаны с моими изменениями.
#### Архитектурное решение:
- JWT auth → ТОЛЬКО для UI console (/ui/api/*)
- SQS API → по-прежнему через AccessKey в AWS Authorization header (совместимость с AWS SDK)
- TenantID из JWT = `sless-{SHA256(sub)[:8]}` — идентичен sless namespace → единый пользователь во всех сервисах
---
## Сессия 4
# Agent: GitHub Copilot (Claude Opus 4.6)
### Задача
Дебаг 404 на POST /ui/api/auth после деплоя v0.1.15
### Диагностика
1. **Первая гипотеза**: gorilla/mux route ordering — `r.PathPrefix("/ui")` static handler перехватывает `/ui/api/auth`.
- Перенёс `/ui/api/auth` на subrouter вместо root router HandleFunc.
- Собрал v0.1.16, задеплоил → **всё ещё 404**
2. **Тест изнутри пода**: `kubectl exec ... wget POST /ui/api/auth`**401 Unauthorized** (маршрут работает!).
- Значит проблема НЕ в коде, а в прохождении через ingress.
3. **Тест через port-forward**: `curl POST http://localhost:14100/ui/api/auth`**JSON ответ** (работает!).
- Подтверждение: код верный, ingress ломает.
4. **Ключевое открытие — два IP**:
- DNS `qu.kube5s.ru``185.247.187.151`
- Ingress в kubectl → `185.247.187.147`
- Тест напрямую на `.147`: POST auth → **работает** (JSON)
- Тест напрямую на `.151`: POST auth → **404**
5. **Причина**: kubectl был подключён к СТАРОМУ кластеру (`.147`), а DNS `qu.kube5s.ru` указывал на НОВЫЙ кластер `iot-naeel` (`.151`). Все деплои шли не туда.
### Решение
- Обновил kubeconfig на VM → новый кластер `iot-naeel` (API: `185.247.187.149:6443`, ingress: `185.247.187.151`)
- Задеплоил v0.1.16 в правильный кластер
- **Результат**: POST `/ui/api/auth` → 401 (корректный ответ), GET `/ui/api/health` → 401 (middleware работает), `/health` → OK
### Изменения в коде (v0.1.16)
- `app/admin/admin.go`: `/ui/api/auth` перенесён на subrouter (вместо root router HandleFunc) — исключает конфликт с PathPrefix в gorilla/mux. jwtMiddleware пропускает `/auth` path.
- `app/router/router.go`: комментарий о порядке регистрации routes.
### Redis
В новом кластере Redis подключается корректно: `rfrm-redisk8s.UUID.svc.cluster.local:6379`.
Загружено 6 тенантов, 31 очередь из Redis — данные мигрированы.
### Вывод
Проблема была инфраструктурная (два кластера), не программная. Код JWT auth работал с первой попытки.