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:
@@ -1,16 +1,21 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
"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 {
|
type accountRequest struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Secret string `json:"secret"`
|
Secret json.RawMessage `json:"secret"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountResponse deliberately excludes the secret (plaintext or encrypted).
|
// accountResponse deliberately excludes the secret (plaintext or encrypted).
|
||||||
|
|||||||
@@ -31,11 +31,22 @@ func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decodeBody(w, r, &req) {
|
if !decodeBody(w, r, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Provider == "" || req.Secret == "" {
|
if req.Provider == "" || len(req.Secret) == 0 {
|
||||||
writeErr(w, http.StatusBadRequest, "provider and secret are required")
|
writeErr(w, http.StatusBadRequest, "provider and secret are required")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
log.Printf("api: encrypt secret failed: %v", err)
|
log.Printf("api: encrypt secret failed: %v", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
|
|||||||
@@ -140,15 +140,19 @@ func (mockCipher) Decrypt(enc string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type mockRegistry struct {
|
type mockRegistry struct {
|
||||||
zones []provider.Zone
|
zones []provider.Zone
|
||||||
|
validateErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mockRegistry) ByName(name string) (provider.Provider, 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 {
|
type mockProvider struct {
|
||||||
zones []provider.Zone
|
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" }
|
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 {
|
func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
|
||||||
return nil
|
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
|
// newTenantTestAPI wires a fixed authenticated user who owns whatever
|
||||||
// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see
|
// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see
|
||||||
@@ -179,11 +185,16 @@ func newTenantTestAPI() (*API, *mockTenantStore) {
|
|||||||
|
|
||||||
// --- accounts ---
|
// --- 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, ts := newTenantTestAPI()
|
||||||
|
a.Reg = &mockRegistry{}
|
||||||
router := NewRouter(a)
|
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))
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
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))
|
t.Fatalf("expected 1 CreateAccount call, got %d", len(ts.createAccounts))
|
||||||
}
|
}
|
||||||
got := ts.createAccounts[0].secretEnc
|
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")
|
t.Fatalf("store received plaintext secret instead of encrypted value")
|
||||||
}
|
}
|
||||||
if got != "ENC(super-secret-token)" {
|
wantEnc := `ENC({"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"})`
|
||||||
t.Fatalf("unexpected encrypted secret stored: %q", got)
|
if got != wantEnc {
|
||||||
|
t.Fatalf("unexpected encrypted secret stored: %q, want %q", got, wantEnc)
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp accountResponse
|
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) {
|
func TestListAccounts_NoSecretsInResponse(t *testing.T) {
|
||||||
a, ts := newTenantTestAPI()
|
a, ts := newTenantTestAPI()
|
||||||
ts.accounts = []store.Account{
|
ts.accounts = []store.Account{
|
||||||
|
|||||||
Reference in New Issue
Block a user