diff --git a/cmd/root/auth.go b/cmd/root/auth.go index ca9987da9d..6a85b52713 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -52,6 +52,26 @@ func initProfileFlag(cmd *cobra.Command) { cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion) } +// isPATOnSPOGWithoutWorkspaceID reports whether the resolved config is a PAT +// profile pointing at a SPOG host with no workspace_id set. The SDK strips the +// routing identifier from the request, which lands on the account-plane where +// PATs aren't accepted. The legacy "none" sentinel (auth.WorkspaceIDNone) is +// treated as empty here, matching the convention used elsewhere in the repo +// (e.g. libs/databrickscfg/profile/profiler.go). +func isPATOnSPOGWithoutWorkspaceID(cfg *config.Config) bool { + return cfg.AuthType == auth.AuthTypePat && + (cfg.WorkspaceID == "" || cfg.WorkspaceID == auth.WorkspaceIDNone) && + auth.HasUnifiedHostSignal(cfg.DiscoveryURL) +} + +// patSPOGNoWorkspaceIDError describes the configuration gap and how to fix it. +func patSPOGNoWorkspaceIDError(profileName string) error { + if profileName == "" { + return errors.New("personal access token (PAT) auth on this host requires a workspace_id; PATs are workspace-scoped, but no workspace_id is set. Add workspace_id = (or set DATABRICKS_WORKSPACE_ID) to the profile associated with this PAT token") + } + return fmt.Errorf("profile %q uses PAT auth on a SPOG host but is missing workspace_id; PATs are workspace-scoped, so the request can't be routed. Edit the profile to add workspace_id = matching the workspace the token was minted in", profileName) +} + // ErrAccountOnlyProfile signals that the resolved profile has an account_id // but no workspace_id, so workspace APIs can't be reached. Workspace-only // commands surface this as an actionable error; MustAnyClient (used by `auth @@ -221,9 +241,20 @@ func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPromp // // We require cfg.Profile to be set so we don't reject env-var-only // configs targeting a unified host where workspace APIs are also - // served from the account host. + // served from the account host. This branch runs first so MustAnyClient + // can recognize ErrAccountOnlyProfile and fall through to the account + // client; the PAT-on-SPOG check below handles the remaining cases + // (env-var-only configs and profiles without account_id resolved). return nil, accountOnlyProfileError(cfg.Profile) } + if err == nil && isPATOnSPOGWithoutWorkspaceID(cfg) { + // PATs are workspace-scoped. On a SPOG host without workspace_id the + // SDK can't add the routing identifier, the backend treats the call as + // account-plane, and PATs aren't accepted there. The result is an + // opaque "Credential was not sent" error from the auth endpoint; + // rewrite up front so the user sees what's actually wrong. + return nil, patSPOGNoWorkspaceIDError(cfg.Profile) + } if err == nil { err = w.Config.Authenticate(emptyHttpRequest(ctx)) } diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index bcbcc3d1bc..4187b6525d 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -2,13 +2,16 @@ package root import ( "context" + "fmt" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/databricks/cli/internal/testutil" + "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go" @@ -454,6 +457,62 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) { assert.ErrorIs(t, err, databricks.ErrNotAccountClient) } +func TestIsPATOnSPOGWithoutWorkspaceID(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + want bool + }{ + { + name: "pat on spog without workspace_id", + cfg: &config.Config{ + AuthType: "pat", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: true, + }, + { + name: "pat on spog with workspace_id is fine", + cfg: &config.Config{ + AuthType: "pat", + WorkspaceID: "12345", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: false, + }, + { + name: "pat on spog with legacy 'none' sentinel is treated as missing", + cfg: &config.Config{ + AuthType: "pat", + WorkspaceID: auth.WorkspaceIDNone, + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: true, + }, + { + name: "pat on classic workspace host is fine", + cfg: &config.Config{ + AuthType: "pat", + DiscoveryURL: "https://workspace.example.test/oidc/.well-known/oauth-authorization-server", + }, + want: false, + }, + { + name: "u2m on spog is not affected (handled by other paths)", + cfg: &config.Config{ + AuthType: "databricks-cli", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isPATOnSPOGWithoutWorkspaceID(tt.cfg)) + }) + } +} + func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) { tests := []struct { name string @@ -490,6 +549,75 @@ func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) { } } +func TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID(t *testing.T) { + testutil.CleanupEnvironment(t) + t.Setenv("PATH", "") + + // No AccountID is set, so the account-only profile detector (which requires + // AccountID) does not fire and the PAT-on-SPOG detector is exercised. + cfg := &config.Config{ + Host: "https://spog.example.test/", + Token: "dapi-fake", + Profile: "spog-pat", + DiscoveryURL: "https://spog.example.test/oidc/accounts/abc-123/.well-known/oauth-authorization-server", + AuthType: "pat", + HTTPTransport: noNetworkTransport, + } + + w, err := workspaceClientOrPrompt(t.Context(), cfg, false) + assert.Nil(t, w) + require.Error(t, err) + assert.Contains(t, err.Error(), `profile "spog-pat"`) + assert.Contains(t, err.Error(), "workspace_id") + assert.Contains(t, err.Error(), "PAT") +} + +// TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile exercises the +// real .databrickscfg shape from the bug bash: `host` + `token` only, no +// `auth_type`, no `workspace_id`. The SDK populates AuthType during +// NewWorkspaceClient via its credential probe, so the PAT-on-SPOG detector +// must keep working after going through that path. +func TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile(t *testing.T) { + // testutil.CleanupEnvironment calls os.Clearenv(), which wipes Windows + // essentials like SystemRoot and breaks Winsock initialization for + // subsequent net.Listen calls. We only need a clean DATABRICKS_CONFIG_FILE + // for this test; set it directly with t.Setenv so the rest of the + // environment (notably the Windows networking stack) keeps working. + t.Setenv("DATABRICKS_AUTH_TYPE", "") + t.Setenv("DATABRICKS_HOST", "") + t.Setenv("DATABRICKS_TOKEN", "") + t.Setenv("DATABRICKS_CONFIG_PROFILE", "") + t.Setenv("PATH", "") + + // Mock .well-known/databricks-config to return an account-scoped OIDC + // endpoint so the SDK populates cfg.DiscoveryURL with the SPOG signal. + // Omit account_id so AccountID stays unset; otherwise the account-only + // profile detector would intercept this case before the PAT-on-SPOG check. + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/databricks-config", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"oidc_endpoint":"https://spog.example.test/oidc/accounts/abc-123"}`)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(configFile, fmt.Appendf(nil, ` +[spog-pat] +host = %s +token = dapi-fake +`, server.URL), 0o600)) + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + cfg := &config.Config{Profile: "spog-pat"} + w, err := workspaceClientOrPrompt(t.Context(), cfg, false) + assert.Nil(t, w) + require.Error(t, err) + assert.Contains(t, err.Error(), `profile "spog-pat"`) + assert.Contains(t, err.Error(), "workspace_id") + assert.Contains(t, err.Error(), "PAT") +} + func TestMustAnyClientFallsThroughOnAccountOnlyProfile(t *testing.T) { testutil.CleanupEnvironment(t) t.Setenv("PATH", "")