From 617b02dbfb9036d4e22252f51773380e6b4433a4 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 19:52:42 +0700 Subject: [PATCH 1/5] docs: plan for Selectel IAM auth (Cloud DNS v2) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- .../plans/2026-07-04-selectel-iam-auth.md | 398 ++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-selectel-iam-auth.md diff --git a/docs/superpowers/plans/2026-07-04-selectel-iam-auth.md b/docs/superpowers/plans/2026-07-04-selectel-iam-auth.md new file mode 100644 index 0000000..034fbc0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-selectel-iam-auth.md @@ -0,0 +1,398 @@ +# Selectel IAM-авторизация (Cloud DNS v2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Заменить неработающую авторизацию Selectel (статический API-ключ → 401) на project-scoped IAM-токен: приложение само получает 24ч-токен из учётки сервисного пользователя, кэширует и обновляет. + +**Architecture:** Cloud DNS v2 (`/domains/v2`) требует `X-Auth-Token` = project IAM-токен, выдаваемый Identity API (Keystone v3) сервисному пользователю. Учётка теперь хранит зашифрованный JSON `{username, password, account_id, project_name}` в том же `provider_accounts.secret_enc` (схема БД не меняется). Selectel-клиент получает токен через `POST https://cloud.api.selcloud.ru/identity/v3/auth/tokens`, кэширует его в памяти по ключу учётки до `expires_at` минус запас. Креды проверяются пробным логином при добавлении учётки. + +**Tech Stack:** Go (net/http, crypto/aes-gcm cipher уже есть), React 19 + Vite + zod. + +## Global Constraints + +- Identity endpoint: `https://cloud.api.selcloud.ru/identity/v3/auth/tokens` (домен **selcloud.ru**). DNS v2 base: `https://api.selectel.ru/domains/v2` (не менять). +- Тело запроса токена (project-scoped) — строго по доке Selectel (см. Task 1 Step 3). +- Токен — в заголовке ответа `X-Subject-Token`; срок — в теле `token.expires_at` (ISO 8601 / RFC3339). Запас перед истечением: 5 минут. +- SECURITY: пароль сервисного пользователя — секрет. НИКОГДА не в логах, не в HTTP-ответах, не в текстах ошибок. `accountResponse` уже исключает secret — не регрессировать. При ошибке Identity/DNS не включать тело с кредами в возвращаемую ошибку. +- Секрет учётки шифруется целиком (весь JSON) существующим `crypto.Cipher`. Кэш токенов — только в памяти, не в БД. +- Комментарии в Go — на английском (как в файлах), в web — как в окружающих файлах. +- TDD: тест падает → минимальная реализация → тест проходит → commit. НЕ коммитить реальную сборку `internal/web/dist/*` (перед коммитом `git checkout internal/web/dist/index.html`). + +--- + +### Task 1: Selectel IAM-аутентификация + кэш токена + +**Files:** +- Modify: `internal/provider/provider.go`, `internal/provider/selectel/selectel.go`, `internal/provider/registry/registry_test.go` (fakeProvider += Validate), `cmd/server/main.go` (если New() сигнатура) +- Test: `internal/provider/selectel/selectel_test.go` (создать, если нет — проверь) + +**Interfaces:** +- Produces: `provider.Provider` получает метод `Validate(ctx, Credentials) error`; `selectel.Creds{Username,Password,AccountID,ProjectName}` (JSON-теги `username/password/account_id/project_name`); `selectel.Client` кэширует IAM-токен и шлёт его как `X-Auth-Token` в v2. +- Consumes: `provider.Credentials.Secret` теперь = расшифрованный provider-specific JSON (для Selectel — Creds). + +- [ ] **Step 1: Тест — Credentials парсится, Validate делает пробный логин** + +В `internal/provider/selectel/selectel_test.go`: подними `httptest.Server`, отвечающий на `POST /identity/v3/auth/tokens` заголовком `X-Subject-Token: tok-1` и телом `{"token":{"expires_at":""}}`, статус 201. Настрой `Client{IdentityURL: srv.URL+"/identity/v3/auth/tokens", ...}`. Проверь: `Validate(ctx, Credentials{Secret: `{"username":"u","password":"p","account_id":"123","project_name":"proj"}`})` возвращает nil; при 401 от Identity — ошибку. Run: `go test ./internal/provider/selectel/... -run Validate -v`. Ожидание: FAIL (метод/поля не существуют). + +- [ ] **Step 2: Тип Creds + парсинг** + +В `selectel.go` добавь: +```go +// Creds is the Selectel service-user credential set, stored as the encrypted +// JSON blob in provider_accounts.secret_enc and parsed from Credentials.Secret. +type Creds struct { + Username string `json:"username"` + Password string `json:"password"` + AccountID string `json:"account_id"` + ProjectName string `json:"project_name"` +} + +func parseCreds(secret string) (Creds, error) { + var c Creds + if err := json.Unmarshal([]byte(secret), &c); err != nil { + return Creds{}, fmt.Errorf("selectel: invalid credentials format") + } + if c.Username == "" || c.Password == "" || c.AccountID == "" || c.ProjectName == "" { + return Creds{}, fmt.Errorf("selectel: username, password, account_id and project_name are required") + } + return c, nil +} +``` + +- [ ] **Step 3: Token-manager (authenticate + cache)** + +Расширь `Client`: +```go +const ( + DefaultBaseURL = "https://api.selectel.ru/domains/v2" + DefaultIdentityURL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens" + tokenLeeway = 5 * time.Minute +) + +type Client struct { + BaseURL string + IdentityURL string + HTTP *http.Client + + mu sync.Mutex + tokens map[string]cachedToken +} + +type cachedToken struct { + token string + expires time.Time +} + +func New() *Client { + return &Client{ + BaseURL: DefaultBaseURL, + IdentityURL: DefaultIdentityURL, + HTTP: &http.Client{Timeout: 30 * time.Second}, + tokens: map[string]cachedToken{}, + } +} +``` + +`cacheKey` — по неизменяемой части учётки (без пароля в открытом виде в ключе; пароль включаем как часть идентичности через хеш, чтобы смена пароля инвалидировала запись): +```go +func cacheKey(c Creds) string { + sum := sha256.Sum256([]byte(c.AccountID + "\x00" + c.ProjectName + "\x00" + c.Username + "\x00" + c.Password)) + return hex.EncodeToString(sum[:]) +} +``` + +`authenticate` — POST в Identity, вернуть токен и срок: +```go +func (c *Client) authenticate(ctx context.Context, cr Creds) (string, time.Time, error) { + body := map[string]any{ + "auth": map[string]any{ + "identity": map[string]any{ + "methods": []string{"password"}, + "password": map[string]any{ + "user": map[string]any{ + "name": cr.Username, + "domain": map[string]any{"name": cr.AccountID}, + "password": cr.Password, + }, + }, + }, + "scope": map[string]any{ + "project": map[string]any{ + "name": cr.ProjectName, + "domain": map[string]any{"name": cr.AccountID}, + }, + }, + }, + } + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IdentityURL, bytes.NewReader(b)) + if err != nil { + return "", time.Time{}, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + // Never wrap the error with request/body (contains password). + return "", time.Time{}, fmt.Errorf("selectel: identity request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return "", time.Time{}, fmt.Errorf("selectel: identity auth failed: %d", resp.StatusCode) + } + tok := resp.Header.Get("X-Subject-Token") + if tok == "" { + return "", time.Time{}, fmt.Errorf("selectel: identity returned no token") + } + var out struct { + Token struct { + ExpiresAt string `json:"expires_at"` + } `json:"token"` + } + // Body may be absent/short — a decode error is non-fatal; fall back to a + // conservative 1h TTL so the token is still usable and refreshed sooner. + exp := time.Time{} + if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && out.Token.ExpiresAt != "" { + if t, perr := time.Parse(time.RFC3339, out.Token.ExpiresAt); perr == nil { + exp = t + } + } + return tok, exp, nil +} +``` + +`token` — кэш с запасом; `now` инъектируется для тестируемости через поле или передаётся. Используй `time.Now()` напрямую (в тестах играй сроком expires): +```go +func (c *Client) token(ctx context.Context, cr Creds) (string, error) { + key := cacheKey(cr) + c.mu.Lock() + if ct, ok := c.tokens[key]; ok && (ct.expires.IsZero() || time.Now().Add(tokenLeeway).Before(ct.expires)) { + c.mu.Unlock() + return ct.token, nil + } + c.mu.Unlock() + + tok, exp, err := c.authenticate(ctx, cr) + if err != nil { + return "", err + } + // Zero expiry (unparseable) → treat as short-lived: cache for 1h from now. + if exp.IsZero() { + exp = time.Now().Add(time.Hour) + } + c.mu.Lock() + c.tokens[key] = cachedToken{token: tok, expires: exp} + c.mu.Unlock() + return tok, nil +} +``` +(Добавь импорты: `sync`, `crypto/sha256`, `encoding/hex`, `time` — часть уже есть.) + +- [ ] **Step 4: Validate + подключение токена в методы** + +```go +func (c *Client) Validate(ctx context.Context, creds provider.Credentials) error { + cr, err := parseCreds(creds.Secret) + if err != nil { + return err + } + if _, err := c.token(ctx, cr); err != nil { + return err + } + return nil +} +``` + +В `ListZones`, `GetRecords`→`listRRSets`, `ApplyChanges`: в начале `cr, err := parseCreds(creds.Secret)`; `tok, err := c.token(ctx, cr)`; далее использовать `tok` там, где сейчас передаётся `creds.Secret` в `c.do(...)`. `listRRSets`/`do` уже принимают token-строку — передавать `tok` вместо расшифрованного secret. Сигнатуры `listRRSets(ctx, token, zoneID)` и `do(...token...)` НЕ менять — они и так работают с токеном. + +Проверь на 401-refresh: если хочешь (опционально, вне минимума) — при 401 от v2 сбросить кэш и повторить один раз. НЕ добавляй, если усложняет; базовый кэш-с-запасом достаточен для плана. + +- [ ] **Step 5: provider.Provider += Validate; fakeProvider в registry_test** + +`provider.go`: +```go +// Credentials holds the provider-specific secret. It is stored encrypted and, +// once decrypted, is a provider-defined value — for Selectel a JSON blob with +// the service-user credentials (see selectel.Creds). +type Credentials struct { + Secret string +} +``` +Добавь в интерфейс `Provider`: +```go + // Validate checks the credentials are usable (e.g. a trial auth), so a + // bad account is rejected at creation time rather than at first import. + Validate(ctx context.Context, creds Credentials) error +``` +В `internal/provider/registry/registry_test.go` добавь методу `fakeProvider` реализацию: +```go +func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil } +``` + +- [ ] **Step 6: Тесты — кэш переиспользуется/рефрешится, ListZones шлёт полученный токен** + +В `selectel_test.go`: счётчик обращений к Identity. Проверь: (а) два `token()` подряд с валидным сроком → 1 обращение к Identity (кэш); (б) `expires_at` в прошлом (или в пределах leeway) → повторный `token()` снова логинится; (в) `ListZones` шлёт на `/zones` заголовок `X-Auth-Token: tok-1` (значение от Identity, НЕ сырые creds) — эмулируй оба эндпоинта на одном httptest.Server, роутинг по пути. + +- [ ] **Step 7: Прогон и коммит** + +Run: `go build ./... && go test ./internal/provider/...`. Ожидание PASS. +```bash +git add internal/provider/ cmd/server/ +git commit -m "feat(selectel): project-scoped IAM auth with token cache; provider Validate" +``` + +--- + +### Task 2: API — приём структурированной учётки + валидация при создании + +**Files:** +- Modify: `internal/api/tenant_dto.go` (accountRequest.Secret → json.RawMessage), `internal/api/tenant_handlers.go` (handleCreateAccount), `internal/api/api.go` (если нужен доступ к Reg — уже есть a.Reg) +- Test: `internal/api/tenant_test.go` (или соответствующий) + +**Interfaces:** +- Consumes: `provider.Provider.Validate` (Task 1); `a.Reg.ByName`; `a.Cipher.Encrypt`. +- Produces: `POST /accounts` принимает `{provider, comment, secret: }`; невалидные креды → 400 до сохранения. + +- [ ] **Step 1: Тест — невалидные креды дают 400, валидные 201 + шифрование** + +В `tenant_test.go`: mock provider registry, где `Validate` возвращает ошибку для одного входа и nil для другого (или мок-провайдер с настраиваемым validateErr). POST `/accounts` с `secret` = JSON-объект. Ожидание: при validateErr → 400 и `CreateAccount` НЕ вызван; при nil → 201, `Cipher.Encrypt` получил сырой JSON, `CreateAccount` вызван. Run: `go test ./internal/api/... -run CreateAccount -v`. Ожидание: FAIL (Secret ещё string, валидации нет). + +- [ ] **Step 2: accountRequest.Secret → json.RawMessage** + +`tenant_dto.go`: +```go +type accountRequest struct { + Provider string `json:"provider"` + Secret json.RawMessage `json:"secret"` + Comment string `json:"comment"` +} +``` +(добавь импорт `encoding/json`.) + +- [ ] **Step 3: handleCreateAccount — валидация перед сохранением** + +```go +func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) { + pid, _ := projectIDFrom(r.Context()) + var req accountRequest + if !decodeBody(w, r, &req) { + return + } + if req.Provider == "" || len(req.Secret) == 0 { + writeErr(w, http.StatusBadRequest, "provider and secret are required") + return + } + p, err := a.Reg.ByName(req.Provider) + if err != nil { + writeErr(w, http.StatusBadRequest, "unknown provider") + return + } + // Trial auth up-front so bad credentials fail at creation, not at import. + // The error text is deliberately generic — never echo the secret back. + if err := p.Validate(r.Context(), provider.Credentials{Secret: string(req.Secret)}); err != nil { + writeErr(w, http.StatusBadRequest, "invalid provider credentials") + return + } + secretEnc, err := a.Cipher.Encrypt(req.Secret) + if err != nil { + log.Printf("api: encrypt secret failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + acc, err := a.Store.CreateAccount(r.Context(), pid, req.Provider, secretEnc, req.Comment) + if err != nil { + log.Printf("api: create account failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusCreated, toAccountResponse(acc)) +} +``` +(`a.Cipher.Encrypt` принимает `[]byte`; `req.Secret` — `json.RawMessage` = `[]byte`, передавай напрямую. Убедись, что `provider` импортирован в tenant_handlers.go — уже да.) + +- [ ] **Step 4: Прогон и коммит** + +Run: `go build ./... && go test ./internal/api/...`. Ожидание PASS. +```bash +git add internal/api/ +git commit -m "feat(api): structured provider credentials + trial-auth validation on account create" +``` + +--- + +### Task 3: Frontend — форма сервисного пользователя Selectel + +**Files:** +- Modify: `web/src/api/types.ts` (CreateAccountInput.secret → object), `web/src/pages/AccountsPage.tsx` (форма 4 поля + zod + инструкция), при необходимости `web/src/api/client.ts` (не должен меняться — шлёт объект как есть) +- Test: `web/src/pages/AccountsPage.test.tsx` (создать/обновить) + +**Interfaces:** +- Consumes: `POST /accounts` с `{provider, comment, secret:{username,password,account_id,project_name}}`; 400 при неверных кредах. +- Produces: форма с 4 полями; пароль type=password; ошибка валидации из 400 показывается. + +- [ ] **Step 1: types.ts** + +```ts +export interface SelectelSecret { + username: string + password: string + account_id: string + project_name: string +} +export interface CreateAccountInput { provider: string; secret: SelectelSecret; comment: string } +``` +(`client.ts` `createAccount` уже сериализует `input` целиком — менять не нужно; проверь.) + +- [ ] **Step 2: AccountsPage — форма и zod** + +Заменить одиночное поле `secret` на 4: `username`, `password` (input type=password), `accountId`, `projectName`. zod-схема: +```ts +const schema = z.object({ + provider: z.literal("selectel"), + username: z.string().min(1, "Укажите имя сервисного пользователя"), + password: z.string().min(1, "Укажите пароль"), + accountId: z.string().min(1, "Укажите номер аккаунта"), + projectName: z.string().min(1, "Укажите имя проекта"), + comment: z.string().optional().default(""), +}) +``` +При сабмите собрать `secret`: +```ts +createAccount({ + provider: "selectel", + comment: values.comment ?? "", + secret: { + username: values.username.trim(), + password: values.password, + account_id: values.accountId.trim(), + project_name: values.projectName.trim(), + }, +}) +``` +`defaultValues`/`reset` обновить на новые поля. Каждое поле — свой `FieldLabel`/`FieldError`, `noValidate` на форме (zod валидирует). + +- [ ] **Step 3: Инструкция + обработка ошибки 400** + +Заменить текст-подсказку: вместо «раздел API-ключи» — создать **сервисного пользователя** в панели Selectel, выдать ему роль на нужный проект, указать логин/пароль/номер аккаунта/имя проекта. Ссылка — на раздел «Пользователи и роли» (`https://my.selectel.ru/iam/users`). Явно: пароль хранится в зашифрованном виде. +Ошибку мутации (400 «invalid provider credentials») показать как алерт с текстом вроде «Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта». Использовать существующий паттерн отображения ошибки мутации на странице (посмотри как показаны ошибки в других формах, напр. ChannelsPage). + +- [ ] **Step 4: Тест** + +`AccountsPage.test.tsx`: (а) сабмит с пустыми полями → показываются zod-ошибки, `createAccount` не вызван; (б) заполнение 4 полей + submit → `createAccount` вызван с `secret:{username,password,account_id,project_name}` и правильным маппингом; (в) пароль-поле имеет `type="password"`. Обернуть в существующие провайдеры (AuthProvider+QueryClient) как в других тестах. + +- [ ] **Step 5: Прогон и коммит** + +Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`. Ожидание PASS. +```bash +cd .. && git checkout internal/web/dist/index.html +git add web/src/ +git commit -m "feat(web): Selectel service-user account form (IAM credentials)" +``` + +--- + +## Итоговая проверка + +- `go build ./... && go test ./...` — PASS. +- `cd web && npm run test -- --run && npm run build` — PASS. +- Ручная: пересоздать учётку Selectel с логином/паролем/аккаунтом/проектом сервисного пользователя → добавление проходит валидацию → «Импортировать зоны» возвращает список зон без 401. +- SECURITY: пароль не в логах/ответах/ошибках; кэш токенов только в памяти; `accountResponse` без secret. From 32107571d14c638327b9c299d71836d380baf9ba Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 20:02:36 +0700 Subject: [PATCH 2/5] feat(selectel): project-scoped IAM auth with token cache; provider Validate Selectel Cloud DNS v2 requires a project IAM token in X-Auth-Token, not the raw service-user secret; the previous client sent the static secret directly and got 401. The client now parses Credentials.Secret as a Creds JSON blob (username/password/account_id/project_name), exchanges it for a token via the Identity API (POST /identity/v3/auth/tokens), and caches the token in memory per-account until 5 minutes before expiry. ListZones/GetRecords/ ApplyChanges send the cached IAM token instead of the raw secret. provider.Provider gains a Validate(ctx, Credentials) method so a bad account can be rejected via trial login at creation time; all Provider fakes across provider/registry/api/service test packages implement it as a no-op stub for now (Task 2 will make api's mock configurable). Security: the service-user password is folded into the token cache key via SHA-256 (never stored in the clear) so a password change invalidates the cached token; identity errors are generic and never echo the request body. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/api/tenant_test.go | 1 + internal/provider/provider.go | 8 +- internal/provider/provider_test.go | 1 + internal/provider/registry/registry_test.go | 1 + internal/provider/selectel/selectel.go | 195 ++++++++++++++- internal/provider/selectel/selectel_test.go | 254 ++++++++++++++++---- internal/service/service_test.go | 1 + 7 files changed, 403 insertions(+), 58 deletions(-) diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 73c9f89..a3a6db7 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -161,6 +161,7 @@ func (mockProvider) GetRecords(context.Context, provider.Credentials, string) ([ func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error { return nil } +func (mockProvider) Validate(context.Context, provider.Credentials) error { return nil } // newTenantTestAPI wires a fixed authenticated user who owns whatever // project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f2a8f23..28287a8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,8 +7,9 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/model" ) -// Credentials holds the secret used to authenticate against a provider. -// For Selectel this is the project-scoped token sent as X-Auth-Token. +// Credentials holds the provider-specific secret. It is stored encrypted and, +// once decrypted, is a provider-defined value — for Selectel a JSON blob with +// the service-user credentials (see selectel.Creds). type Credentials struct { Secret string } @@ -25,4 +26,7 @@ type Provider interface { ListZones(ctx context.Context, creds Credentials) ([]Zone, error) GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error) ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error + // Validate checks the credentials are usable (e.g. a trial auth), so a + // bad account is rejected at creation time rather than at first import. + Validate(ctx context.Context, creds Credentials) error } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 800aad0..479b297 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -21,6 +21,7 @@ func (stubProvider) GetRecords(context.Context, Credentials, string) ([]model.Re func (stubProvider) ApplyChanges(context.Context, Credentials, string, diff.Changeset) error { return nil } +func (stubProvider) Validate(context.Context, Credentials) error { return nil } func TestProviderInterfaceSatisfied(t *testing.T) { var p Provider = stubProvider{} diff --git a/internal/provider/registry/registry_test.go b/internal/provider/registry/registry_test.go index b720b3c..2ad7638 100644 --- a/internal/provider/registry/registry_test.go +++ b/internal/provider/registry/registry_test.go @@ -21,6 +21,7 @@ func (fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([ func (fakeProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error { return nil } +func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil } func TestRegistryByName(t *testing.T) { r := New() diff --git a/internal/provider/selectel/selectel.go b/internal/provider/selectel/selectel.go index 7056039..6add0e6 100644 --- a/internal/provider/selectel/selectel.go +++ b/internal/provider/selectel/selectel.go @@ -3,11 +3,14 @@ package selectel import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" + "sync" "time" "github.com/vasyakrg/dns-autoresolver/internal/diff" @@ -15,20 +18,166 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/provider" ) -const DefaultBaseURL = "https://api.selectel.ru/domains/v2" +const ( + DefaultBaseURL = "https://api.selectel.ru/domains/v2" + DefaultIdentityURL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens" + tokenLeeway = 5 * time.Minute +) -// Client implements provider.Provider for Selectel DNS API v2. +// Client implements provider.Provider for Selectel DNS API v2. Credentials +// are a service-user login (see Creds); the client exchanges them for a +// project-scoped IAM token via the Identity API and caches it in memory. type Client struct { - BaseURL string - HTTP *http.Client + BaseURL string + IdentityURL string + HTTP *http.Client + + mu sync.Mutex + tokens map[string]cachedToken +} + +// cachedToken is an in-memory IAM token with its expiry, keyed by cacheKey. +type cachedToken struct { + token string + expires time.Time +} + +// Creds is the Selectel service-user credential set, stored as the encrypted +// JSON blob in provider_accounts.secret_enc and parsed from Credentials.Secret. +type Creds struct { + Username string `json:"username"` + Password string `json:"password"` + AccountID string `json:"account_id"` + ProjectName string `json:"project_name"` +} + +func parseCreds(secret string) (Creds, error) { + var c Creds + if err := json.Unmarshal([]byte(secret), &c); err != nil { + return Creds{}, fmt.Errorf("selectel: invalid credentials format") + } + if c.Username == "" || c.Password == "" || c.AccountID == "" || c.ProjectName == "" { + return Creds{}, fmt.Errorf("selectel: username, password, account_id and project_name are required") + } + return c, nil } func New() *Client { - return &Client{BaseURL: DefaultBaseURL, HTTP: &http.Client{Timeout: 30 * time.Second}} + return &Client{ + BaseURL: DefaultBaseURL, + IdentityURL: DefaultIdentityURL, + HTTP: &http.Client{Timeout: 30 * time.Second}, + tokens: map[string]cachedToken{}, + } } func (c *Client) Name() string { return "selectel" } +// cacheKey identifies a service-user identity for the in-memory token cache. +// The password is folded in via hash (never in the clear) so that a password +// change invalidates any previously cached token for the same account/user. +func cacheKey(c Creds) string { + sum := sha256.Sum256([]byte(c.AccountID + "\x00" + c.ProjectName + "\x00" + c.Username + "\x00" + c.Password)) + return hex.EncodeToString(sum[:]) +} + +// authenticate exchanges the service-user credentials for a project-scoped +// IAM token via the Identity API (Keystone v3). SECURITY: the request body +// contains the password — errors must never wrap or echo it back. +func (c *Client) authenticate(ctx context.Context, cr Creds) (string, time.Time, error) { + body := map[string]any{ + "auth": map[string]any{ + "identity": map[string]any{ + "methods": []string{"password"}, + "password": map[string]any{ + "user": map[string]any{ + "name": cr.Username, + "domain": map[string]any{"name": cr.AccountID}, + "password": cr.Password, + }, + }, + }, + "scope": map[string]any{ + "project": map[string]any{ + "name": cr.ProjectName, + "domain": map[string]any{"name": cr.AccountID}, + }, + }, + }, + } + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IdentityURL, bytes.NewReader(b)) + if err != nil { + return "", time.Time{}, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + // Never wrap the error with request/body (contains password). + return "", time.Time{}, fmt.Errorf("selectel: identity request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return "", time.Time{}, fmt.Errorf("selectel: identity auth failed: %d", resp.StatusCode) + } + tok := resp.Header.Get("X-Subject-Token") + if tok == "" { + return "", time.Time{}, fmt.Errorf("selectel: identity returned no token") + } + var out struct { + Token struct { + ExpiresAt string `json:"expires_at"` + } `json:"token"` + } + // Body may be absent/short — a decode error is non-fatal; fall back to a + // conservative 1h TTL so the token is still usable and refreshed sooner. + exp := time.Time{} + if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && out.Token.ExpiresAt != "" { + if t, perr := time.Parse(time.RFC3339, out.Token.ExpiresAt); perr == nil { + exp = t + } + } + return tok, exp, nil +} + +// token returns a cached, still-valid IAM token for cr, re-authenticating +// when there is no cached entry or it is within tokenLeeway of expiring. +func (c *Client) token(ctx context.Context, cr Creds) (string, error) { + key := cacheKey(cr) + c.mu.Lock() + if ct, ok := c.tokens[key]; ok && (ct.expires.IsZero() || time.Now().Add(tokenLeeway).Before(ct.expires)) { + c.mu.Unlock() + return ct.token, nil + } + c.mu.Unlock() + + tok, exp, err := c.authenticate(ctx, cr) + if err != nil { + return "", err + } + // Zero expiry (unparseable) → treat as short-lived: cache for 1h from now. + if exp.IsZero() { + exp = time.Now().Add(time.Hour) + } + c.mu.Lock() + c.tokens[key] = cachedToken{token: tok, expires: exp} + c.mu.Unlock() + return tok, nil +} + +// Validate performs a trial login so bad credentials are rejected at account +// creation time rather than at first import. +func (c *Client) Validate(ctx context.Context, creds provider.Credentials) error { + cr, err := parseCreds(creds.Secret) + if err != nil { + return err + } + if _, err := c.token(ctx, cr); err != nil { + return err + } + return nil +} + // --- wire types --- type apiZone struct { @@ -92,12 +241,20 @@ func (c *Client) do(ctx context.Context, method, path, token string, body any, o // --- Provider implementation --- func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]provider.Zone, error) { + cr, err := parseCreds(creds.Secret) + if err != nil { + return nil, err + } + tok, err := c.token(ctx, cr) + if err != nil { + return nil, err + } var zones []provider.Zone offset := 0 for { var page apiZoneList path := fmt.Sprintf("/zones?limit=1000&offset=%d", offset) - if err := c.do(ctx, http.MethodGet, path, creds.Secret, nil, &page); err != nil { + if err := c.do(ctx, http.MethodGet, path, tok, nil, &page); err != nil { return nil, err } for _, z := range page.Result { @@ -116,7 +273,15 @@ func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]p } func (c *Client) GetRecords(ctx context.Context, creds provider.Credentials, zoneID string) ([]model.Record, error) { - rrsets, err := c.listRRSets(ctx, creds.Secret, zoneID) + cr, err := parseCreds(creds.Secret) + if err != nil { + return nil, err + } + tok, err := c.token(ctx, cr) + if err != nil { + return nil, err + } + rrsets, err := c.listRRSets(ctx, tok, zoneID) if err != nil { return nil, err } @@ -150,8 +315,16 @@ func (c *Client) listRRSets(ctx context.Context, token, zoneID string) ([]apiRRS } func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, zoneID string, cs diff.Changeset) error { + cr, err := parseCreds(creds.Secret) + if err != nil { + return err + } + tok, err := c.token(ctx, cr) + if err != nil { + return err + } // resolve rrset ids for update/delete - existing, err := c.listRRSets(ctx, creds.Secret, zoneID) + existing, err := c.listRRSets(ctx, tok, zoneID) if err != nil { return err } @@ -170,7 +343,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z if d.Desired == nil { return fmt.Errorf("selectel: add/update diff without Desired record") } - if err := c.do(ctx, http.MethodPost, base, creds.Secret, toRRSet(*d.Desired), nil); err != nil { + if err := c.do(ctx, http.MethodPost, base, tok, toRRSet(*d.Desired), nil); err != nil { return err } case diff.Update: @@ -181,7 +354,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z if !ok { return fmt.Errorf("cannot update: rrset %s not found in zone", d.Desired.Key()) } - if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), creds.Secret, toRRSet(*d.Desired), nil); err != nil { + if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), tok, toRRSet(*d.Desired), nil); err != nil { return err } case diff.Delete: @@ -192,7 +365,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z if !ok { return fmt.Errorf("cannot delete: rrset %s not found in zone", d.Actual.Key()) } - if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), creds.Secret, nil, nil); err != nil { + if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), tok, nil, nil); err != nil { return err } } diff --git a/internal/provider/selectel/selectel_test.go b/internal/provider/selectel/selectel_test.go index a3a884d..91ca44f 100644 --- a/internal/provider/selectel/selectel_test.go +++ b/internal/provider/selectel/selectel_test.go @@ -14,16 +14,172 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/provider" ) -func creds() provider.Credentials { return provider.Credentials{Secret: "secret-token"} } +// testIdentityToken is the IAM token handed out by identityOKHandler. Tests +// assert on this value (not the raw secret) to prove the client sends the +// token obtained from the Identity API, never the service-user password. +const testIdentityToken = "tok-1" -func newTestClient(h http.Handler) (*Client, *httptest.Server) { - srv := httptest.NewServer(h) - return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv +// testCreds returns valid Selectel service-user credentials (the decrypted +// JSON blob provider.Credentials.Secret carries for this provider). +func testCreds() provider.Credentials { + return provider.Credentials{Secret: `{"username":"u","password":"p","account_id":"123","project_name":"proj"}`} } +// identityOKHandler emulates a healthy Identity API: 201 Created with the +// token in the X-Subject-Token header and expires_at far in the future. +func identityOKHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Subject-Token", testIdentityToken) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": map[string]any{"expires_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339)}, + }) +} + +// newTestClient wires a single httptest.Server that routes the Identity +// endpoint to identityHandler and everything else (the DNS v2 API) to +// v2Handler, and returns a Client pointed at it. +func newTestClient(identityHandler, v2Handler http.HandlerFunc) (*Client, *httptest.Server) { + mux := http.NewServeMux() + mux.HandleFunc("/identity/v3/auth/tokens", identityHandler) + mux.HandleFunc("/", v2Handler) + srv := httptest.NewServer(mux) + c := &Client{ + BaseURL: srv.URL, + IdentityURL: srv.URL + "/identity/v3/auth/tokens", + HTTP: srv.Client(), + tokens: map[string]cachedToken{}, + } + return c, srv +} + +// --- Step 1: Validate performs a trial login against Identity --- + +func TestValidateSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/identity/v3/auth/tokens" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + identityOKHandler(w, r) + })) + defer srv.Close() + + c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}} + if err := c.Validate(context.Background(), testCreds()); err != nil { + t.Fatalf("Validate: unexpected error: %v", err) + } +} + +func TestValidateIdentityUnauthorizedReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}} + if err := c.Validate(context.Background(), testCreds()); err == nil { + t.Fatal("expected error when identity rejects credentials with 401") + } +} + +func TestValidateInvalidCredentialsFormatReturnsErrorWithoutCallingIdentity(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}} + err := c.Validate(context.Background(), provider.Credentials{Secret: `{"username":"u"}`}) + if err == nil { + t.Fatal("expected error for incomplete credentials") + } + if called { + t.Fatal("identity must not be called for malformed/incomplete credentials") + } +} + +// --- Step 6: token cache reuse / refresh, and ListZones sends the IAM token --- + +func TestTokenCachedAcrossCalls(t *testing.T) { + var identityHits int + c, srv := newTestClient( + func(w http.ResponseWriter, r *http.Request) { + identityHits++ + identityOKHandler(w, r) + }, + func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, + ) + defer srv.Close() + + cr, err := parseCreds(testCreds().Secret) + if err != nil { + t.Fatal(err) + } + if _, err := c.token(context.Background(), cr); err != nil { + t.Fatal(err) + } + if _, err := c.token(context.Background(), cr); err != nil { + t.Fatal(err) + } + if identityHits != 1 { + t.Fatalf("expected a single Identity call thanks to caching, got %d", identityHits) + } +} + +func TestTokenRefreshesWhenExpiring(t *testing.T) { + var identityHits int + c, srv := newTestClient( + func(w http.ResponseWriter, r *http.Request) { + identityHits++ + w.Header().Set("X-Subject-Token", testIdentityToken) + w.WriteHeader(http.StatusCreated) + // expires_at already inside the leeway window -> every token() call + // must re-authenticate instead of serving a near-expiry cache hit. + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": map[string]any{"expires_at": time.Now().Add(-time.Minute).Format(time.RFC3339)}, + }) + }, + func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, + ) + defer srv.Close() + + cr, err := parseCreds(testCreds().Secret) + if err != nil { + t.Fatal(err) + } + if _, err := c.token(context.Background(), cr); err != nil { + t.Fatal(err) + } + if _, err := c.token(context.Background(), cr); err != nil { + t.Fatal(err) + } + if identityHits != 2 { + t.Fatalf("expected re-authentication on every call while token is within leeway of expiry, got %d hits", identityHits) + } +} + +func TestListZonesSendsIAMTokenNotRawSecret(t *testing.T) { + var gotToken string + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { + gotToken = r.Header.Get("X-Auth-Token") + _ = json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0}) + }) + defer srv.Close() + + if _, err := c.ListZones(context.Background(), testCreds()); err != nil { + t.Fatal(err) + } + if gotToken != testIdentityToken { + t.Fatalf("expected IAM token %q sent as X-Auth-Token, got %q", testIdentityToken, gotToken) + } +} + +// --- existing behavior, now exercised through the Identity + v2 flow --- + func TestListZonesSendsTokenAndParses(t *testing.T) { var gotToken string - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { gotToken = r.Header.Get("X-Auth-Token") json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ @@ -32,14 +188,14 @@ func TestListZonesSendsTokenAndParses(t *testing.T) { }, "next_offset": 0, }) - })) + }) defer srv.Close() - zs, err := c.ListZones(context.Background(), creds()) + zs, err := c.ListZones(context.Background(), testCreds()) if err != nil { t.Fatal(err) } - if gotToken != "secret-token" { + if gotToken != testIdentityToken { t.Fatalf("token not sent, got %q", gotToken) } if len(zs) != 2 || zs[0].ID != "z1" || zs[1].Name != "test.org." { @@ -48,7 +204,7 @@ func TestListZonesSendsTokenAndParses(t *testing.T) { } func TestGetRecordsMapsRRSet(t *testing.T) { - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ {"id": "r1", "name": "example.com.", "type": "MX", "ttl": 3600, @@ -58,10 +214,10 @@ func TestGetRecordsMapsRRSet(t *testing.T) { }, "next_offset": 0, }) - })) + }) defer srv.Close() - recs, err := c.GetRecords(context.Background(), creds(), "z1") + recs, err := c.GetRecords(context.Background(), testCreds(), "z1") if err != nil { t.Fatal(err) } @@ -83,7 +239,7 @@ func TestGetRecordsMapsRRSet(t *testing.T) { func TestApplyChangesRoutesVerbs(t *testing.T) { type call struct{ method, path string } var calls []call - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { // GET rrset -> return existing set with ids for update/delete resolution if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{ @@ -99,7 +255,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) { } calls = append(calls, call{r.Method, r.URL.Path}) w.WriteHeader(http.StatusOK) - })) + }) defer srv.Close() add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}} @@ -114,7 +270,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) { {Kind: diff.Update, Type: ns.Type, Name: ns.Name, Desired: &ns, ReadOnly: true}, // must be skipped }} - if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil { + if err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs); err != nil { t.Fatal(err) } @@ -136,7 +292,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) { // Global Constraint: id not found for Update -> error, and mutation must not proceed. func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) { var calls []string - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { // empty existing rrset set -> nothing resolves to an id json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0}) @@ -144,7 +300,7 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) } calls = append(calls, r.Method+" "+r.URL.Path) w.WriteHeader(http.StatusOK) - })) + }) defer srv.Close() missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}} @@ -155,7 +311,7 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) {Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add}, }} - err := c.ApplyChanges(context.Background(), creds(), "z1", cs) + err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs) if err == nil { t.Fatal("expected non-nil error when update rrset id is not found") } @@ -166,13 +322,13 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) // Global Constraint: id not found for Delete -> error. func TestApplyChangesDeleteIDNotFoundReturnsError(t *testing.T) { - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0}) return } t.Fatalf("unexpected mutating call %s %s, delete should have errored before reaching HTTP", r.Method, r.URL.Path) - })) + }) defer srv.Close() missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}} @@ -180,15 +336,16 @@ func TestApplyChangesDeleteIDNotFoundReturnsError(t *testing.T) { {Kind: diff.Delete, Type: missing.Type, Name: missing.Name, Actual: &missing}, }} - if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err == nil { + if err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs); err == nil { t.Fatal("expected non-nil error when delete rrset id is not found") } } -// Global Constraint: X-Auth-Token must be sent on mutating requests (POST/PATCH/DELETE), not only on GET. +// Global Constraint: X-Auth-Token (the IAM token, not the raw secret) must be +// sent on mutating requests (POST/PATCH/DELETE), not only on GET. func TestApplyChangesSendsTokenOnMutations(t *testing.T) { var tokens []string - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{ "result": []map[string]any{ @@ -203,7 +360,7 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) { } tokens = append(tokens, r.Header.Get("X-Auth-Token")) w.WriteHeader(http.StatusOK) - })) + }) defer srv.Close() add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}} @@ -216,15 +373,15 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) { {Kind: diff.Delete, Type: delActual.Type, Name: delActual.Name, Actual: &delActual}, }} - if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil { + if err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs); err != nil { t.Fatal(err) } if len(tokens) != 3 { t.Fatalf("expected 3 mutating requests (POST/PATCH/DELETE), got %d", len(tokens)) } for _, tok := range tokens { - if tok != "secret-token" { - t.Fatalf("expected X-Auth-Token %q on mutation, got %q", "secret-token", tok) + if tok != testIdentityToken { + t.Fatalf("expected X-Auth-Token %q on mutation, got %q", testIdentityToken, tok) } } } @@ -232,7 +389,7 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) { // Global Constraint: multi-page pagination must accumulate records across pages without an infinite loop. func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) { var offsets []string - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") offsets = append(offsets, offset) if len(offsets) > 2 { @@ -252,10 +409,10 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) { default: t.Fatalf("unexpected offset %q", offset) } - })) + }) defer srv.Close() - zs, err := c.ListZones(context.Background(), creds()) + zs, err := c.ListZones(context.Background(), testCreds()) if err != nil { t.Fatal(err) } @@ -272,7 +429,7 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) { // no third request should ever be issued. func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) { var offsets []string - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") offsets = append(offsets, offset) if len(offsets) > 2 { @@ -298,10 +455,10 @@ func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) { default: t.Fatalf("unexpected offset %q", offset) } - })) + }) defer srv.Close() - recs, err := c.GetRecords(context.Background(), creds(), "z1") + recs, err := c.GetRecords(context.Background(), testCreds(), "z1") if err != nil { t.Fatal(err) } @@ -320,13 +477,13 @@ func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) { // Global Constraint: ApplyChanges must not panic on a Changeset with a nil Desired // record for Add/Update, and must instead return a clear error. func TestApplyChangesAddWithNilDesiredReturnsErrorNoPanic(t *testing.T) { - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0}) return } t.Fatalf("unexpected mutating call %s %s, nil Desired should have errored before reaching HTTP", r.Method, r.URL.Path) - })) + }) defer srv.Close() cs := diff.Changeset{Diffs: []diff.RecordDiff{ @@ -339,24 +496,31 @@ func TestApplyChangesAddWithNilDesiredReturnsErrorNoPanic(t *testing.T) { } }() - err := c.ApplyChanges(context.Background(), creds(), "z1", cs) + err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs) if err == nil { t.Fatal("expected non-nil error for Add diff with nil Desired") } } -// Global Constraint: New() must configure the default base URL and a 30s HTTP timeout. +// Global Constraint: New() must configure the default base URL, identity URL, +// a 30s HTTP timeout, and an initialized token cache. func TestNewDefaults(t *testing.T) { c := New() if c.BaseURL != DefaultBaseURL { t.Fatalf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL) } + if c.IdentityURL != DefaultIdentityURL { + t.Fatalf("IdentityURL = %q, want %q", c.IdentityURL, DefaultIdentityURL) + } if c.HTTP == nil { t.Fatal("HTTP client must not be nil") } if c.HTTP.Timeout != 30*time.Second { t.Fatalf("HTTP.Timeout = %v, want %v", c.HTTP.Timeout, 30*time.Second) } + if c.tokens == nil { + t.Fatal("token cache map must be initialized") + } } // Global Constraint: if the API returns a NextOffset that does not advance past the @@ -364,7 +528,7 @@ func TestNewDefaults(t *testing.T) { // stop pagination instead of looping forever on the same/earlier offset. func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) { var requests int - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { requests++ if requests > 5 { t.Fatalf("too many requests, possible infinite pagination loop on stuck offset") @@ -386,10 +550,10 @@ func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) { default: t.Fatalf("unexpected offset %q", offset) } - })) + }) defer srv.Close() - zs, err := c.ListZones(context.Background(), creds()) + zs, err := c.ListZones(context.Background(), testCreds()) if err != nil { t.Fatal(err) } @@ -405,7 +569,7 @@ func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) { // a non-advancing NextOffset on a non-empty page must stop pagination, not loop forever. func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) { var requests int - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { requests++ if requests > 5 { t.Fatalf("too many requests, possible infinite pagination loop on stuck offset") @@ -433,10 +597,10 @@ func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) { default: t.Fatalf("unexpected offset %q", offset) } - })) + }) defer srv.Close() - recs, err := c.GetRecords(context.Background(), creds(), "z1") + recs, err := c.GetRecords(context.Background(), testCreds(), "z1") if err != nil { t.Fatal(err) } @@ -451,13 +615,13 @@ func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) { // Global Constraint: HTTP errors (status >= 300) must surface a non-nil error whose text // includes the method/path/status (or response body) for diagnosability. func TestListZonesHTTPErrorIncludesMethodPathStatus(t *testing.T) { - c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("zone not found")) - })) + }) defer srv.Close() - _, err := c.ListZones(context.Background(), creds()) + _, err := c.ListZones(context.Background(), testCreds()) if err == nil { t.Fatal("expected non-nil error on non-2xx response") } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index d3e7cf7..0da37d9 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -41,6 +41,7 @@ func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ f.applied = cs return nil } +func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil } type fakeLoader struct{ ref DomainRef } From 568452846ae3f92a4780d55c9f7586f91be1e8c6 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 20:12:41 +0700 Subject: [PATCH 3/5] feat(api): structured provider credentials + trial-auth validation on account create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /accounts now accepts secret as a provider-specific JSON object instead of an opaque string, and validates credentials via provider.Provider.Validate before persisting — invalid credentials get a generic 400 without ever reaching Store.CreateAccount or echoing the secret back. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/api/tenant_dto.go | 11 +++++-- internal/api/tenant_handlers.go | 15 ++++++++-- internal/api/tenant_test.go | 53 ++++++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/internal/api/tenant_dto.go b/internal/api/tenant_dto.go index 613b1c8..1ec0b6c 100644 --- a/internal/api/tenant_dto.go +++ b/internal/api/tenant_dto.go @@ -1,16 +1,21 @@ package api import ( + "encoding/json" + "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) +// accountRequest.Secret is a provider-specific JSON object (e.g. Selectel's +// service-user credentials) rather than an opaque string — it is passed +// through as raw bytes to Validate/Encrypt, never parsed here. type accountRequest struct { - Provider string `json:"provider"` - Secret string `json:"secret"` - Comment string `json:"comment"` + Provider string `json:"provider"` + Secret json.RawMessage `json:"secret"` + Comment string `json:"comment"` } // accountResponse deliberately excludes the secret (plaintext or encrypted). diff --git a/internal/api/tenant_handlers.go b/internal/api/tenant_handlers.go index 13711a4..4a93a68 100644 --- a/internal/api/tenant_handlers.go +++ b/internal/api/tenant_handlers.go @@ -31,11 +31,22 @@ func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) { if !decodeBody(w, r, &req) { return } - if req.Provider == "" || req.Secret == "" { + if req.Provider == "" || len(req.Secret) == 0 { writeErr(w, http.StatusBadRequest, "provider and secret are required") return } - secretEnc, err := a.Cipher.Encrypt([]byte(req.Secret)) + p, err := a.Reg.ByName(req.Provider) + if err != nil { + writeErr(w, http.StatusBadRequest, "unknown provider") + return + } + // Trial auth up-front so bad credentials fail at creation, not at import. + // The error text is deliberately generic — never echo the secret back. + if err := p.Validate(r.Context(), provider.Credentials{Secret: string(req.Secret)}); err != nil { + writeErr(w, http.StatusBadRequest, "invalid provider credentials") + return + } + secretEnc, err := a.Cipher.Encrypt(req.Secret) if err != nil { log.Printf("api: encrypt secret failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index a3a6db7..2eaea37 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -140,15 +140,19 @@ func (mockCipher) Decrypt(enc string) ([]byte, error) { } type mockRegistry struct { - zones []provider.Zone + zones []provider.Zone + validateErr error } func (r *mockRegistry) ByName(name string) (provider.Provider, error) { - return &mockProvider{zones: r.zones}, nil + return &mockProvider{zones: r.zones, validateErr: r.validateErr}, nil } type mockProvider struct { zones []provider.Zone + // validateErr, when set, makes Validate reject the credentials — lets + // tests exercise the 400-before-save path of handleCreateAccount. + validateErr error } func (mockProvider) Name() string { return "mock" } @@ -161,7 +165,9 @@ func (mockProvider) GetRecords(context.Context, provider.Credentials, string) ([ func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error { return nil } -func (mockProvider) Validate(context.Context, provider.Credentials) error { return nil } +func (p mockProvider) Validate(context.Context, provider.Credentials) error { + return p.validateErr +} // newTenantTestAPI wires a fixed authenticated user who owns whatever // project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see @@ -179,11 +185,16 @@ func newTenantTestAPI() (*API, *mockTenantStore) { // --- accounts --- -func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) { +// TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates covers the +// happy path of the structured-secret contract: secret is a provider-specific +// JSON object, Validate accepts it, and the *raw* JSON (not a re-serialized +// or unwrapped form) is what gets encrypted and handed to the store. +func TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates(t *testing.T) { a, ts := newTenantTestAPI() + a.Reg = &mockRegistry{} router := NewRouter(a) - body := `{"provider":"selectel","secret":"super-secret-token","comment":"prod"}` + body := `{"provider":"selectel","secret":{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"},"comment":"prod"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -198,11 +209,12 @@ func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) { t.Fatalf("expected 1 CreateAccount call, got %d", len(ts.createAccounts)) } got := ts.createAccounts[0].secretEnc - if got == "super-secret-token" { + if got == `{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"}` { t.Fatalf("store received plaintext secret instead of encrypted value") } - if got != "ENC(super-secret-token)" { - t.Fatalf("unexpected encrypted secret stored: %q", got) + wantEnc := `ENC({"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"})` + if got != wantEnc { + t.Fatalf("unexpected encrypted secret stored: %q, want %q", got, wantEnc) } var resp accountResponse @@ -214,6 +226,31 @@ func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) { } } +// TestCreateAccount_InvalidCredentials_Returns400BeforeSave covers the +// trial-auth gate: when the provider rejects the credentials, the handler +// must reject with 400 and a generic message, and must never reach +// Store.CreateAccount (so no bad account is persisted). +func TestCreateAccount_InvalidCredentials_Returns400BeforeSave(t *testing.T) { + a, ts := newTenantTestAPI() + a.Reg = &mockRegistry{validateErr: errors.New("identity: invalid password")} + router := NewRouter(a) + + body := `{"provider":"selectel","secret":{"username":"u","password":"wrong","account_id":"123","project_name":"proj"},"comment":"prod"}` + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body %s", w.Code, w.Body.String()) + } + if len(ts.createAccounts) != 0 { + t.Fatalf("expected CreateAccount not to be called, got %d calls", len(ts.createAccounts)) + } + if strings.Contains(w.Body.String(), "wrong") || strings.Contains(w.Body.String(), "invalid password") { + t.Fatalf("response leaks credential/provider error detail: %s", w.Body.String()) + } +} + func TestListAccounts_NoSecretsInResponse(t *testing.T) { a, ts := newTenantTestAPI() ts.accounts = []store.Account{ From be408a216cc71c5e66be99c348e536f7318eec03 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 20:23:34 +0700 Subject: [PATCH 4/5] feat(web): Selectel service-user account form (IAM credentials) Replace the single API-key field with 4 IAM service-user fields (username, password, account_id, project_name) matching the new backend contract; map 400 "invalid provider credentials" to a user-facing message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- web/src/api/client.test.ts | 6 +- web/src/api/types.ts | 8 +- web/src/pages/AccountsPage.test.tsx | 54 ++++++++--- web/src/pages/AccountsPage.tsx | 139 +++++++++++++++++++++++----- 4 files changed, 170 insertions(+), 37 deletions(-) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index f9ad71d..ced6d5d 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -79,7 +79,11 @@ describe("api client", () => { it("sends secret on account creation but path has no secret leakage in response typing", async () => { const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" }) - await api.createAccount(PROJECT_ID, { provider: "selectel", secret: "TOKEN", comment: "prod" }) + await api.createAccount(PROJECT_ID, { + provider: "selectel", + secret: { username: "svc-user", password: "TOKEN", account_id: "123456", project_name: "default" }, + comment: "prod", + }) const [, opts] = spy.mock.calls[0] expect((opts as RequestInit).method).toBe("POST") expect(String((opts as RequestInit).body)).toContain("TOKEN") diff --git a/web/src/api/types.ts b/web/src/api/types.ts index da63682..4c8c540 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3,7 +3,13 @@ export interface Project { id: string; name: string } export interface AuthState { user: User; project: Project } export interface Account { id: string; provider: string; comment: string } -export interface CreateAccountInput { provider: string; secret: string; comment: string } +export interface SelectelSecret { + username: string + password: string + account_id: string + project_name: string +} +export interface CreateAccountInput { provider: string; secret: SelectelSecret; comment: string } export interface RecordDTO { type: string; name: string; ttl: number; values: string[] } export interface Template { id: string; name: string; records: RecordDTO[]; version: number } diff --git a/web/src/pages/AccountsPage.test.tsx b/web/src/pages/AccountsPage.test.tsx index c82d8ab..8943130 100644 --- a/web/src/pages/AccountsPage.test.tsx +++ b/web/src/pages/AccountsPage.test.tsx @@ -44,7 +44,23 @@ test("отрисовывает список учёток без секрета", expect(screen.getAllByText("selectel").length).toBeGreaterThan(0) }) -test("форма создания вызывает api.createAccount с введённым secret и не показывает его после", async () => { +test("сабмит с пустыми полями показывает zod-ошибки и не вызывает createAccount", async () => { + const createSpy = vi.spyOn(api, "createAccount") + const user = userEvent.setup() + renderPage() + + await screen.findByText("Main") + + await user.click(screen.getByRole("button", { name: /добавить учётку/i })) + + expect(await screen.findByText("Укажите имя сервисного пользователя")).toBeInTheDocument() + expect(screen.getByText("Укажите пароль")).toBeInTheDocument() + expect(screen.getByText("Укажите номер аккаунта")).toBeInTheDocument() + expect(screen.getByText("Укажите имя проекта")).toBeInTheDocument() + expect(createSpy).not.toHaveBeenCalled() +}) + +test("заполнение 4 полей и сабмит вызывает createAccount с secret в snake_case и не показывает пароль после", async () => { const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({ id: "acc3", provider: "selectel", @@ -55,10 +71,13 @@ test("форма создания вызывает api.createAccount с введ await screen.findByText("Main") - const secretInput = screen.getByLabelText(/api-ключ/i) - expect(secretInput).toHaveAttribute("type", "password") + const passwordInput = screen.getByLabelText(/пароль/i) + expect(passwordInput).toHaveAttribute("type", "password") - await user.type(secretInput, "super-secret-token-123") + await user.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user") + await user.type(passwordInput, "super-secret-token-123") + await user.type(screen.getByLabelText(/номер аккаунта/i), "123456") + await user.type(screen.getByLabelText(/имя проекта/i), "default") await user.type(screen.getByLabelText(/комментарий/i), "New account") await user.click(screen.getByRole("button", { name: /добавить учётку/i })) @@ -66,36 +85,45 @@ test("форма создания вызывает api.createAccount с введ await waitFor(() => expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { provider: "selectel", - secret: "super-secret-token-123", comment: "New account", + secret: { + username: "svc-user", + password: "super-secret-token-123", + account_id: "123456", + project_name: "default", + }, }), ) - await waitFor(() => expect(secretInput).toHaveValue("")) + await waitFor(() => expect(passwordInput).toHaveValue("")) expect(screen.queryByText("super-secret-token-123")).not.toBeInTheDocument() expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument() }) -test("содержит ссылку-инструкцию получения ключа Selectel", async () => { +test("содержит ссылку-инструкцию на раздел пользователей и ролей Selectel", async () => { renderPage() await screen.findByText("Main") const link = screen.getByRole("link", { name: /selectel/i }) - expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru")) + expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru/iam/users")) }) -test("ошибка создания учётки отображается пользователю", async () => { - vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку")) +test("ошибка 400 invalid provider credentials отображается понятным текстом", async () => { + vi.spyOn(api, "createAccount").mockRejectedValue(new Error("invalid provider credentials")) const user = userEvent.setup() renderPage() await screen.findByText("Main") - await user.type(screen.getByLabelText(/api-ключ/i), "token-xyz") - await user.type(screen.getByLabelText(/комментарий/i), "Test") + await user.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user") + await user.type(screen.getByLabelText(/пароль/i), "wrong") + await user.type(screen.getByLabelText(/номер аккаунта/i), "123456") + await user.type(screen.getByLabelText(/имя проекта/i), "default") await user.click(screen.getByRole("button", { name: /добавить учётку/i })) - expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать учётку") + expect(await screen.findByRole("alert")).toHaveTextContent( + "Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта.", + ) }) test("удаление учётки вызывает api.deleteAccount", async () => { diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx index ff227d8..b782bcd 100644 --- a/web/src/pages/AccountsPage.tsx +++ b/web/src/pages/AccountsPage.tsx @@ -26,17 +26,41 @@ import { useAccounts, useCreateAccount, useDeleteAccount } from "@/hooks/useApi" const createAccountSchema = z.object({ provider: z.literal("selectel"), - secret: z.string().min(1, "Укажите API-ключ"), - comment: z.string().min(1, "Укажите комментарий"), + username: z.string().min(1, "Укажите имя сервисного пользователя"), + password: z.string().min(1, "Укажите пароль"), + accountId: z.string().min(1, "Укажите номер аккаунта"), + projectName: z.string().min(1, "Укажите имя проекта"), + comment: z.string(), }) type CreateAccountForm = z.infer +// API возвращает generic "invalid provider credentials" (нарочно, чтобы не +// палить детали) — переводим в понятную пользователю формулировку. +function createAccountErrorMessage(message: string): string { + if (message.toLowerCase().includes("invalid provider credentials")) { + return "Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта." + } + return message +} + +const EMPTY_FORM: CreateAccountForm = { + provider: "selectel", + username: "", + password: "", + accountId: "", + projectName: "", + comment: "", +} + export function AccountsPage() { const accounts = useAccounts() const createAccount = useCreateAccount() const deleteAccount = useDeleteAccount() - const secretFieldId = useId() + const usernameFieldId = useId() + const passwordFieldId = useId() + const accountIdFieldId = useId() + const projectNameFieldId = useId() const commentFieldId = useId() const accountList = accounts.data ?? [] @@ -48,13 +72,23 @@ export function AccountsPage() { formState: { errors }, } = useForm({ resolver: zodResolver(createAccountSchema), - defaultValues: { provider: "selectel", secret: "", comment: "" }, + defaultValues: EMPTY_FORM, }) function onSubmit(values: CreateAccountForm) { - createAccount.mutate(values, { - onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }), - }) + createAccount.mutate( + { + provider: "selectel", + comment: values.comment, + secret: { + username: values.username.trim(), + password: values.password, + account_id: values.accountId.trim(), + project_name: values.projectName.trim(), + }, + }, + { onSuccess: () => reset(EMPTY_FORM) }, + ) } function onDelete(id: string, comment: string) { @@ -74,28 +108,88 @@ export function AccountsPage() {
- - - API-ключ Selectel + + + Имя сервисного пользователя ( )} /> - + + + + + + Пароль + + ( + + )} + /> + + + + + + Номер аккаунта + + ( + + )} + /> + + + + + + Имя проекта + + ( + + )} + /> + @@ -122,16 +216,17 @@ export function AccountsPage() { - Провайдер: selectel. Ключ создаётся - в панели управления Selectel — раздел «API-ключи» — и используется только для - запроса, храниться в открытом виде он не будет.{" "} + Провайдер: selectel. Создайте + сервисного пользователя в панели управления Selectel, выдайте ему роль на нужный + проект и укажите здесь его логин, пароль, номер аккаунта и имя проекта. Пароль + хранится в зашифрованном виде.{" "} - Получить ключ на my.selectel.ru + Пользователи и роли на my.selectel.ru @@ -141,7 +236,7 @@ export function AccountsPage() {
{createAccount.isError && ( - {createAccount.error.message} + {createAccountErrorMessage(createAccount.error.message)} )}