Skip to content

Commit 30f637f

Browse files
authored
auth: handle account-only profiles cleanly for workspace and describe commands (#5340)
## Why Two related bugs in a bug bash, both rooted in profiles created with \`databricks auth login --skip-workspace\` (no \`workspace_id\`, or the legacy \`workspace_id = none\` sentinel): 1. **Workspace commands** (e.g. \`databricks clusters list --profile <account-only>\`) failed with the opaque backend error \"Credential was not sent or was of an unsupported type for this API\" — the SDK forwarded the literal sentinel as a routing identifier, or sent an unrouted request to the account-plane. 2. **\`databricks auth describe --profile <account-only>\`** printed the false-negative \`Unable to authenticate: Unable to load OAuth Config\` on a perfectly valid profile — describe calls \`w.CurrentUser.Me()\` after MustAnyClient, which hits the workspace plane without a workspace_id and the backend rejects the call. Reproduced both against \`db-deco-test.databricks.com\` with a real \`--skip-workspace\` profile. ## Changes - \`workspaceClientOrPrompt\` detects an account-only profile (\`account_id\` set + \`workspace_id\` empty or the legacy \"none\" sentinel + an explicit \`Profile\`) before any API call and returns a typed \`ErrAccountOnlyProfile\` with an actionable message naming the profile. - \`MustAnyClient\` (used by \`auth describe\` and similar) recognizes \`ErrAccountOnlyProfile\` and falls through to the account client, so account-only profiles describe cleanly via the account plane. - Workspace commands (\`MustWorkspaceClient\` directly) keep getting the actionable error — they need a workspace. After the fix on the same \`db-deco-test\` profile: \`\`\` $ databricks auth describe --profile bug2-test Host: https://db-deco-test.databricks.com Account ID: 968367da-... Authenticated with: databricks-cli ✓ host: https://db-deco-test.databricks.com (from .databrickscfg) ✓ account_id: 968367da-... (from .databrickscfg) ✓ workspace_id: none (from .databrickscfg) ✓ profile: bug2-test (from --profile flag) ✓ auth_type: databricks-cli ... $ databricks clusters list --profile bug2-test Error: profile \"bug2-test\" 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 \`\`\` The env-var-only path (no \`Profile\` set) is intentionally left alone — unified hosts can serve workspace APIs from the account host with just \`DATABRICKS_HOST\` + \`DATABRICKS_ACCOUNT_ID\` + a token, and we don't want to reject those. ## Test plan - [x] Reproduced both bugs against \`db-deco-test.databricks.com\` with a real \`--skip-workspace\`-created profile. - [x] After the fix: \`auth describe --profile bug2-test\` succeeds and shows account info; \`clusters list --profile bug2-test\` gives the actionable error. - [x] Existing valid SPOG workspace profile (with \`workspace_id\`) still works against the same host — no regression. - [x] New unit tests: \`TestWorkspaceClientOrPromptRejectsAccountOnlyProfile\` (both shapes), \`TestMustAnyClientFallsThroughOnAccountOnlyProfile\`. - [x] \`go test ./cmd/root/...\`, \`./task checks\`, \`./task lint-q\`.
1 parent 5e6847b commit 30f637f

2 files changed

Lines changed: 102 additions & 3 deletions

File tree

cmd/root/auth.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,25 @@ func initProfileFlag(cmd *cobra.Command) {
5252
cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion)
5353
}
5454

55+
// ErrAccountOnlyProfile signals that the resolved profile has an account_id
56+
// but no workspace_id, so workspace APIs can't be reached. Workspace-only
57+
// commands surface this as an actionable error; MustAnyClient (used by `auth
58+
// describe` and similar) recognizes the type and falls through to the account
59+
// client so account-only profiles still describe cleanly.
60+
type ErrAccountOnlyProfile struct {
61+
profileName string
62+
}
63+
64+
func (e ErrAccountOnlyProfile) Error() string {
65+
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)
66+
}
67+
68+
// accountOnlyProfileError describes why a workspace command can't run against
69+
// a profile that has an account_id but no workspace_id.
70+
func accountOnlyProfileError(profileName string) error {
71+
return ErrAccountOnlyProfile{profileName: profileName}
72+
}
73+
5574
func profileFlagValue(cmd *cobra.Command) (string, bool) {
5675
profileFlag := cmd.Flag("profile")
5776
if profileFlag == nil {
@@ -127,9 +146,11 @@ func MustAnyClient(cmd *cobra.Command, args []string) (bool, error) {
127146
}
128147

129148
// If the error indicates a wrong config type (workspace host used for account client,
130-
// or config type mismatch detected by workspaceClientOrPrompt), fall through to try
131-
// account client.
132-
if _, ok := errors.AsType[ErrNoWorkspaceProfiles](werr); !errors.Is(werr, errNotWorkspaceClient) && !ok {
149+
// or config type mismatch detected by workspaceClientOrPrompt), or an account-only
150+
// profile (no workspace_id), fall through to try the account client.
151+
_, noWorkspaceProfiles := errors.AsType[ErrNoWorkspaceProfiles](werr)
152+
_, accountOnly := errors.AsType[ErrAccountOnlyProfile](werr)
153+
if !errors.Is(werr, errNotWorkspaceClient) && !noWorkspaceProfiles && !accountOnly {
133154
return false, werr
134155
}
135156

@@ -190,6 +211,19 @@ func MustAccountClient(cmd *cobra.Command, args []string) error {
190211
// Helper function to create a workspace client or prompt once if the given configuration is not valid.
191212
func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPrompt bool) (*databricks.WorkspaceClient, error) {
192213
w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg))
214+
if err == nil && cfg.Profile != "" && cfg.AccountID != "" &&
215+
(cfg.WorkspaceID == "" || cfg.WorkspaceID == auth.WorkspaceIDNone) {
216+
// Account-only profile (created with --skip-workspace): account_id is
217+
// set but workspace_id is absent (new shape) or the legacy "none"
218+
// sentinel. Without a workspace_id the SDK would either send "none" as
219+
// a routing identifier or fail later with an opaque auth error.
220+
// Reject up front with a message the user can act on.
221+
//
222+
// We require cfg.Profile to be set so we don't reject env-var-only
223+
// configs targeting a unified host where workspace APIs are also
224+
// served from the account host.
225+
return nil, accountOnlyProfileError(cfg.Profile)
226+
}
193227
if err == nil {
194228
err = w.Config.Authenticate(emptyHttpRequest(ctx))
195229
}

cmd/root/auth_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,71 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) {
454454
assert.ErrorIs(t, err, databricks.ErrNotAccountClient)
455455
}
456456

457+
func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) {
458+
tests := []struct {
459+
name string
460+
workspaceID string
461+
}{
462+
// New shape: --skip-workspace omits workspace_id entirely.
463+
{name: "empty workspace_id", workspaceID: ""},
464+
// Legacy shape: older CLIs persisted the "none" sentinel.
465+
{name: "legacy none sentinel", workspaceID: "none"},
466+
}
467+
for _, tt := range tests {
468+
t.Run(tt.name, func(t *testing.T) {
469+
testutil.CleanupEnvironment(t)
470+
t.Setenv("PATH", "")
471+
472+
cfg := &config.Config{
473+
Host: "https://example.test/",
474+
AccountID: "abc-123",
475+
WorkspaceID: tt.workspaceID,
476+
Token: "foobar",
477+
Profile: "bb",
478+
HTTPTransport: noNetworkTransport,
479+
}
480+
481+
w, err := workspaceClientOrPrompt(t.Context(), cfg, false)
482+
assert.Nil(t, w)
483+
require.Error(t, err)
484+
var accountOnly ErrAccountOnlyProfile
485+
require.ErrorAs(t, err, &accountOnly)
486+
assert.Contains(t, err.Error(), `profile "bb"`)
487+
assert.Contains(t, err.Error(), "account-only")
488+
assert.Contains(t, err.Error(), "no workspace_id set")
489+
})
490+
}
491+
}
492+
493+
func TestMustAnyClientFallsThroughOnAccountOnlyProfile(t *testing.T) {
494+
testutil.CleanupEnvironment(t)
495+
t.Setenv("PATH", "")
496+
497+
configFile := filepath.Join(t.TempDir(), ".databrickscfg")
498+
err := os.WriteFile(configFile, []byte(`
499+
[skipws]
500+
host = https://accounts.azuredatabricks.net/
501+
account_id = abc-123
502+
token = foobar
503+
workspace_id = none
504+
`), 0o600)
505+
require.NoError(t, err)
506+
t.Setenv("DATABRICKS_CONFIG_FILE", configFile)
507+
508+
ctx, tt := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
509+
t.Cleanup(tt.Done)
510+
cmd := New(ctx)
511+
require.NoError(t, cmd.PersistentFlags().Set("profile", "skipws"))
512+
513+
// Workspace path returns ErrAccountOnlyProfile. MustAnyClient must
514+
// recognize the type and fall through to the account client so
515+
// `auth describe` shows account info for account-only profiles.
516+
isAccount, err := MustAnyClient(cmd, []string{})
517+
require.NoError(t, err)
518+
require.True(t, isAccount, "expected fall-through to account client")
519+
require.NotNil(t, cmdctx.AccountClient(cmd.Context()))
520+
}
521+
457522
func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) {
458523
testutil.CleanupEnvironment(t)
459524
t.Setenv("PATH", "")

0 commit comments

Comments
 (0)