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
@@ -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
}