feat: namespace-per-user via JWT sub SHA256 + ensureNamespace in operator
- operator: ensureNamespace() создаёт k8s namespace при первом Create-запросе
- operator: defaultNamespace константа вместо хардкода 'default'
- provider: SubFromJWT декодирует JWT payload, извлекает sub
- provider: NamespaceFromSub вычисляет sless-{sha256[:8]} из sub
- provider: PingNubesAPI валидирует токен запросом к nubes API
- provider: Configure вычисляет namespace и создаёт Client с ним
- provider: новый атрибут nubes_endpoint (опционально, env: NUBES_ENDPOINT)
This commit is contained in:
@@ -1,13 +1,26 @@
|
||||
// 2026-03-08
|
||||
// 2026-03-11
|
||||
// client.go — HTTP-клиент для REST API sless оператора.
|
||||
// Намеренно изолирован от terraform-plugin-framework — при переносе в nubes
|
||||
// этот файл кладётся в internal/core/ без изменений.
|
||||
// Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
|
||||
// Все методы принимают ctx для правильной работы с таймаутами terraform.
|
||||
//
|
||||
// Архитектура namespace:
|
||||
// - Namespace вычисляется из JWT-токена провайдером ОДИН РАЗ при Configure().
|
||||
// - Алгоритм: JWT.sub → SHA256 → hex первые 16 байт → "sless-{hex}"
|
||||
// - Client хранит уже вычисленный Namespace — ресурсы просто читают его.
|
||||
// - SubFromJWT и NamespaceFromSub — package-level функции (не методы),
|
||||
// вызываются из provider.Configure() до создания Client.
|
||||
//
|
||||
// Валидация токена:
|
||||
// - PingNubesAPI делает GET запрос к nubes API с Bearer токеном.
|
||||
// - 401/403 → токен невалиден → ошибка инициализации провайдера.
|
||||
// - Любой другой ответ → токен принят сервером.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,6 +28,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,22 +37,102 @@ type Client struct {
|
||||
httpClient *http.Client
|
||||
endpoint string
|
||||
token string
|
||||
// Namespace захардкодено = "default".
|
||||
// TODO: изоляция пользователей — каждый токен привязан к своему namespace,
|
||||
// провайдер получает его через GET /v1/whoami. Сейчас всё в одном ns для демо.
|
||||
// Namespace — k8s namespace пользователя, вычисленный из JWT-токена.
|
||||
// Устанавливается один раз при создании Client в provider.Configure().
|
||||
// Алгоритм вычисления: SubFromJWT → NamespaceFromSub.
|
||||
// Все ресурсы (FunctionResource, TriggerResource, JobResource) читают это поле.
|
||||
Namespace string
|
||||
}
|
||||
|
||||
// New создаёт клиент. endpoint — базовый URL оператора (без trailing slash).
|
||||
func New(endpoint, token string) *Client {
|
||||
// New создаёт клиент.
|
||||
// - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru"
|
||||
// - token — Bearer JWT-токен облака
|
||||
// - namespace — k8s namespace пользователя (вычислен через NamespaceFromSub)
|
||||
func New(endpoint, token, namespace string) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
Namespace: "default",
|
||||
Namespace: namespace,
|
||||
}
|
||||
}
|
||||
|
||||
// SubFromJWT декодирует JWT payload (base64url) и возвращает claim "sub".
|
||||
// Не проверяет подпись — только структуру и наличие sub.
|
||||
// Проверка подписи не нужна: токен val идирован через PingNubesAPI запросом к реальному API.
|
||||
func SubFromJWT(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
// JWT использует base64url без padding — добавляем padding
|
||||
payload := parts[1]
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
// Пробуем StdEncoding на случай нестандартного токена
|
||||
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode JWT payload: %w", err)
|
||||
}
|
||||
}
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return "", fmt.Errorf("parse JWT claims: %w", err)
|
||||
}
|
||||
if claims.Sub == "" {
|
||||
return "", fmt.Errorf("JWT missing 'sub' claim")
|
||||
}
|
||||
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
|
||||
return "", fmt.Errorf("JWT token expired")
|
||||
}
|
||||
return claims.Sub, nil
|
||||
}
|
||||
|
||||
// NamespaceFromSub вычисляет имя k8s namespace из JWT subject (sub claim).
|
||||
// Алгоритм: SHA256(sub) → берём первые 8 байт → hex → "sless-{16 hex символов}".
|
||||
// Итоговая длина: 6 + 16 = 22 символа — укладывается в лимит k8s (63 символа).
|
||||
// SHA256 необратим — sub пользователя не раскрывается через имя namespace.
|
||||
// Детерминирован: один и тот же sub всегда даёт один и тот же namespace.
|
||||
func NamespaceFromSub(sub string) string {
|
||||
hash := sha256.Sum256([]byte(sub))
|
||||
return fmt.Sprintf("sless-%x", hash[:8])
|
||||
}
|
||||
|
||||
// PingNubesAPI делает GET запрос к nubes API для проверки валидности токена.
|
||||
// endpoint — базовый URL nubes API (например "https://deck-api.ngcloud.ru/api/v1").
|
||||
// Логика проверки:
|
||||
// - 401 или 403 → токен невалиден или истёк → возвращаем ошибку
|
||||
// - ошибка соединения → API недоступен → возвращаем ошибку
|
||||
// - любой другой HTTP статус → API ответил, токен не отклонён → OK
|
||||
func PingNubesAPI(ctx context.Context, endpoint, token string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build nubes ping request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
c := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("nubes API rejected token (HTTP %d) — check api_token", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- JSON-структуры (зеркало handler/functions.go и handler/triggers.go) ---
|
||||
|
||||
// FunctionRequest — тело POST/PUT /v1/namespaces/{ns}/functions[/{name}]
|
||||
|
||||
Reference in New Issue
Block a user