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

399 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` добавь:
```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: <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`:
```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.