From 32e780a18b072fe670221571c86ee0b92fa222f0 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 10 Apr 2026 13:13:59 +0000 Subject: [PATCH 1/3] Fix auth profiles misclassifying SPOG hosts as workspace configs SPOG hosts (e.g. db-deco-test.gcp.databricks.com) don't match the accounts.* URL prefix, so ConfigType() classifies them as WorkspaceConfig. This causes `auth profiles` to validate with CurrentUser.Me instead of Workspaces.List, which fails for account-scoped SPOG profiles. Use the resolved DiscoveryURL from .well-known/databricks-config to detect SPOG hosts with account-scoped OIDC, matching the routing logic in auth.AuthArguments.ToOAuthArgument(). Also add a fallback for legacy profiles with Experimental_IsUnifiedHost where .well-known is unreachable. --- cmd/auth/profiles.go | 27 +++++- cmd/auth/profiles_test.go | 191 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go index 38ba3599fc..da76eceecb 100644 --- a/cmd/auth/profiles.go +++ b/cmd/auth/profiles.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "io/fs" + "strings" "sync" "time" + "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" @@ -56,7 +58,30 @@ func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipV return } - switch cfg.ConfigType() { + // ConfigType() classifies based on the host URL prefix (accounts.* → + // AccountConfig, everything else → WorkspaceConfig). SPOG hosts don't + // match the accounts.* prefix so they're misclassified as WorkspaceConfig. + // Use the resolved DiscoveryURL (from .well-known/databricks-config) to + // detect SPOG hosts with account-scoped OIDC, matching the routing logic + // in auth.AuthArguments.ToOAuthArgument(). + configType := cfg.ConfigType() + isAccountScopedOIDC := cfg.DiscoveryURL != "" && strings.Contains(cfg.DiscoveryURL, "/oidc/accounts/") + if configType != config.AccountConfig && cfg.AccountID != "" && isAccountScopedOIDC { + if cfg.WorkspaceID != "" && cfg.WorkspaceID != auth.WorkspaceIDNone { + configType = config.WorkspaceConfig + } else { + configType = config.AccountConfig + } + } + + // Legacy backward compat: profiles with Experimental_IsUnifiedHost where + // .well-known is unreachable (so DiscoveryURL is empty). Matches the + // fallback in auth.AuthArguments.ToOAuthArgument(). + if configType == config.InvalidConfig && cfg.Experimental_IsUnifiedHost && cfg.AccountID != "" { + configType = config.AccountConfig + } + + switch configType { case config.AccountConfig: a, err := databricks.NewAccountClient((*databricks.Config)(cfg)) if err != nil { diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index afd7b0b548..63185c73fd 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -1,6 +1,10 @@ package auth import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" "path/filepath" "runtime" "testing" @@ -74,3 +78,190 @@ func TestProfilesDefaultMarker(t *testing.T) { require.NoError(t, err) assert.Equal(t, "profile-a", defaultProfile) } + +// newProfileTestServer creates a mock server for profile validation tests. +// It serves /.well-known/databricks-config with the given OIDC shape and +// responds to the workspace/account validation API endpoints. +func newProfileTestServer(t *testing.T, accountScoped bool, accountID string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/.well-known/databricks-config": + oidcEndpoint := r.Host + "/oidc" + if accountScoped { + oidcEndpoint = r.Host + "/oidc/accounts/" + accountID + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "account_id": accountID, + "oidc_endpoint": oidcEndpoint, + }) + case r.URL.Path == "/api/2.0/preview/scim/v2/Me": + _ = json.NewEncoder(w).Encode(map[string]any{ + "userName": "test-user", + }) + case r.URL.Path == "/api/2.0/accounts/"+accountID+"/workspaces": + _ = json.NewEncoder(w).Encode([]map[string]any{}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} + +func TestProfileLoadSPOGConfigType(t *testing.T) { + spogServer := newProfileTestServer(t, true, "spog-acct") + wsServer := newProfileTestServer(t, false, "ws-acct") + + cases := []struct { + name string + host string + accountID string + workspaceID string + wantValid bool + wantConfigCloud string + }{ + { + name: "SPOG account profile validated as account", + host: spogServer.URL, + accountID: "spog-acct", + wantValid: true, + }, + { + name: "SPOG workspace profile validated as workspace", + host: spogServer.URL, + accountID: "spog-acct", + workspaceID: "ws-123", + wantValid: true, + }, + { + name: "SPOG profile with workspace_id=none validated as account", + host: spogServer.URL, + accountID: "spog-acct", + workspaceID: "none", + wantValid: true, + }, + { + name: "classic workspace with account_id from discovery stays workspace", + host: wsServer.URL, + accountID: "ws-acct", + wantValid: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + content := "[test-profile]\nhost = " + tc.host + "\ntoken = test-token\n" + if tc.accountID != "" { + content += "account_id = " + tc.accountID + "\n" + } + if tc.workspaceID != "" { + content += "workspace_id = " + tc.workspaceID + "\n" + } + require.NoError(t, os.WriteFile(configFile, []byte(content), 0o600)) + + p := &profileMetadata{ + Name: "test-profile", + Host: tc.host, + AccountID: tc.accountID, + } + p.Load(t.Context(), configFile, false) + + assert.Equal(t, tc.wantValid, p.Valid, "Valid mismatch") + assert.NotEmpty(t, p.Host, "Host should be set") + assert.NotEmpty(t, p.AuthType, "AuthType should be set") + }) + } +} + +func TestProfileLoadUnifiedHostFallback(t *testing.T) { + // When Experimental_IsUnifiedHost is set but .well-known is unreachable, + // ConfigType() returns InvalidConfig. The fallback should reclassify as + // AccountConfig so the profile is still validated. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/.well-known/databricks-config": + // Simulate unreachable/missing .well-known endpoint + w.WriteHeader(http.StatusNotFound) + case r.URL.Path == "/api/2.0/accounts/unified-acct/workspaces": + _ = json.NewEncoder(w).Encode([]map[string]any{}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + content := "[unified-profile]\nhost = " + server.URL + "\ntoken = test-token\naccount_id = unified-acct\nexperimental_is_unified_host = true\n" + require.NoError(t, os.WriteFile(configFile, []byte(content), 0o600)) + + p := &profileMetadata{ + Name: "unified-profile", + Host: server.URL, + AccountID: "unified-acct", + } + p.Load(t.Context(), configFile, false) + + assert.True(t, p.Valid, "unified host profile should be valid via fallback") + assert.NotEmpty(t, p.Host) + assert.NotEmpty(t, p.AuthType) +} + +func TestProfileLoadClassicAccountHost(t *testing.T) { + // Classic accounts.* hosts are already correctly classified as AccountConfig + // by ConfigType(). Verify that behavior is preserved. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/.well-known/databricks-config": + _ = json.NewEncoder(w).Encode(map[string]any{ + "account_id": "classic-acct", + "oidc_endpoint": r.Host + "/oidc/accounts/classic-acct", + }) + case r.URL.Path == "/api/2.0/accounts/classic-acct/workspaces": + _ = json.NewEncoder(w).Encode([]map[string]any{}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + // Use the test server URL but override the host to look like an accounts host. + // Since we can't actually use accounts.cloud.databricks.com in tests, we verify + // indirectly: a SPOG profile without workspace_id should be validated as account. + content := "[acct-profile]\nhost = " + server.URL + "\ntoken = test-token\naccount_id = classic-acct\n" + require.NoError(t, os.WriteFile(configFile, []byte(content), 0o600)) + + p := &profileMetadata{ + Name: "acct-profile", + Host: server.URL, + AccountID: "classic-acct", + } + p.Load(t.Context(), configFile, false) + + assert.True(t, p.Valid, "classic account profile should be valid") + assert.NotEmpty(t, p.Host) + assert.NotEmpty(t, p.AuthType) +} From 7b8d12242993d267ec42474dae2ccab04e3531a9 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 10 Apr 2026 13:19:22 +0000 Subject: [PATCH 2/3] Add acceptance test for SPOG account profile validation --- .../auth/profiles/spog-account/out.test.toml | 5 +++++ .../cmd/auth/profiles/spog-account/output.txt | 16 +++++++++++++++ .../cmd/auth/profiles/spog-account/script | 15 ++++++++++++++ .../cmd/auth/profiles/spog-account/test.toml | 20 +++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 acceptance/cmd/auth/profiles/spog-account/out.test.toml create mode 100644 acceptance/cmd/auth/profiles/spog-account/output.txt create mode 100644 acceptance/cmd/auth/profiles/spog-account/script create mode 100644 acceptance/cmd/auth/profiles/spog-account/test.toml diff --git a/acceptance/cmd/auth/profiles/spog-account/out.test.toml b/acceptance/cmd/auth/profiles/spog-account/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/profiles/spog-account/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/profiles/spog-account/output.txt b/acceptance/cmd/auth/profiles/spog-account/output.txt new file mode 100644 index 0000000000..f5ce0ac53c --- /dev/null +++ b/acceptance/cmd/auth/profiles/spog-account/output.txt @@ -0,0 +1,16 @@ + +=== SPOG account profile should be valid +>>> [CLI] auth profiles --output json +{ + "profiles": [ + { + "name":"spog-account", + "host":"[DATABRICKS_URL]", + "account_id":"spog-acct-123", + "workspace_id":"none", + "cloud":"aws", + "auth_type":"pat", + "valid":true + } + ] +} diff --git a/acceptance/cmd/auth/profiles/spog-account/script b/acceptance/cmd/auth/profiles/spog-account/script new file mode 100644 index 0000000000..64285ad0ec --- /dev/null +++ b/acceptance/cmd/auth/profiles/spog-account/script @@ -0,0 +1,15 @@ +sethome "./home" + +# Create a SPOG account profile: non-accounts.* host with account_id, no workspace_id. +# Before the fix, this was misclassified as WorkspaceConfig and validated with +# CurrentUser.Me, which fails on account-scoped SPOG hosts. +cat > "./home/.databrickscfg" < Date: Fri, 10 Apr 2026 13:26:44 +0000 Subject: [PATCH 3/3] Use tagged switch to fix staticcheck QF1002 lint errors --- cmd/auth/profiles_test.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index 63185c73fd..edbdd25fb5 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -86,8 +86,8 @@ func newProfileTestServer(t *testing.T, accountScoped bool, accountID string) *h t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - switch { - case r.URL.Path == "/.well-known/databricks-config": + switch r.URL.Path { + case "/.well-known/databricks-config": oidcEndpoint := r.Host + "/oidc" if accountScoped { oidcEndpoint = r.Host + "/oidc/accounts/" + accountID @@ -96,11 +96,11 @@ func newProfileTestServer(t *testing.T, accountScoped bool, accountID string) *h "account_id": accountID, "oidc_endpoint": oidcEndpoint, }) - case r.URL.Path == "/api/2.0/preview/scim/v2/Me": + case "/api/2.0/preview/scim/v2/Me": _ = json.NewEncoder(w).Encode(map[string]any{ "userName": "test-user", }) - case r.URL.Path == "/api/2.0/accounts/"+accountID+"/workspaces": + case "/api/2.0/accounts/" + accountID + "/workspaces": _ = json.NewEncoder(w).Encode([]map[string]any{}) default: w.WriteHeader(http.StatusNotFound) @@ -188,11 +188,10 @@ func TestProfileLoadUnifiedHostFallback(t *testing.T) { // AccountConfig so the profile is still validated. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - switch { - case r.URL.Path == "/.well-known/databricks-config": - // Simulate unreachable/missing .well-known endpoint + switch r.URL.Path { + case "/.well-known/databricks-config": w.WriteHeader(http.StatusNotFound) - case r.URL.Path == "/api/2.0/accounts/unified-acct/workspaces": + case "/api/2.0/accounts/unified-acct/workspaces": _ = json.NewEncoder(w).Encode([]map[string]any{}) default: w.WriteHeader(http.StatusNotFound) @@ -227,13 +226,13 @@ func TestProfileLoadClassicAccountHost(t *testing.T) { // by ConfigType(). Verify that behavior is preserved. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - switch { - case r.URL.Path == "/.well-known/databricks-config": + switch r.URL.Path { + case "/.well-known/databricks-config": _ = json.NewEncoder(w).Encode(map[string]any{ "account_id": "classic-acct", "oidc_endpoint": r.Host + "/oidc/accounts/classic-acct", }) - case r.URL.Path == "/api/2.0/accounts/classic-acct/workspaces": + case "/api/2.0/accounts/classic-acct/workspaces": _ = json.NewEncoder(w).Encode([]map[string]any{}) default: w.WriteHeader(http.StatusNotFound)