From bfa34522e3727ecca253968ba850dce66359f345 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 13:25:15 +0200 Subject: [PATCH 1/4] auth: clear error when workspace commands run on an account-only profile Profiles created with `databricks auth login --skip-workspace` are persisted with the CLI-only `workspace_id = none` sentinel. Running a workspace command (e.g. `clusters list`) against such a profile failed with an opaque "Credential was not sent or was of an unsupported type" error from the auth endpoint, because the SDK forwarded the literal string "none" as a routing identifier. Detect the sentinel in `workspaceClientOrPrompt` before any API call and return a message that names the profile and suggests concrete fixes (edit the profile to set a real workspace_id, or pass --profile with a workspace-scoped profile). Co-authored-by: Isaac --- cmd/root/auth.go | 16 ++++++++++++++++ cmd/root/auth_test.go | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 8b9bfd0810..517cea22ec 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -52,6 +52,15 @@ func initProfileFlag(cmd *cobra.Command) { cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion) } +// accountOnlyProfileError describes why a workspace command can't run against +// a profile whose workspace_id sentinel marks it as account-only. +func accountOnlyProfileError(profileName string) error { + if profileName == "" { + return errors.New("the active configuration has workspace_id = none, which marks it as account-only; this command requires a workspace. Set workspace_id to a real ID, or use a workspace-scoped profile") + } + return fmt.Errorf("profile %q has workspace_id = none, which marks it as account-only; this command requires a workspace. Edit the profile to set workspace_id to a real ID, or pass --profile with a workspace-scoped profile", profileName) +} + func profileFlagValue(cmd *cobra.Command) (string, bool) { profileFlag := cmd.Flag("profile") if profileFlag == nil { @@ -189,6 +198,13 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { // Helper function to create a workspace client or prompt once if the given configuration is not valid. func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPrompt bool) (*databricks.WorkspaceClient, error) { w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg)) + if err == nil && cfg.WorkspaceID == auth.WorkspaceIDNone { + // CLI-only sentinel marking an account-only profile (created with + // --skip-workspace). The SDK doesn't know "none" is a sentinel and + // would send it as a routing identifier, producing an opaque auth + // error. Reject up front with a message the user can act on. + return nil, accountOnlyProfileError(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 c75fd0209f..aeb117191d 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -454,6 +454,29 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) { assert.ErrorIs(t, err, databricks.ErrNotAccountClient) } +func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) { + testutil.CleanupEnvironment(t) + t.Setenv("PATH", "") + + // Profile created with --skip-workspace persists `workspace_id = none` as a + // CLI-internal sentinel; workspace commands cannot run against it. + cfg := &config.Config{ + Host: "https://example.test/", + AccountID: "abc-123", + WorkspaceID: "none", + Token: "foobar", + Profile: "bb", + HTTPTransport: noNetworkTransport, + } + + w, err := workspaceClientOrPrompt(t.Context(), cfg, false) + assert.Nil(t, w) + require.Error(t, err) + assert.Contains(t, err.Error(), `profile "bb"`) + assert.Contains(t, err.Error(), "account-only") + assert.Contains(t, err.Error(), "workspace_id = none") +} + func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) { testutil.CleanupEnvironment(t) t.Setenv("PATH", "") From 0bb2bb7d4c3ec4ab80c0f97dc562cd52e1be3992 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 14:00:28 +0200 Subject: [PATCH 2/4] auth: detect account-only profiles without the legacy "none" sentinel PR #5338 stops writing workspace_id = none on --skip-workspace. After it lands, new account-only profiles have no workspace_id key at all, so the "= none" check here misses them. Broaden the detection to fire when cfg.Profile is set, cfg.AccountID is non-empty, and cfg.WorkspaceID is either empty or the legacy "none" sentinel. Reword the error to talk about "no workspace_id set" instead of the literal sentinel. The cfg.Profile guard keeps env-var-only configs targeting a unified host out of the rejection path (their workspace APIs are served from the account host and auth resolves end-to-end). Co-authored-by: Isaac --- cmd/root/auth.go | 23 ++++++++++++--------- cmd/root/auth_test.go | 47 ++++++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 517cea22ec..c3823ce96d 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -53,12 +53,9 @@ func initProfileFlag(cmd *cobra.Command) { } // accountOnlyProfileError describes why a workspace command can't run against -// a profile whose workspace_id sentinel marks it as account-only. +// a profile that has an account_id but no workspace_id. func accountOnlyProfileError(profileName string) error { - if profileName == "" { - return errors.New("the active configuration has workspace_id = none, which marks it as account-only; this command requires a workspace. Set workspace_id to a real ID, or use a workspace-scoped profile") - } - return fmt.Errorf("profile %q has workspace_id = none, which marks it as account-only; this command requires a workspace. Edit the profile to set workspace_id to a real ID, or pass --profile with a workspace-scoped profile", profileName) + return fmt.Errorf("profile %q has no workspace_id set (account-only); this command requires a workspace. Edit the profile to set workspace_id to a real ID, or pass --profile with a workspace-scoped profile", profileName) } func profileFlagValue(cmd *cobra.Command) (string, bool) { @@ -198,11 +195,17 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { // Helper function to create a workspace client or prompt once if the given configuration is not valid. func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPrompt bool) (*databricks.WorkspaceClient, error) { w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg)) - if err == nil && cfg.WorkspaceID == auth.WorkspaceIDNone { - // CLI-only sentinel marking an account-only profile (created with - // --skip-workspace). The SDK doesn't know "none" is a sentinel and - // would send it as a routing identifier, producing an opaque auth - // error. Reject up front with a message the user can act on. + if err == nil && cfg.Profile != "" && cfg.AccountID != "" && + (cfg.WorkspaceID == "" || cfg.WorkspaceID == auth.WorkspaceIDNone) { + // Account-only profile (created with --skip-workspace): account_id is + // set but workspace_id is absent (new shape) or the legacy "none" + // sentinel. Without a workspace_id the SDK would either send "none" as + // a routing identifier or fail later with an opaque auth error. + // Reject up front with a message the user can act on. + // + // 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. return nil, accountOnlyProfileError(cfg.Profile) } if err == nil { diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index aeb117191d..dcc70cc9e4 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -455,26 +455,37 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) { } func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) { - testutil.CleanupEnvironment(t) - t.Setenv("PATH", "") - - // Profile created with --skip-workspace persists `workspace_id = none` as a - // CLI-internal sentinel; workspace commands cannot run against it. - cfg := &config.Config{ - Host: "https://example.test/", - AccountID: "abc-123", - WorkspaceID: "none", - Token: "foobar", - Profile: "bb", - HTTPTransport: noNetworkTransport, + tests := []struct { + name string + workspaceID string + }{ + // New shape: --skip-workspace omits workspace_id entirely. + {name: "empty workspace_id", workspaceID: ""}, + // Legacy shape: older CLIs persisted the "none" sentinel. + {name: "legacy none sentinel", workspaceID: "none"}, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.CleanupEnvironment(t) + t.Setenv("PATH", "") + + cfg := &config.Config{ + Host: "https://example.test/", + AccountID: "abc-123", + WorkspaceID: tt.workspaceID, + Token: "foobar", + Profile: "bb", + HTTPTransport: noNetworkTransport, + } - w, err := workspaceClientOrPrompt(t.Context(), cfg, false) - assert.Nil(t, w) - require.Error(t, err) - assert.Contains(t, err.Error(), `profile "bb"`) - assert.Contains(t, err.Error(), "account-only") - assert.Contains(t, err.Error(), "workspace_id = none") + w, err := workspaceClientOrPrompt(t.Context(), cfg, false) + assert.Nil(t, w) + require.Error(t, err) + assert.Contains(t, err.Error(), `profile "bb"`) + assert.Contains(t, err.Error(), "account-only") + assert.Contains(t, err.Error(), "no workspace_id set") + }) + } } func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) { From da9f7e818dd2e17b44b6886ea7726a90d2a46c51 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 14:44:18 +0200 Subject: [PATCH 3/4] auth: fall through to account client for account-only profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, `databricks auth describe --profile ` reached `w.CurrentUser.Me()` in describe.go, which on a SPOG host with workspace_id absent (or the legacy "none" sentinel) hit the account-plane and was rejected by the backend with "Unable to load OAuth Config" — a false negative for a valid profile. - Promote `accountOnlyProfileError` from a plain `errors.Errorf` to a typed `ErrAccountOnlyProfile` so callers can detect it. - Extend MustAnyClient's fall-through check to include the new type, so workspace commands (clusters list, etc.) keep getting the clear actionable error from PR #5340 while MustAnyClient callers (auth describe, the only one today) recover and describe the profile via the account client. Co-authored-by: Isaac --- cmd/root/auth.go | 23 +++++++++++++++++++---- cmd/root/auth_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/cmd/root/auth.go b/cmd/root/auth.go index c3823ce96d..919444c655 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -52,10 +52,23 @@ func initProfileFlag(cmd *cobra.Command) { cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion) } +// 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 +// describe` and similar) recognizes the type and falls through to the account +// client so account-only profiles still describe cleanly. +type ErrAccountOnlyProfile struct { + profileName string +} + +func (e ErrAccountOnlyProfile) Error() string { + return fmt.Sprintf("profile %q has no workspace_id set (account-only); this command requires a workspace. Edit the profile to set workspace_id to a real ID, or pass --profile with a workspace-scoped profile", e.profileName) +} + // accountOnlyProfileError describes why a workspace command can't run against // a profile that has an account_id but no workspace_id. func accountOnlyProfileError(profileName string) error { - return fmt.Errorf("profile %q has no workspace_id set (account-only); this command requires a workspace. Edit the profile to set workspace_id to a real ID, or pass --profile with a workspace-scoped profile", profileName) + return ErrAccountOnlyProfile{profileName: profileName} } func profileFlagValue(cmd *cobra.Command) (string, bool) { @@ -133,9 +146,11 @@ func MustAnyClient(cmd *cobra.Command, args []string) (bool, error) { } // If the error indicates a wrong config type (workspace host used for account client, - // or config type mismatch detected by workspaceClientOrPrompt), fall through to try - // account client. - if _, ok := errors.AsType[ErrNoWorkspaceProfiles](werr); !errors.Is(werr, errNotWorkspaceClient) && !ok { + // or config type mismatch detected by workspaceClientOrPrompt), or an account-only + // profile (no workspace_id), fall through to try the account client. + _, noWorkspaceProfiles := errors.AsType[ErrNoWorkspaceProfiles](werr) + _, accountOnly := errors.AsType[ErrAccountOnlyProfile](werr) + if !errors.Is(werr, errNotWorkspaceClient) && !noWorkspaceProfiles && !accountOnly { return false, werr } diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index dcc70cc9e4..9ba5d5e080 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -2,6 +2,7 @@ package root import ( "context" + "errors" "net/http" "os" "path/filepath" @@ -481,6 +482,8 @@ func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) { w, err := workspaceClientOrPrompt(t.Context(), cfg, false) assert.Nil(t, w) require.Error(t, err) + var accountOnly ErrAccountOnlyProfile + require.ErrorAs(t, err, &accountOnly) assert.Contains(t, err.Error(), `profile "bb"`) assert.Contains(t, err.Error(), "account-only") assert.Contains(t, err.Error(), "no workspace_id set") @@ -488,6 +491,39 @@ func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) { } } +func TestMustAnyClientFallsThroughOnAccountOnlyProfile(t *testing.T) { + testutil.CleanupEnvironment(t) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + err := os.WriteFile(configFile, []byte(` +[skipws] +host = https://spog.example.test/ +account_id = abc-123 +auth_type = databricks-cli +workspace_id = none +`), 0o600) + require.NoError(t, err) + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + t.Setenv("PATH", "") + + // MustAnyClient should recognize ErrAccountOnlyProfile from the workspace + // path and try the account path so `auth describe` works on account-only + // profiles. We can't assert on a successful client here (no real OAuth + // token), but we can verify the workspace path produced the typed error + // and the fall-through reaches the account path. + cfg := &config.Config{ + Host: "https://spog.example.test/", + Profile: "skipws", + AccountID: "abc-123", + WorkspaceID: "none", + HTTPTransport: noNetworkTransport, + } + _, err = workspaceClientOrPrompt(t.Context(), cfg, false) + require.Error(t, err) + _, ok := errors.AsType[ErrAccountOnlyProfile](err) + assert.True(t, ok, "expected ErrAccountOnlyProfile to surface; MustAnyClient depends on this type for fall-through") +} + func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) { testutil.CleanupEnvironment(t) t.Setenv("PATH", "") From fa7f3a1ca503b5469cf401e6ae3bc91d2a7dc9ee Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 27 May 2026 15:05:47 +0200 Subject: [PATCH 4/4] auth: exercise MustAnyClient in fall-through test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPT review pointed out the previous TestMustAnyClientFallsThroughOnAccountOnlyProfile only verified workspaceClientOrPrompt returned ErrAccountOnlyProfile — it never called MustAnyClient, so removing the fall-through case from MustAnyClient wouldn't have failed the test. Drive it end-to-end: build the same cobra command with --profile, invoke MustAnyClient, and assert it returns (true, nil) with an AccountClient on the context. Co-authored-by: Isaac --- cmd/root/auth_test.go | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index 9ba5d5e080..bcbcc3d1bc 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -2,7 +2,6 @@ package root import ( "context" - "errors" "net/http" "os" "path/filepath" @@ -493,35 +492,31 @@ func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) { func TestMustAnyClientFallsThroughOnAccountOnlyProfile(t *testing.T) { testutil.CleanupEnvironment(t) + t.Setenv("PATH", "") configFile := filepath.Join(t.TempDir(), ".databrickscfg") err := os.WriteFile(configFile, []byte(` [skipws] -host = https://spog.example.test/ +host = https://accounts.azuredatabricks.net/ account_id = abc-123 -auth_type = databricks-cli +token = foobar workspace_id = none `), 0o600) require.NoError(t, err) t.Setenv("DATABRICKS_CONFIG_FILE", configFile) - t.Setenv("PATH", "") - // MustAnyClient should recognize ErrAccountOnlyProfile from the workspace - // path and try the account path so `auth describe` works on account-only - // profiles. We can't assert on a successful client here (no real OAuth - // token), but we can verify the workspace path produced the typed error - // and the fall-through reaches the account path. - cfg := &config.Config{ - Host: "https://spog.example.test/", - Profile: "skipws", - AccountID: "abc-123", - WorkspaceID: "none", - HTTPTransport: noNetworkTransport, - } - _, err = workspaceClientOrPrompt(t.Context(), cfg, false) - require.Error(t, err) - _, ok := errors.AsType[ErrAccountOnlyProfile](err) - assert.True(t, ok, "expected ErrAccountOnlyProfile to surface; MustAnyClient depends on this type for fall-through") + ctx, tt := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true}) + t.Cleanup(tt.Done) + cmd := New(ctx) + require.NoError(t, cmd.PersistentFlags().Set("profile", "skipws")) + + // Workspace path returns ErrAccountOnlyProfile. MustAnyClient must + // recognize the type and fall through to the account client so + // `auth describe` shows account info for account-only profiles. + isAccount, err := MustAnyClient(cmd, []string{}) + require.NoError(t, err) + require.True(t, isAccount, "expected fall-through to account client") + require.NotNil(t, cmdctx.AccountClient(cmd.Context())) } func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) {