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()))