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,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()))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user