Skip to content

Commit 083cb51

Browse files
authored
auth describe: try both verification endpoints before reporting failure (#5512)
## Why `databricks auth describe` makes a single verification call based on the client type it resolved (workspace client: `CurrentUser.Me`, account client: `Workspaces.List`). If that one call fails, describe prints "Unable to authenticate" even when the credentials are perfectly valid. A profile with an accounts host plus both `account_id` and `workspace_id` (older logins wrote this shape) resolves to a workspace client. The token works, but `CurrentUser.Me` against the accounts host always fails with HTTP 400, so describe reports failure while `databricks auth token` succeeds. Fixes #5479. ## Changes Before, describe gave up after one failed verification call; now it tries the other endpoint and only reports failure when neither proves the credentials. If the first verification call fails, describe builds the other client type from the same resolved config (non-interactively, over the same config pointer) and tries its verification call. Account branch falls back to `CurrentUser.Me`; workspace branch falls back to `Workspaces.List` when an `account_id` is configured (account clients require one). If the fallback succeeds, describe reports success with the matching fields (username from `Me`, account ID for account-side success). If both calls fail, describe reports the first error. Success paths still make exactly one call. An earlier revision also treated HTTP 403 on both checks as proof of authentication (to cover non-admin users on account hosts, where `Workspaces.List` is admin-only). That heuristic was dropped: 403 also comes from network-level gates such as IP access lists and private link, which can answer before credentials are validated, so it is not reliable proof. For non-admin account users, describe keeps reporting the underlying permission error as-is. The output templates are unchanged. ## Test plan - [x] Unit tests in `cmd/auth/describe_test.go`: workspace check fails then account check succeeds; account check fails then workspace check succeeds; both fail reports the first error; no second call without an `account_id` - [x] Acceptance test `acceptance/cmd/auth/describe/account-host-with-workspace-id`: end-to-end reproduction of the issue (workspace client on an account host, `Me` returns 400, account fallback succeeds) - [x] Existing describe unit and acceptance tests pass unchanged (happy paths still make exactly one verification call) - [x] `./task checks` This pull request and its description were written by Isaac.
1 parent 41b4a19 commit 083cb51

7 files changed

Lines changed: 286 additions & 35 deletions

File tree

‎NEXT_CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
### CLI
99
* Added the `databricks quickstart` command, a short introduction to the CLI that prints a human-friendly guide interactively and an agent-oriented version when run non-interactively ([#5464](https://github.com/databricks/cli/pull/5464)).
10+
* `databricks auth describe` now verifies credentials against both the workspace and account endpoints before reporting a failure, fixing false "Unable to authenticate" errors for account console profiles ([#5479](https://github.com/databricks/cli/issues/5479)).
11+
* `databricks auth login` no longer prompts for workspace selection when logging in to an account console host (`https://accounts.*`). Pass `--workspace-id` explicitly to store a workspace ID on such a profile ([#5504](https://github.com/databricks/cli/pull/5504)).
1012
* `databricks auth profiles --skip-validate` no longer makes any network calls; the host metadata fetch is skipped along with validation ([#5530](https://github.com/databricks/cli/pull/5530)).
1113

1214

‎acceptance/cmd/auth/describe/account-host-with-workspace-id/out.test.toml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
=== Describe falls back to the account endpoint when the workspace check fails
3+
4+
>>> [CLI] auth describe --profile acct-with-ws
5+
Host: [DATABRICKS_URL]
6+
Account ID: acct-123
7+
Authenticated with: pat
8+
-----
9+
Current configuration:
10+
✓ host: [DATABRICKS_URL] (from DATABRICKS_HOST environment variable)
11+
✓ account_id: acct-123 (from [TEST_TMP_DIR]/home/.databrickscfg config file)
12+
✓ workspace_id: [NUMID] (from [TEST_TMP_DIR]/home/.databrickscfg config file)
13+
✓ token: ******** (from DATABRICKS_TOKEN environment variable)
14+
✓ profile: acct-with-ws (from --profile flag)
15+
✓ databricks_cli_path: [CLI]
16+
✓ auth_type: pat
17+
✓ rate_limit: [NUMID] (from DATABRICKS_RATE_LIMIT environment variable)
18+
✓ cloud: AWS
19+
✓ discovery_url: [DATABRICKS_URL]/oidc/.well-known/oauth-authorization-server
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
sethome "./home"
2+
3+
# Older logins wrote account console profiles with both account_id and
4+
# workspace_id, which resolves to a workspace client even though the host only
5+
# serves account APIs (https://github.com/databricks/cli/issues/5479).
6+
cat > "./home/.databrickscfg" <<EOF
7+
[acct-with-ws]
8+
host = $DATABRICKS_HOST
9+
token = $DATABRICKS_TOKEN
10+
account_id = acct-123
11+
workspace_id = 900800700600
12+
EOF
13+
14+
title "Describe falls back to the account endpoint when the workspace check fails\n"
15+
trace $CLI auth describe --profile acct-with-ws
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Ignore = [
2+
"home"
3+
]
4+
5+
# Simulate an account console host: it cannot serve workspace APIs, so the
6+
# workspace-side verification call fails even though the credentials are valid.
7+
[[Server]]
8+
Pattern = "GET /api/2.0/preview/scim/v2/Me"
9+
Response.Body = 'Unable to load OAuth Config'
10+
Response.StatusCode = 400
11+
12+
[[Server]]
13+
Pattern = "GET /api/2.0/accounts/acct-123/workspaces"
14+
Response.Body = '[]'

‎cmd/auth/describe.go‎

Lines changed: 69 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/databricks/cli/libs/env"
1515
"github.com/databricks/cli/libs/flags"
1616
"github.com/databricks/cli/libs/log"
17+
"github.com/databricks/databricks-sdk-go"
1718
"github.com/databricks/databricks-sdk-go/config"
1819
"github.com/databricks/databricks-sdk-go/service/iam"
1920
"github.com/spf13/cobra"
@@ -122,63 +123,96 @@ func getAuthStatus(cmd *cobra.Command, args []string, showSensitive bool, fn try
122123
cfg, isAccount, err := fn(cmd, args)
123124
ctx := cmd.Context()
124125
if err != nil {
125-
details := getAuthDetails(cmd, cfg, showSensitive)
126-
return &authStatus{ //nolint:nilerr // error is returned in the authStatus struct
127-
Status: "error",
128-
Error: err,
129-
Details: details,
130-
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
131-
}, nil
126+
return errorAuthStatus(ctx, cmd, cfg, showSensitive, err), nil //nolint:nilerr // error is returned in the authStatus struct
132127
}
133128

134129
if isAccount {
135130
a := cmdctx.AccountClient(ctx)
136131

137132
// Doing a simple API call to check if the auth is valid
138133
_, err := a.Workspaces.List(ctx)
139-
if err != nil {
140-
details := getAuthDetails(cmd, cfg, showSensitive)
141-
return &authStatus{ //nolint:nilerr // error is returned in the authStatus struct
142-
Status: "error",
143-
Error: err,
144-
Details: details,
145-
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
146-
}, nil
134+
if err == nil {
135+
return successAuthStatus(ctx, cmd, a.Config, showSensitive, a.Config.Username, a.Config.AccountID), nil
147136
}
148137

149-
details := getAuthDetails(cmd, a.Config, showSensitive)
150-
status := authStatus{
151-
Status: "success",
152-
Details: details,
153-
AccountID: a.Config.AccountID,
154-
Username: a.Config.Username,
155-
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
138+
// Workspaces.List can fail for reasons other than bad credentials
139+
// (e.g. it requires account admin), so verify against the workspace
140+
// endpoint with the same resolved config before reporting failure.
141+
me, meErr := verifyWorkspaceAuth(ctx, a.Config)
142+
if meErr == nil {
143+
return successAuthStatus(ctx, cmd, a.Config, showSensitive, me.UserName, ""), nil
156144
}
157-
158-
return &status, nil
145+
return errorAuthStatus(ctx, cmd, cfg, showSensitive, err), nil
159146
}
160147

161148
w := cmdctx.WorkspaceClient(ctx)
162149
me, err := w.CurrentUser.Me(ctx, iam.MeRequest{})
150+
if err == nil {
151+
return successAuthStatus(ctx, cmd, w.Config, showSensitive, me.UserName, ""), nil
152+
}
153+
154+
// Account console profiles that also carry a workspace_id resolve to a
155+
// workspace client even though the host only serves account APIs, so the
156+
// call above fails despite valid credentials (see
157+
// https://github.com/databricks/cli/issues/5479). Verify against the
158+
// account endpoint before reporting failure. Account clients require an
159+
// account ID, so skip the second check when none is configured.
160+
if w.Config.AccountID != "" {
161+
if listErr := verifyAccountAuth(ctx, w.Config); listErr == nil {
162+
return successAuthStatus(ctx, cmd, w.Config, showSensitive, w.Config.Username, w.Config.AccountID), nil
163+
}
164+
}
165+
return errorAuthStatus(ctx, cmd, cfg, showSensitive, err), nil
166+
}
167+
168+
// verifyWorkspaceAuth checks whether cfg's credentials are accepted by the
169+
// workspace endpoint. The client is built over the same resolved config
170+
// pointer (config.Config must not be copied by value), without any profile
171+
// prompting.
172+
func verifyWorkspaceAuth(ctx context.Context, cfg *config.Config) (*iam.User, error) {
173+
w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg))
174+
if err != nil {
175+
return nil, err
176+
}
177+
return w.CurrentUser.Me(ctx, iam.MeRequest{})
178+
}
179+
180+
// verifyAccountAuth checks whether cfg's credentials are accepted by the
181+
// account endpoint. cfg.AccountID must be non-empty. The client is built over
182+
// the same resolved config pointer (config.Config must not be copied by
183+
// value), without any profile prompting.
184+
func verifyAccountAuth(ctx context.Context, cfg *config.Config) error {
185+
a, err := databricks.NewAccountClient((*databricks.Config)(cfg))
163186
if err != nil {
164-
details := getAuthDetails(cmd, cfg, showSensitive)
165-
return &authStatus{ //nolint:nilerr // error is returned in the authStatus struct
166-
Status: "error",
167-
Error: err,
168-
Details: details,
169-
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
170-
}, nil
187+
return err
171188
}
189+
_, err = a.Workspaces.List(ctx)
190+
return err
191+
}
172192

173-
details := getAuthDetails(cmd, w.Config, showSensitive)
174-
status := authStatus{
193+
// successAuthStatus reports verified credentials, with details derived from cfg.
194+
func successAuthStatus(ctx context.Context, cmd *cobra.Command, cfg *config.Config, showSensitive bool, username, accountID string) *authStatus {
195+
details := getAuthDetails(cmd, cfg, showSensitive)
196+
return &authStatus{
175197
Status: "success",
176198
Details: details,
177-
Username: me.UserName,
199+
Username: username,
200+
AccountID: accountID,
178201
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
179202
}
203+
}
180204

181-
return &status, nil
205+
// errorAuthStatus reports a failed credential check. The error is carried in
206+
// the status rather than returned so describe still renders the resolved
207+
// configuration alongside the failure.
208+
func errorAuthStatus(ctx context.Context, cmd *cobra.Command, cfg *config.Config, showSensitive bool, err error) *authStatus {
209+
details := getAuthDetails(cmd, cfg, showSensitive)
210+
return &authStatus{
211+
Status: "error",
212+
Error: err,
213+
Details: details,
214+
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
215+
}
182216
}
183217

184218
func render(ctx context.Context, cmd *cobra.Command, status *authStatus, template string) error {

‎cmd/auth/describe_test.go‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package auth
22

33
import (
4+
"encoding/json"
45
"errors"
6+
"net/http"
7+
"net/http/httptest"
58
"os"
69
"path/filepath"
710
"testing"
811

912
"github.com/databricks/cli/libs/auth/storage"
1013
"github.com/databricks/cli/libs/cmdctx"
1114
"github.com/databricks/cli/libs/databrickscfg/profile"
15+
"github.com/databricks/databricks-sdk-go/apierr"
1216
"github.com/databricks/databricks-sdk-go/config"
1317
"github.com/databricks/databricks-sdk-go/experimental/mocks"
1418
"github.com/databricks/databricks-sdk-go/service/iam"
@@ -397,6 +401,166 @@ func TestGetWorkspaceAuthStatus_U2M_PopulatesTokenStorage(t *testing.T) {
397401
assert.Equal(t, "DATABRICKS_AUTH_STORAGE environment variable", status.TokenStorage.Source)
398402
}
399403

404+
// describeVerifyServer simulates the host that describe's secondary
405+
// verification calls hit: CurrentUser.Me and account Workspaces.List.
406+
// Statuses other than 200 return an error body with that status code; a zero
407+
// status marks an endpoint that must not be called at all.
408+
func describeVerifyServer(t *testing.T, accountID string, meStatus, listStatus int) *httptest.Server {
409+
t.Helper()
410+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
411+
respond := func(status int, okBody any) {
412+
if status == 0 {
413+
t.Errorf("unexpected request to %s", r.URL.Path)
414+
status = http.StatusNotFound
415+
}
416+
w.Header().Set("Content-Type", "application/json")
417+
if status != http.StatusOK {
418+
w.WriteHeader(status)
419+
okBody = map[string]any{"error_code": "TEST_ERROR", "message": "secondary check failed"}
420+
}
421+
require.NoError(t, json.NewEncoder(w).Encode(okBody))
422+
}
423+
switch r.URL.Path {
424+
case "/api/2.0/preview/scim/v2/Me":
425+
respond(meStatus, map[string]any{"userName": "fallback-user"})
426+
case "/api/2.0/accounts/" + accountID + "/workspaces":
427+
respond(listStatus, []map[string]any{})
428+
default:
429+
w.WriteHeader(http.StatusNotFound)
430+
}
431+
}))
432+
t.Cleanup(server.Close)
433+
return server
434+
}
435+
436+
// newDescribeWorkspaceCmd wires a mocked workspace client whose Me call fails
437+
// with meErr, with cfg as its resolved configuration.
438+
func newDescribeWorkspaceCmd(t *testing.T, cfg *config.Config, meErr error) *cobra.Command {
439+
t.Helper()
440+
m := mocks.NewMockWorkspaceClient(t)
441+
m.WorkspaceClient.Config = cfg
442+
m.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything, mock.Anything).Return(nil, meErr)
443+
cmd := newHostProfileCmd(t)
444+
cmd.SetContext(cmdctx.SetWorkspaceClient(t.Context(), m.WorkspaceClient))
445+
t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), ".databrickscfg"))
446+
return cmd
447+
}
448+
449+
// newDescribeAccountCmd wires a mocked account client whose Workspaces.List
450+
// call fails with listErr, with cfg as its resolved configuration.
451+
func newDescribeAccountCmd(t *testing.T, cfg *config.Config, listErr error) *cobra.Command {
452+
t.Helper()
453+
m := mocks.NewMockAccountClient(t)
454+
m.AccountClient.Config = cfg
455+
m.GetMockWorkspacesAPI().EXPECT().List(mock.Anything).Return(nil, listErr)
456+
cmd := newHostProfileCmd(t)
457+
cmd.SetContext(cmdctx.SetAccountClient(t.Context(), m.AccountClient))
458+
t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), ".databrickscfg"))
459+
return cmd
460+
}
461+
462+
// resolveCfg returns a tryAuth that resolves cfg from attrs and reports the
463+
// given client type, mirroring what root.MustAnyClient leaves behind.
464+
func resolveCfg(t *testing.T, cfg *config.Config, attrs map[string]string, isAccount bool) tryAuth {
465+
t.Helper()
466+
return func(cmd *cobra.Command, args []string) (*config.Config, bool, error) {
467+
require.NoError(t, config.ConfigAttributes.ResolveFromStringMap(cfg, attrs))
468+
return cfg, isAccount, nil
469+
}
470+
}
471+
472+
// TestGetAuthStatusVerificationFallback covers the fallback when the primary
473+
// verification call (mocked client) fails: describe tries the other endpoint
474+
// over HTTP against describeVerifyServer, and only when both fail does it
475+
// report the primary error. Error rows leave wantUsername/wantAccountID empty
476+
// because errorAuthStatus never sets them.
477+
func TestGetAuthStatusVerificationFallback(t *testing.T) {
478+
tests := []struct {
479+
name string
480+
isAccount bool
481+
primaryErr *apierr.APIError
482+
meStatus int
483+
listStatus int
484+
accountID string
485+
wantStatus string
486+
wantUsername string
487+
wantAccountID string
488+
}{
489+
{
490+
name: "workspace check fails, account check succeeds",
491+
primaryErr: &apierr.APIError{StatusCode: http.StatusBadRequest, Message: "Unable to load OAuth Config"},
492+
meStatus: http.StatusNotFound,
493+
listStatus: http.StatusOK,
494+
accountID: "test-acct",
495+
wantStatus: "success",
496+
wantAccountID: "test-acct",
497+
},
498+
{
499+
name: "no second call without an account id",
500+
primaryErr: &apierr.APIError{StatusCode: http.StatusBadRequest, Message: "Unable to load OAuth Config"},
501+
wantStatus: "error",
502+
},
503+
{
504+
name: "account check fails, workspace check succeeds",
505+
isAccount: true,
506+
primaryErr: &apierr.APIError{StatusCode: http.StatusForbidden, Message: "This API is disabled for users without account admin status"},
507+
meStatus: http.StatusOK,
508+
listStatus: http.StatusNotFound,
509+
accountID: "test-acct",
510+
wantStatus: "success",
511+
wantUsername: "fallback-user",
512+
},
513+
{
514+
name: "workspace branch, both checks fail",
515+
primaryErr: &apierr.APIError{StatusCode: http.StatusBadRequest, Message: "Unable to load OAuth Config"},
516+
meStatus: http.StatusNotFound,
517+
listStatus: http.StatusUnauthorized,
518+
accountID: "test-acct",
519+
wantStatus: "error",
520+
},
521+
{
522+
name: "account branch, both checks fail",
523+
isAccount: true,
524+
primaryErr: &apierr.APIError{StatusCode: http.StatusUnauthorized, Message: "credentials expired"},
525+
meStatus: http.StatusUnauthorized,
526+
listStatus: http.StatusNotFound,
527+
accountID: "test-acct",
528+
wantStatus: "error",
529+
},
530+
}
531+
532+
for _, tc := range tests {
533+
t.Run(tc.name, func(t *testing.T) {
534+
t.Setenv("DATABRICKS_ACCOUNT_ID", "")
535+
server := describeVerifyServer(t, tc.accountID, tc.meStatus, tc.listStatus)
536+
cfg := &config.Config{}
537+
var cmd *cobra.Command
538+
if tc.isAccount {
539+
cmd = newDescribeAccountCmd(t, cfg, tc.primaryErr)
540+
} else {
541+
cmd = newDescribeWorkspaceCmd(t, cfg, tc.primaryErr)
542+
}
543+
attrs := map[string]string{
544+
"host": server.URL,
545+
"token": "test-token",
546+
"auth_type": "pat",
547+
}
548+
if tc.accountID != "" {
549+
attrs["account_id"] = tc.accountID
550+
}
551+
552+
status, err := getAuthStatus(cmd, []string{}, false, resolveCfg(t, cfg, attrs, tc.isAccount))
553+
require.NoError(t, err)
554+
require.Equal(t, tc.wantStatus, status.Status)
555+
assert.Equal(t, tc.wantUsername, status.Username)
556+
assert.Equal(t, tc.wantAccountID, status.AccountID)
557+
if tc.wantStatus == "error" {
558+
assert.ErrorIs(t, status.Error, tc.primaryErr)
559+
}
560+
})
561+
}
562+
}
563+
400564
func TestGetWorkspaceAuthStatus_NonU2M_OmitsTokenStorage(t *testing.T) {
401565
ctx := t.Context()
402566
m := mocks.NewMockWorkspaceClient(t)

0 commit comments

Comments
 (0)