feat(api): structured provider credentials + trial-auth validation on account create

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-04 20:12:41 +07:00
parent 32107571d1
commit 568452846a
3 changed files with 66 additions and 13 deletions
+6 -1
View File
@@ -1,15 +1,20 @@
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"`
Secret json.RawMessage `json:"secret"`
Comment string `json:"comment"`
}
+13 -2
View File
@@ -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")
+44 -7
View File
@@ -141,14 +141,18 @@ func (mockCipher) Decrypt(enc string) ([]byte, error) {
type mockRegistry struct {
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{