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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-04 20:02:36 +07:00
parent 617b02dbfb
commit 32107571d1
7 changed files with 403 additions and 58 deletions
+209 -45
View File
@@ -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")
}