From f41cd39b2677900c94e5cad62dc6fbccc7ff2e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 11 Mar 2026 07:35:49 +0400 Subject: [PATCH] feat: namespace-per-user via JWT sub SHA256 + ensureNamespace in operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/api/handler/functions.go | 8 +- internal/api/handler/handler.go | 53 ++++++++- internal/api/handler/jobs.go | 5 + internal/api/handler/triggers.go | 5 + terraform/provider/internal/client/client.go | 112 ++++++++++++++++-- .../provider/internal/provider/provider.go | 77 ++++++++++-- 6 files changed, 236 insertions(+), 24 deletions(-) diff --git a/internal/api/handler/functions.go b/internal/api/handler/functions.go index 191edec..391d172 100644 --- a/internal/api/handler/functions.go +++ b/internal/api/handler/functions.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-11 // functions.go — CRUD handlers для Function CRD. // Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function. // 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 func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) { 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 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index c7c51c5..2f54ebd 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -1,16 +1,28 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-11 // Handler — общий контейнер зависимостей для всех REST handlers. // Все handlers получают доступ к k8s, S3 и Postgres через эту структуру. // Логирование через slog, маршрутизация через gorilla/mux. +// +// Архитектура namespace: +// - Каждый пользователь работает в своём k8s namespace. +// - Имя namespace вычисляется провайдером из JWT-токена (SHA256 от sub). +// - Оператор сам не создаёт namespace заранее — он делает это при первом +// Create-запросе через ensureNamespace(). +// - defaultNamespace используется только как fallback для dev/тестов, +// когда namespace не передан в URL. package handler import ( + "context" "encoding/json" "log/slog" "net/http" "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" "sigs.k8s.io/controller-runtime/pkg/client" @@ -18,6 +30,11 @@ import ( "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3" ) +// defaultNamespace — fallback namespace для dev/тестов. +// В production namespace определяется из JWT-токена провайдером. +// Все обращения к "default" строке идут через эту константу — одно место замены. +const defaultNamespace = "default" + // Handler содержит зависимости для всех REST-обработчиков. type Handler struct { K8s client.Client @@ -44,10 +61,40 @@ func pathVar(r *http.Request, key string) string { return mux.Vars(r)[key] } -// namespace читает {namespace} из пути, fallback — "default". +// namespace читает {namespace} из пути URL. +// Fallback — defaultNamespace (используется только в dev/тестах). func namespace(r *http.Request) string { if ns := mux.Vars(r)["namespace"]; 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) } diff --git a/internal/api/handler/jobs.go b/internal/api/handler/jobs.go index 30010c3..114cd24 100644 --- a/internal/api/handler/jobs.go +++ b/internal/api/handler/jobs.go @@ -64,6 +64,11 @@ func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse { // Создаёт FunctionJob CR. Оператор запустит k8s Job асинхронно. func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { 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 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/internal/api/handler/triggers.go b/internal/api/handler/triggers.go index b772d54..f3afbd9 100644 --- a/internal/api/handler/triggers.go +++ b/internal/api/handler/triggers.go @@ -74,6 +74,11 @@ func (h *Handler) ListTriggers(w http.ResponseWriter, r *http.Request) { // CreateTrigger — POST /v1/namespaces/{namespace}/triggers func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) { 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 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index 258f2a4..dce52c1 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -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}] diff --git a/terraform/provider/internal/provider/provider.go b/terraform/provider/internal/provider/provider.go index 3528966..c6cd2a0 100644 --- a/terraform/provider/internal/provider/provider.go +++ b/terraform/provider/internal/provider/provider.go @@ -1,8 +1,20 @@ -// 2026-03-07 +// 2026-03-11 // provider.go — описание провайдера sless для Terraform. -// Паттерн идентичен nubes провайдеру (NubesProvider) — для облегчения переноса. -// Атрибуты: endpoint (URL оператора) + token (Bearer). -// Env-переменные: SLESS_ENDPOINT, SLESS_API_TOKEN. +// +// Архитектура инициализации (Configure): +// 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 import ( @@ -28,8 +40,9 @@ type SlessProvider struct { // SlessProviderModel — конфигурация блока provider {} в .tf файле. type SlessProviderModel struct { - Endpoint types.String `tfsdk:"endpoint"` - Token types.String `tfsdk:"token"` + Endpoint types.String `tfsdk:"endpoint"` + Token types.String `tfsdk:"token"` + NubesEndpoint types.String `tfsdk:"nubes_endpoint"` } // New возвращает фабрику провайдера — точная копия паттерна nubes. @@ -48,20 +61,25 @@ func (p *SlessProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "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, }, "token": schema.StringAttribute{ - MarkdownDescription: "Bearer-токен аутентификации (env: SLESS_API_TOKEN)", + MarkdownDescription: "JWT Bearer-токен облака (env: SLESS_API_TOKEN). Тот же токен что в provider \"nubes\".", Optional: 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(req.ProviderData). +// Порядок инициализации описан в комментарии к файлу (см. начало provider.go). func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { var config SlessProviderModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) @@ -69,15 +87,16 @@ func (p *SlessProvider) Configure(ctx context.Context, req provider.ConfigureReq return } + // --- 1. Читаем endpoint sless оператора --- endpoint := "http://localhost:9090" - token := "" - if !config.Endpoint.IsNull() && config.Endpoint.ValueString() != "" { endpoint = config.Endpoint.ValueString() } else if v := os.Getenv("SLESS_ENDPOINT"); v != "" { endpoint = v } + // --- 2. Читаем JWT токен --- + token := "" if !config.Token.IsNull() { 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.DataSourceData = c }