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.
// Принимает 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()))
+50 -3
View File
@@ -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)
}
+5
View File
@@ -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 {
+5
View File
@@ -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()))
+103 -9
View File
@@ -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}]
@@ -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
}