Files
dns-autoresolver/docs/superpowers/plans/2026-07-04-selectel-iam-auth.md
2026-07-04 19:52:42 +07:00

21 KiB
Raw Permalink Blame History

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":"<now+24h RFC3339>"}}, статус 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 добавь:

// 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:

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 — по неизменяемой части учётки (без пароля в открытом виде в ключе; пароль включаем как часть идентичности через хеш, чтобы смена пароля инвалидировала запись):

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, вернуть токен и срок:

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

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 + подключение токена в методы
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, GetRecordslistRRSets, 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:

// 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:

	// 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 реализацию:

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.

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: <provider-specific JSON object>}; невалидные креды → 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:

type accountRequest struct {
	Provider string          `json:"provider"`
	Secret   json.RawMessage `json:"secret"`
	Comment  string          `json:"comment"`
}

(добавь импорт encoding/json.)

  • Step 3: handleCreateAccount — валидация перед сохранением
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.Secretjson.RawMessage = []byte, передавай напрямую. Убедись, что provider импортирован в tenant_handlers.go — уже да.)

  • Step 4: Прогон и коммит

Run: go build ./... && go test ./internal/api/.... Ожидание PASS.

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

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-схема:

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:

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.

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.