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:
“Naeel”
2026-03-11 07:35:49 +04:00
parent 6dff628975
commit f41cd39b26
6 changed files with 236 additions and 24 deletions
+7 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-07 // Изменено: 2026-03-11
// functions.go — CRUD handlers для Function CRD. // functions.go — CRUD handlers для Function CRD.
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function. // Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name} // Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
@@ -80,6 +80,12 @@ func (h *Handler) ListFunctions(w http.ResponseWriter, r *http.Request) {
// CreateFunction — POST /v1/namespaces/{namespace}/functions // CreateFunction — POST /v1/namespaces/{namespace}/functions
func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) {
ns := namespace(r) ns := namespace(r)
// Гарантируем существование namespace перед созданием Function CRD.
// Подробнее про логику — см. ensureNamespace() в handler.go.
if err := h.ensureNamespace(r.Context(), ns); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("ensure namespace: "+err.Error()))
return
}
var req functionRequest var req functionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
+50 -3
View File
@@ -1,16 +1,28 @@
// Изменено: 2026-03-07 // Изменено: 2026-03-11
// Handler — общий контейнер зависимостей для всех REST handlers. // Handler — общий контейнер зависимостей для всех REST handlers.
// Все handlers получают доступ к k8s, S3 и Postgres через эту структуру. // Все handlers получают доступ к k8s, S3 и Postgres через эту структуру.
// Логирование через slog, маршрутизация через gorilla/mux. // Логирование через slog, маршрутизация через gorilla/mux.
//
// Архитектура namespace:
// - Каждый пользователь работает в своём k8s namespace.
// - Имя namespace вычисляется провайдером из JWT-токена (SHA256 от sub).
// - Оператор сам не создаёт namespace заранее — он делает это при первом
// Create-запросе через ensureNamespace().
// - defaultNamespace используется только как fallback для dev/тестов,
// когда namespace не передан в URL.
package handler package handler
import ( import (
"context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
@@ -18,6 +30,11 @@ import (
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3" "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
) )
// defaultNamespace — fallback namespace для dev/тестов.
// В production namespace определяется из JWT-токена провайдером.
// Все обращения к "default" строке идут через эту константу — одно место замены.
const defaultNamespace = "default"
// Handler содержит зависимости для всех REST-обработчиков. // Handler содержит зависимости для всех REST-обработчиков.
type Handler struct { type Handler struct {
K8s client.Client K8s client.Client
@@ -44,10 +61,40 @@ func pathVar(r *http.Request, key string) string {
return mux.Vars(r)[key] return mux.Vars(r)[key]
} }
// namespace читает {namespace} из пути, fallback — "default". // namespace читает {namespace} из пути URL.
// Fallback — defaultNamespace (используется только в dev/тестах).
func namespace(r *http.Request) string { func namespace(r *http.Request) string {
if ns := mux.Vars(r)["namespace"]; ns != "" { if ns := mux.Vars(r)["namespace"]; ns != "" {
return ns return ns
} }
return "default" return defaultNamespace
}
// ensureNamespace создаёт k8s namespace если он не существует.
// Вызывается в начале каждого Create-хендлера (CreateFunction, CreateTrigger, CreateJob).
// Идемпотентен: если namespace уже есть — не ошибается.
// Зачем здесь, а не в контроллере: оператор получает запросы ДО того как
// контроллер может что-то создать, поэтому namespace нужен прямо при Create.
func (h *Handler) ensureNamespace(ctx context.Context, ns string) error {
existing := &corev1.Namespace{}
err := h.K8s.Get(ctx, client.ObjectKey{Name: ns}, existing)
if err == nil {
// namespace уже существует — всё ок
return nil
}
if !k8serrors.IsNotFound(err) {
// неожиданная ошибка k8s API
return err
}
// namespace не существует — создаём
ns_obj := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: ns,
// label для идентификации namespace как принадлежащего sless
Labels: map[string]string{
"managed-by": "sless-operator",
},
},
}
return h.K8s.Create(ctx, ns_obj)
} }
+5
View File
@@ -64,6 +64,11 @@ func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse {
// Создаёт FunctionJob CR. Оператор запустит k8s Job асинхронно. // Создаёт FunctionJob CR. Оператор запустит k8s Job асинхронно.
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
ns := namespace(r) ns := namespace(r)
// Гарантируем существование namespace — аналогично CreateFunction.
if err := h.ensureNamespace(r.Context(), ns); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("ensure namespace: "+err.Error()))
return
}
var req jobRequest var req jobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+5
View File
@@ -74,6 +74,11 @@ func (h *Handler) ListTriggers(w http.ResponseWriter, r *http.Request) {
// CreateTrigger — POST /v1/namespaces/{namespace}/triggers // CreateTrigger — POST /v1/namespaces/{namespace}/triggers
func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) {
ns := namespace(r) ns := namespace(r)
// Гарантируем существование namespace — аналогично CreateFunction.
if err := h.ensureNamespace(r.Context(), ns); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("ensure namespace: "+err.Error()))
return
}
var req triggerRequest var req triggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error()))
+103 -9
View File
@@ -1,13 +1,26 @@
// 2026-03-08 // 2026-03-11
// client.go — HTTP-клиент для REST API sless оператора. // client.go — HTTP-клиент для REST API sless оператора.
// Намеренно изолирован от terraform-plugin-framework — при переносе в nubes // Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
// этот файл кладётся в internal/core/ без изменений.
// Все методы принимают ctx для правильной работы с таймаутами terraform. // Все методы принимают 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 package client
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -15,6 +28,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
) )
@@ -23,22 +37,102 @@ type Client struct {
httpClient *http.Client httpClient *http.Client
endpoint string endpoint string
token string token string
// Namespace захардкодено = "default". // Namespace — k8s namespace пользователя, вычисленный из JWT-токена.
// TODO: изоляция пользователей — каждый токен привязан к своему namespace, // Устанавливается один раз при создании Client в provider.Configure().
// провайдер получает его через GET /v1/whoami. Сейчас всё в одном ns для демо. // Алгоритм вычисления: SubFromJWT → NamespaceFromSub.
// Все ресурсы (FunctionResource, TriggerResource, JobResource) читают это поле.
Namespace string Namespace string
} }
// New создаёт клиент. endpoint — базовый URL оператора (без trailing slash). // New создаёт клиент.
func New(endpoint, token string) *Client { // - 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{ return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second}, httpClient: &http.Client{Timeout: 30 * time.Second},
endpoint: endpoint, endpoint: endpoint,
token: token, 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) --- // --- JSON-структуры (зеркало handler/functions.go и handler/triggers.go) ---
// FunctionRequest — тело POST/PUT /v1/namespaces/{ns}/functions[/{name}] // FunctionRequest — тело POST/PUT /v1/namespaces/{ns}/functions[/{name}]
@@ -1,8 +1,20 @@
// 2026-03-07 // 2026-03-11
// provider.go — описание провайдера sless для Terraform. // provider.go — описание провайдера sless для Terraform.
// Паттерн идентичен nubes провайдеру (NubesProvider) — для облегчения переноса. //
// Атрибуты: endpoint (URL оператора) + token (Bearer). // Архитектура инициализации (Configure):
// Env-переменные: SLESS_ENDPOINT, SLESS_API_TOKEN. // 1. Читаем token (JWT облака) — из конфига или env SLESS_API_TOKEN.
// 2. Декодируем JWT → извлекаем sub (уникальный ID пользователя в облаке).
// 3. Вычисляем namespace = SHA256(sub) → "sless-{hex}" (см. client.NamespaceFromSub).
// 4. Если задан nubes_endpoint — пингуем nubes API для валидации токена.
// 401/403 → ошибка инициализации; connection error → ошибка инициализации.
// 5. Создаём client.Client с вычисленным namespace.
//
// Атрибуты провайдера:
// endpoint — URL sless оператора (env: SLESS_ENDPOINT)
// token — JWT токен облака, общий для nubes и sless (env: SLESS_API_TOKEN)
// nubes_endpoint — URL nubes API для валидации токена (env: NUBES_ENDPOINT, опционально)
//
// Env-переменные: SLESS_ENDPOINT, SLESS_API_TOKEN, NUBES_ENDPOINT.
package provider package provider
import ( import (
@@ -28,8 +40,9 @@ type SlessProvider struct {
// SlessProviderModel — конфигурация блока provider {} в .tf файле. // SlessProviderModel — конфигурация блока provider {} в .tf файле.
type SlessProviderModel struct { type SlessProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"` Endpoint types.String `tfsdk:"endpoint"`
Token types.String `tfsdk:"token"` Token types.String `tfsdk:"token"`
NubesEndpoint types.String `tfsdk:"nubes_endpoint"`
} }
// New возвращает фабрику провайдера — точная копия паттерна nubes. // New возвращает фабрику провайдера — точная копия паттерна nubes.
@@ -48,20 +61,25 @@ func (p *SlessProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp
resp.Schema = schema.Schema{ resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{ Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{ "endpoint": schema.StringAttribute{
MarkdownDescription: "sless operator API endpoint, например http://sless-operator.sless.svc:9090", MarkdownDescription: "sless operator API endpoint, например https://sless-api.kube5s.ru",
Optional: true, Optional: true,
}, },
"token": schema.StringAttribute{ "token": schema.StringAttribute{
MarkdownDescription: "Bearer-токен аутентификации (env: SLESS_API_TOKEN)", MarkdownDescription: "JWT Bearer-токен облака (env: SLESS_API_TOKEN). Тот же токен что в provider \"nubes\".",
Optional: true, Optional: true,
Sensitive: true, Sensitive: true,
}, },
"nubes_endpoint": schema.StringAttribute{
MarkdownDescription: "URL nubes API для валидации токена (env: NUBES_ENDPOINT). Опционально. Пример: https://deck-api.ngcloud.ru/api/v1",
Optional: true,
},
}, },
} }
} }
// Configure инициализирует HTTP-клиент и кладёт его в ResourceData/DataSourceData. // Configure инициализирует HTTP-клиент и кладёт его в ResourceData/DataSourceData.
// Ресурсы получают клиент через Configure(req.ProviderData). // Ресурсы получают клиент через Configure(req.ProviderData).
// Порядок инициализации описан в комментарии к файлу (см. начало provider.go).
func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var config SlessProviderModel var config SlessProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
@@ -69,15 +87,16 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
return return
} }
// --- 1. Читаем endpoint sless оператора ---
endpoint := "http://localhost:9090" endpoint := "http://localhost:9090"
token := ""
if !config.Endpoint.IsNull() && config.Endpoint.ValueString() != "" { if !config.Endpoint.IsNull() && config.Endpoint.ValueString() != "" {
endpoint = config.Endpoint.ValueString() endpoint = config.Endpoint.ValueString()
} else if v := os.Getenv("SLESS_ENDPOINT"); v != "" { } else if v := os.Getenv("SLESS_ENDPOINT"); v != "" {
endpoint = v endpoint = v
} }
// --- 2. Читаем JWT токен ---
token := ""
if !config.Token.IsNull() { if !config.Token.IsNull() {
token = strings.TrimSpace(config.Token.ValueString()) token = strings.TrimSpace(config.Token.ValueString())
} }
@@ -87,7 +106,43 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq
} }
} }
c := client.New(endpoint, token) // --- 3. Декодируем JWT → sub → namespace ---
// Если токен не задан (dev-режим без токена) — используем fallback namespace.
// В production токен обязателен: без него нельзя определить namespace пользователя.
namespace := "sless-dev"
if token != "" {
sub, err := client.SubFromJWT(token)
if err != nil {
resp.Diagnostics.AddError(
"Invalid token",
"Cannot decode JWT token: "+err.Error(),
)
return
}
namespace = client.NamespaceFromSub(sub)
}
// --- 4. Пингуем nubes API для валидации токена (если задан nubes_endpoint) ---
// Если nubes_endpoint не задан — пропускаем проверку (dev-режим).
nubesEndpoint := ""
if !config.NubesEndpoint.IsNull() && config.NubesEndpoint.ValueString() != "" {
nubesEndpoint = config.NubesEndpoint.ValueString()
} else if v := os.Getenv("NUBES_ENDPOINT"); v != "" {
nubesEndpoint = v
}
if nubesEndpoint != "" && token != "" {
if err := client.PingNubesAPI(ctx, nubesEndpoint, token); err != nil {
resp.Diagnostics.AddError(
"nubes API validation failed",
err.Error(),
)
return
}
}
// --- 5. Создаём клиент с вычисленным namespace ---
c := client.New(endpoint, token, namespace)
resp.ResourceData = c resp.ResourceData = c
resp.DataSourceData = c resp.DataSourceData = c
} }