Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

### CLI
* 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)).
* `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)).
* `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)).
* `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)).


Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

=== Describe falls back to the account endpoint when the workspace check fails

>>> [CLI] auth describe --profile acct-with-ws
Host: [DATABRICKS_URL]
Account ID: acct-123
Authenticated with: pat
-----
Current configuration:
✓ host: [DATABRICKS_URL] (from DATABRICKS_HOST environment variable)
✓ account_id: acct-123 (from [TEST_TMP_DIR]/home/.databrickscfg config file)
✓ workspace_id: [NUMID] (from [TEST_TMP_DIR]/home/.databrickscfg config file)
✓ token: ******** (from DATABRICKS_TOKEN environment variable)
✓ profile: acct-with-ws (from --profile flag)
✓ databricks_cli_path: [CLI]
✓ auth_type: pat
✓ rate_limit: [NUMID] (from DATABRICKS_RATE_LIMIT environment variable)
✓ cloud: AWS
✓ discovery_url: [DATABRICKS_URL]/oidc/.well-known/oauth-authorization-server
15 changes: 15 additions & 0 deletions acceptance/cmd/auth/describe/account-host-with-workspace-id/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
sethome "./home"

# Older logins wrote account console profiles with both account_id and
# workspace_id, which resolves to a workspace client even though the host only
# serves account APIs (https://github.com/databricks/cli/issues/5479).
cat > "./home/.databrickscfg" <<EOF
[acct-with-ws]
host = $DATABRICKS_HOST
token = $DATABRICKS_TOKEN
account_id = acct-123
workspace_id = 900800700600
EOF

title "Describe falls back to the account endpoint when the workspace check fails\n"
trace $CLI auth describe --profile acct-with-ws
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Ignore = [
"home"
]

# Simulate an account console host: it cannot serve workspace APIs, so the
# workspace-side verification call fails even though the credentials are valid.
[[Server]]
Pattern = "GET /api/2.0/preview/scim/v2/Me"
Response.Body = 'Unable to load OAuth Config'
Response.StatusCode = 400

[[Server]]
Pattern = "GET /api/2.0/accounts/acct-123/workspaces"
Response.Body = '[]'
104 changes: 69 additions & 35 deletions cmd/auth/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/flags"
"github.com/databricks/cli/libs/log"
"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/service/iam"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -122,63 +123,96 @@ func getAuthStatus(cmd *cobra.Command, args []string, showSensitive bool, fn try
cfg, isAccount, err := fn(cmd, args)
ctx := cmd.Context()
if err != nil {
details := getAuthDetails(cmd, cfg, showSensitive)
return &authStatus{ //nolint:nilerr // error is returned in the authStatus struct
Status: "error",
Error: err,
Details: details,
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
}, nil
return errorAuthStatus(ctx, cmd, cfg, showSensitive, err), nil //nolint:nilerr // error is returned in the authStatus struct
}

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

// Doing a simple API call to check if the auth is valid
_, err := a.Workspaces.List(ctx)
if err != nil {
details := getAuthDetails(cmd, cfg, showSensitive)
return &authStatus{ //nolint:nilerr // error is returned in the authStatus struct
Status: "error",
Error: err,
Details: details,
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
}, nil
if err == nil {
return successAuthStatus(ctx, cmd, a.Config, showSensitive, a.Config.Username, a.Config.AccountID), nil
}

details := getAuthDetails(cmd, a.Config, showSensitive)
status := authStatus{
Status: "success",
Details: details,
AccountID: a.Config.AccountID,
Username: a.Config.Username,
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
// Workspaces.List can fail for reasons other than bad credentials
// (e.g. it requires account admin), so verify against the workspace
// endpoint with the same resolved config before reporting failure.
me, meErr := verifyWorkspaceAuth(ctx, a.Config)
if meErr == nil {
return successAuthStatus(ctx, cmd, a.Config, showSensitive, me.UserName, ""), nil
}

return &status, nil
return errorAuthStatus(ctx, cmd, cfg, showSensitive, err), nil
}

w := cmdctx.WorkspaceClient(ctx)
me, err := w.CurrentUser.Me(ctx, iam.MeRequest{})
if err == nil {
return successAuthStatus(ctx, cmd, w.Config, showSensitive, me.UserName, ""), nil
}

// Account console profiles that also carry a workspace_id resolve to a
// workspace client even though the host only serves account APIs, so the
// call above fails despite valid credentials (see
// https://github.com/databricks/cli/issues/5479). Verify against the
// account endpoint before reporting failure. Account clients require an
// account ID, so skip the second check when none is configured.
if w.Config.AccountID != "" {
if listErr := verifyAccountAuth(ctx, w.Config); listErr == nil {
return successAuthStatus(ctx, cmd, w.Config, showSensitive, w.Config.Username, w.Config.AccountID), nil
}
}
return errorAuthStatus(ctx, cmd, cfg, showSensitive, err), nil
}

// verifyWorkspaceAuth checks whether cfg's credentials are accepted by the
// workspace endpoint. The client is built over the same resolved config
// pointer (config.Config must not be copied by value), without any profile
// prompting.
func verifyWorkspaceAuth(ctx context.Context, cfg *config.Config) (*iam.User, error) {
w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg))
if err != nil {
return nil, err
}
return w.CurrentUser.Me(ctx, iam.MeRequest{})
}

// verifyAccountAuth checks whether cfg's credentials are accepted by the
// account endpoint. cfg.AccountID must be non-empty. The client is built over
// the same resolved config pointer (config.Config must not be copied by
// value), without any profile prompting.
func verifyAccountAuth(ctx context.Context, cfg *config.Config) error {
a, err := databricks.NewAccountClient((*databricks.Config)(cfg))
if err != nil {
details := getAuthDetails(cmd, cfg, showSensitive)
return &authStatus{ //nolint:nilerr // error is returned in the authStatus struct
Status: "error",
Error: err,
Details: details,
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
}, nil
return err
}
_, err = a.Workspaces.List(ctx)
return err
}

details := getAuthDetails(cmd, w.Config, showSensitive)
status := authStatus{
// successAuthStatus reports verified credentials, with details derived from cfg.
func successAuthStatus(ctx context.Context, cmd *cobra.Command, cfg *config.Config, showSensitive bool, username, accountID string) *authStatus {
details := getAuthDetails(cmd, cfg, showSensitive)
return &authStatus{
Status: "success",
Details: details,
Username: me.UserName,
Username: username,
AccountID: accountID,
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
}
}

return &status, nil
// errorAuthStatus reports a failed credential check. The error is carried in
// the status rather than returned so describe still renders the resolved
// configuration alongside the failure.
func errorAuthStatus(ctx context.Context, cmd *cobra.Command, cfg *config.Config, showSensitive bool, err error) *authStatus {
details := getAuthDetails(cmd, cfg, showSensitive)
return &authStatus{
Status: "error",
Error: err,
Details: details,
TokenStorage: resolveTokenStorageInfo(ctx, details.AuthType),
}
}

func render(ctx context.Context, cmd *cobra.Command, status *authStatus, template string) error {
Expand Down
164 changes: 164 additions & 0 deletions cmd/auth/describe_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package auth

import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/databricks/cli/libs/auth/storage"
"github.com/databricks/cli/libs/cmdctx"
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/experimental/mocks"
"github.com/databricks/databricks-sdk-go/service/iam"
Expand Down Expand Up @@ -397,6 +401,166 @@ func TestGetWorkspaceAuthStatus_U2M_PopulatesTokenStorage(t *testing.T) {
assert.Equal(t, "DATABRICKS_AUTH_STORAGE environment variable", status.TokenStorage.Source)
}

// describeVerifyServer simulates the host that describe's secondary
// verification calls hit: CurrentUser.Me and account Workspaces.List.
// Statuses other than 200 return an error body with that status code; a zero
// status marks an endpoint that must not be called at all.
func describeVerifyServer(t *testing.T, accountID string, meStatus, listStatus int) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
respond := func(status int, okBody any) {
if status == 0 {
t.Errorf("unexpected request to %s", r.URL.Path)
status = http.StatusNotFound
}
w.Header().Set("Content-Type", "application/json")
if status != http.StatusOK {
w.WriteHeader(status)
okBody = map[string]any{"error_code": "TEST_ERROR", "message": "secondary check failed"}
}
require.NoError(t, json.NewEncoder(w).Encode(okBody))
}
switch r.URL.Path {
case "/api/2.0/preview/scim/v2/Me":
respond(meStatus, map[string]any{"userName": "fallback-user"})
case "/api/2.0/accounts/" + accountID + "/workspaces":
respond(listStatus, []map[string]any{})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
return server
}

// newDescribeWorkspaceCmd wires a mocked workspace client whose Me call fails
// with meErr, with cfg as its resolved configuration.
func newDescribeWorkspaceCmd(t *testing.T, cfg *config.Config, meErr error) *cobra.Command {
t.Helper()
m := mocks.NewMockWorkspaceClient(t)
m.WorkspaceClient.Config = cfg
m.GetMockCurrentUserAPI().EXPECT().Me(mock.Anything, mock.Anything).Return(nil, meErr)
cmd := newHostProfileCmd(t)
cmd.SetContext(cmdctx.SetWorkspaceClient(t.Context(), m.WorkspaceClient))
t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), ".databrickscfg"))
return cmd
}

// newDescribeAccountCmd wires a mocked account client whose Workspaces.List
// call fails with listErr, with cfg as its resolved configuration.
func newDescribeAccountCmd(t *testing.T, cfg *config.Config, listErr error) *cobra.Command {
t.Helper()
m := mocks.NewMockAccountClient(t)
m.AccountClient.Config = cfg
m.GetMockWorkspacesAPI().EXPECT().List(mock.Anything).Return(nil, listErr)
cmd := newHostProfileCmd(t)
cmd.SetContext(cmdctx.SetAccountClient(t.Context(), m.AccountClient))
t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), ".databrickscfg"))
return cmd
}

// resolveCfg returns a tryAuth that resolves cfg from attrs and reports the
// given client type, mirroring what root.MustAnyClient leaves behind.
func resolveCfg(t *testing.T, cfg *config.Config, attrs map[string]string, isAccount bool) tryAuth {
t.Helper()
return func(cmd *cobra.Command, args []string) (*config.Config, bool, error) {
require.NoError(t, config.ConfigAttributes.ResolveFromStringMap(cfg, attrs))
return cfg, isAccount, nil
}
}

// TestGetAuthStatusVerificationFallback covers the fallback when the primary
// verification call (mocked client) fails: describe tries the other endpoint
// over HTTP against describeVerifyServer, and only when both fail does it
// report the primary error. Error rows leave wantUsername/wantAccountID empty
// because errorAuthStatus never sets them.
func TestGetAuthStatusVerificationFallback(t *testing.T) {
tests := []struct {
name string
isAccount bool
primaryErr *apierr.APIError
meStatus int
listStatus int
accountID string
wantStatus string
wantUsername string
wantAccountID string
}{
{
name: "workspace check fails, account check succeeds",
primaryErr: &apierr.APIError{StatusCode: http.StatusBadRequest, Message: "Unable to load OAuth Config"},
meStatus: http.StatusNotFound,
listStatus: http.StatusOK,
accountID: "test-acct",
wantStatus: "success",
wantAccountID: "test-acct",
},
{
name: "no second call without an account id",
primaryErr: &apierr.APIError{StatusCode: http.StatusBadRequest, Message: "Unable to load OAuth Config"},
wantStatus: "error",
},
{
name: "account check fails, workspace check succeeds",
isAccount: true,
primaryErr: &apierr.APIError{StatusCode: http.StatusForbidden, Message: "This API is disabled for users without account admin status"},
meStatus: http.StatusOK,
listStatus: http.StatusNotFound,
accountID: "test-acct",
wantStatus: "success",
wantUsername: "fallback-user",
},
{
name: "workspace branch, both checks fail",
primaryErr: &apierr.APIError{StatusCode: http.StatusBadRequest, Message: "Unable to load OAuth Config"},
meStatus: http.StatusNotFound,
listStatus: http.StatusUnauthorized,
accountID: "test-acct",
wantStatus: "error",
},
{
name: "account branch, both checks fail",
isAccount: true,
primaryErr: &apierr.APIError{StatusCode: http.StatusUnauthorized, Message: "credentials expired"},
meStatus: http.StatusUnauthorized,
listStatus: http.StatusNotFound,
accountID: "test-acct",
wantStatus: "error",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DATABRICKS_ACCOUNT_ID", "")
server := describeVerifyServer(t, tc.accountID, tc.meStatus, tc.listStatus)
cfg := &config.Config{}
var cmd *cobra.Command
if tc.isAccount {
cmd = newDescribeAccountCmd(t, cfg, tc.primaryErr)
} else {
cmd = newDescribeWorkspaceCmd(t, cfg, tc.primaryErr)
}
attrs := map[string]string{
"host": server.URL,
"token": "test-token",
"auth_type": "pat",
}
if tc.accountID != "" {
attrs["account_id"] = tc.accountID
}

status, err := getAuthStatus(cmd, []string{}, false, resolveCfg(t, cfg, attrs, tc.isAccount))
require.NoError(t, err)
require.Equal(t, tc.wantStatus, status.Status)
assert.Equal(t, tc.wantUsername, status.Username)
assert.Equal(t, tc.wantAccountID, status.AccountID)
if tc.wantStatus == "error" {
assert.ErrorIs(t, status.Error, tc.primaryErr)
}
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Could we somehow remove duplication from these tests? Maybe a table driven if it's easy to do?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a2473db: folded the five fallback tests into one table-driven test (TestGetAuthStatusVerificationFallback). A zero status in the test server now means "this endpoint must not be called", which absorbs the no-account-id case into the table. Note the file also shrank in the meantime: the 403-as-success commit was dropped from the PR, so the three tests for it are gone.

func TestGetWorkspaceAuthStatus_NonU2M_OmitsTokenStorage(t *testing.T) {
ctx := t.Context()
m := mocks.NewMockWorkspaceClient(t)
Expand Down
Loading