Skip to content
Open
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
5 changes: 5 additions & 0 deletions acceptance/cmd/auth/describe/with-scopes/out.test.toml

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

17 changes: 17 additions & 0 deletions acceptance/cmd/auth/describe/with-scopes/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

>>> [CLI] auth login --host [DATABRICKS_URL] --profile scoped-profile --scopes jobs,pipelines,clusters
Profile scoped-profile was successfully saved

>>> [CLI] auth describe --profile scoped-profile
Host: [DATABRICKS_URL]
User: [USERNAME]
Authenticated with: databricks-cli
-----
Current configuration:
✓ host: [DATABRICKS_URL] (from DATABRICKS_HOST environment variable)
~ token: ******** (from DATABRICKS_TOKEN environment variable, not used for auth type databricks-cli)
✓ profile: scoped-profile (from --profile flag)
✓ scopes: clusters,jobs,pipelines (from ./home/.databrickscfg config file)
✓ databricks_cli_path: [CLI]
✓ auth_type: databricks-cli (from ./home/.databrickscfg config file)
✓ rate_limit: [NUMID] (from DATABRICKS_RATE_LIMIT environment variable)
11 changes: 11 additions & 0 deletions acceptance/cmd/auth/describe/with-scopes/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
sethome "./home"

# Use a fake browser that performs a GET on the authorization URL
# and follows the redirect back to localhost.
export BROWSER="browser.py"

# Login with restricted scopes to populate the OAuth token cache
trace $CLI auth login --host $DATABRICKS_HOST --profile scoped-profile --scopes "jobs,pipelines,clusters"

# Now describe should show the scopes from the cached OAuth token
trace $CLI auth describe --profile scoped-profile
3 changes: 3 additions & 0 deletions acceptance/cmd/auth/describe/with-scopes/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Ignore = [
"home"
]
4 changes: 2 additions & 2 deletions acceptance/cmd/auth/login/nominal/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
Profile test was successfully saved

>>> [CLI] auth profiles
Name Host Valid
test [DATABRICKS_URL] YES
Name Host Client ID Scopes Valid
test [DATABRICKS_URL] - all-apis YES
7 changes: 7 additions & 0 deletions acceptance/cmd/auth/login/with-scopes/out.databrickscfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified.
[DEFAULT]

[scoped-test]
host = [DATABRICKS_URL]
scopes = jobs,pipelines,clusters
auth_type = databricks-cli
5 changes: 5 additions & 0 deletions acceptance/cmd/auth/login/with-scopes/out.test.toml

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

9 changes: 9 additions & 0 deletions acceptance/cmd/auth/login/with-scopes/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

>>> [CLI] auth login --host [DATABRICKS_URL] --profile scoped-test --scopes jobs,pipelines,clusters
Profile scoped-test was successfully saved

>>> [CLI] auth profiles
Name Host Client ID Scopes Valid
scoped-test [DATABRICKS_URL] - clusters,jobs,pipelines -

Note: Validation is skipped for profiles without the 'all-apis' scope because the validation API may not be accessible with restricted scopes.
11 changes: 11 additions & 0 deletions acceptance/cmd/auth/login/with-scopes/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
sethome "./home"

# Use a fake browser that performs a GET on the authorization URL
# and follows the redirect back to localhost.
export BROWSER="browser.py"

trace $CLI auth login --host $DATABRICKS_HOST --profile scoped-test --scopes "jobs,pipelines,clusters"
trace $CLI auth profiles

# Track the .databrickscfg file that was created to surface changes.
mv "./home/.databrickscfg" "./out.databrickscfg"
3 changes: 3 additions & 0 deletions acceptance/cmd/auth/login/with-scopes/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Ignore = [
"home"
]
5 changes: 5 additions & 0 deletions acceptance/cmd/auth/profiles/with-scopes/out.test.toml

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

9 changes: 9 additions & 0 deletions acceptance/cmd/auth/profiles/with-scopes/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

>>> [CLI] auth profiles
Name Host Client ID Scopes Valid
pat [DATABRICKS_URL] - all-apis YES
restricted-scopes [DATABRICKS_URL] - jobs,pipelines -
m2m [DATABRICKS_URL] acbd1232123 files,sql -
default-scopes [DATABRICKS_URL] - all-apis NO

Note: Validation is skipped for profiles without the 'all-apis' scope because the validation API may not be accessible with restricted scopes.
28 changes: 28 additions & 0 deletions acceptance/cmd/auth/profiles/with-scopes/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
sethome "./home"

# Create profiles with different scope configurations
cat > "./home/.databrickscfg" <<EOF
[DEFAULT]

[pat]
host = $DATABRICKS_HOST
auth_type = pat
token = dapi1234567890

[restricted-scopes]
host = $DATABRICKS_HOST
scopes = jobs,pipelines
auth_type = databricks-cli

[m2m]
host = $DATABRICKS_HOST
scopes = sql,files
client_id = acbd1232123
auth_type = m2m

[default-scopes]
host = $DATABRICKS_HOST
auth_type = databricks-cli
EOF

trace $CLI auth profiles
3 changes: 3 additions & 0 deletions acceptance/cmd/auth/profiles/with-scopes/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Ignore = [
"home"
]
20 changes: 18 additions & 2 deletions cmd/auth/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"slices"

"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/cmdctx"
Expand Down Expand Up @@ -95,7 +96,7 @@ func getAuthStatus(cmd *cobra.Command, args []string, showSensitive bool, fn try
if err != nil {
return &authStatus{
Status: "error",
Error: err,
Error: wrapAuthErrorWithScopeContext(err, cfg),
Details: getAuthDetails(cmd, cfg, showSensitive),
}, nil
}
Expand All @@ -115,7 +116,7 @@ func getAuthStatus(cmd *cobra.Command, args []string, showSensitive bool, fn try
if err != nil {
return &authStatus{
Status: "error",
Error: err,
Error: wrapAuthErrorWithScopeContext(err, cfg),
Details: getAuthDetails(cmd, cfg, showSensitive),
}, nil
}
Expand Down Expand Up @@ -193,3 +194,18 @@ func getAuthDetails(cmd *cobra.Command, cfg *config.Config, showSensitive bool)

return details
}

// wrapAuthErrorWithScopeContext adds context to an authentication error when the
// configuration does not include the 'all-apis' scope, which may cause validation
// API calls to fail even though the token itself is valid for its intended purpose.
Comment on lines +198 to +200
Copy link
Contributor Author

@tejaskochar-db tejaskochar-db Jan 22, 2026

Choose a reason for hiding this comment

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

In case of insufficient scopes, the server responds with a 403 and Provided OAuth token does not have required scopes: ... message. I could instead match on the error message and determine auth to be valid instead of adding a note saying the error may just be due to insufficient scopes.
This is, of course, brittle but allows for more accurate validation.

What is the reason for having this validation in the first place?

func wrapAuthErrorWithScopeContext(err error, cfg *config.Config) error {
if cfg == nil {
return err
}
scopes := cfg.GetScopes()
if slices.Contains(scopes, "all-apis") {
return err
}
return fmt.Errorf("%w\n\nNote: The error above may be due to the use of restricted scopes. "+
"Your authentication may still be valid for the scopes you requested", err)
}
119 changes: 119 additions & 0 deletions cmd/auth/describe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,122 @@ func TestGetAccountAuthStatus(t *testing.T) {
require.Equal(t, "--profile flag", status.Details.Configuration["profile"].Source.String())
require.False(t, status.Details.Configuration["profile"].AuthTypeMismatch)
}

func TestGetWorkspaceAuthStatusWithScopes(t *testing.T) {
ctx := context.Background()
m := mocks.NewMockWorkspaceClient(t)
ctx = cmdctx.SetWorkspaceClient(ctx, m.WorkspaceClient)

cmd := &cobra.Command{}
cmd.SetContext(ctx)

showSensitive := false

currentUserApi := m.GetMockCurrentUserAPI()
currentUserApi.EXPECT().Me(mock.Anything).Return(&iam.User{
UserName: "test-user",
}, nil)

cmd.Flags().String("host", "", "")
cmd.Flags().String("profile", "", "")
err := cmd.Flag("profile").Value.Set("scoped-profile")
require.NoError(t, err)
cmd.Flag("profile").Changed = true

cfg := &config.Config{
Profile: "scoped-profile",
Scopes: []string{"jobs", "pipelines", "clusters"},
}
m.WorkspaceClient.Config = cfg
err = config.ConfigAttributes.Configure(cfg)
require.NoError(t, err)

status, err := getAuthStatus(cmd, []string{}, showSensitive, func(cmd *cobra.Command, args []string) (*config.Config, bool, error) {
err := config.ConfigAttributes.ResolveFromStringMap(cfg, map[string]string{
"host": "https://test.com",
"auth_type": "databricks-cli",
})
require.NoError(t, err)
return cfg, false, nil
})
require.NoError(t, err)
require.NotNil(t, status)
require.Equal(t, "success", status.Status)
require.Equal(t, "test-user", status.Username)
require.Equal(t, "https://test.com", status.Details.Host)
require.Equal(t, "databricks-cli", status.Details.AuthType)

scopesConfig := status.Details.Configuration["scopes"]
require.NotNil(t, scopesConfig)
// Scopes are stored in the order provided in config, not sorted
require.Equal(t, "jobs,pipelines,clusters", scopesConfig.Value)
}

func TestWrapAuthErrorWithScopeContext(t *testing.T) {
originalErr := errors.New("permission denied")
wrappedErr := "permission denied\n\nNote: The error above may be due to the use of restricted scopes. " +
"Your authentication may still be valid for the scopes you requested"

tests := []struct {
name string
cfg *config.Config
expectError string
}{
{
name: "nil config",
cfg: nil,
expectError: "permission denied",
},
{
name: "restricted scopes",
cfg: &config.Config{Scopes: []string{"jobs", "pipelines"}},
expectError: wrappedErr,
},
{
// Empty scopes defaults to "all-apis".
name: "empty scopes",
cfg: &config.Config{},
expectError: "permission denied",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := wrapAuthErrorWithScopeContext(originalErr, tc.cfg)
require.EqualError(t, err, tc.expectError)
})
}
}

func TestGetWorkspaceAuthStatusErrorWithRestrictedScopes(t *testing.T) {
ctx := context.Background()
m := mocks.NewMockWorkspaceClient(t)
ctx = cmdctx.SetWorkspaceClient(ctx, m.WorkspaceClient)

cmd := &cobra.Command{}
cmd.SetContext(ctx)

currentUserApi := m.GetMockCurrentUserAPI()
currentUserApi.EXPECT().Me(mock.Anything).Return(nil, errors.New("permission denied"))

cmd.Flags().String("host", "", "")
cmd.Flags().String("profile", "", "")

cfg := &config.Config{
Profile: "scoped-profile",
Scopes: []string{"jobs", "pipelines"},
}
m.WorkspaceClient.Config = cfg

status, err := getAuthStatus(cmd, []string{}, false, func(cmd *cobra.Command, args []string) (*config.Config, bool, error) {
_ = config.ConfigAttributes.ResolveFromStringMap(cfg, map[string]string{
"host": "https://test.com",
"auth_type": "databricks-cli",
})
return cfg, false, nil
})
require.NoError(t, err)
require.Equal(t, "error", status.Status)
require.Contains(t, status.Error.Error(), "permission denied")
require.Contains(t, status.Error.Error(), "restricted scopes")
}
16 changes: 15 additions & 1 deletion cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,15 @@ depends on the existing profiles you have set in your configuration file
var loginTimeout time.Duration
var configureCluster bool
var configureServerless bool
var scopes string
cmd.Flags().DurationVar(&loginTimeout, "timeout", defaultTimeout,
"Timeout for completing login challenge in the browser")
cmd.Flags().BoolVar(&configureCluster, "configure-cluster", false,
"Prompts to configure cluster")
cmd.Flags().BoolVar(&configureServerless, "configure-serverless", false,
"Prompts to configure serverless")
cmd.Flags().StringVar(&scopes, "scopes", "",
"Comma-separated list of OAuth scopes to request (defaults to 'all-apis')")

cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
Expand Down Expand Up @@ -149,14 +152,24 @@ depends on the existing profiles you have set in your configuration file
return err
}

var scopesList []string
if scopes != "" {
for _, s := range strings.Split(scopes, ",") {
scopesList = append(scopesList, strings.TrimSpace(s))
}
}

oauthArgument, err := authArguments.ToOAuthArgument()
if err != nil {
return err
}
persistentAuthOpts := []u2m.PersistentAuthOption{
u2m.WithOAuthArgument(oauthArgument),
u2m.WithBrowser(getBrowserFunc(cmd)),
}
if len(scopesList) > 0 {
persistentAuthOpts = append(persistentAuthOpts, u2m.WithScopes(scopesList))
}
persistentAuthOpts = append(persistentAuthOpts, u2m.WithBrowser(getBrowserFunc(cmd)))
persistentAuth, err := u2m.NewPersistentAuth(ctx, persistentAuthOpts...)
if err != nil {
return err
Expand Down Expand Up @@ -222,6 +235,7 @@ depends on the existing profiles you have set in your configuration file
ClusterID: clusterID,
ConfigFile: os.Getenv("DATABRICKS_CONFIG_FILE"),
ServerlessComputeID: serverlessComputeID,
Scopes: scopesList,
})
if err != nil {
return err
Expand Down
20 changes: 20 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,23 @@ func TestLoadProfileByNameAndClusterID(t *testing.T) {
})
}
}

func TestLoadProfileWithScopes(t *testing.T) {
t.Setenv("DATABRICKS_CONFIG_FILE", "./testdata/.databrickscfg")
ctx := context.Background()

profile := loadTestProfile(t, ctx, "scoped-profile")

assert.Equal(t, "scoped-profile", profile.Name)
assert.Equal(t, "https://www.host1.com", profile.Host)
}

func TestLoadProfileWithScopesAndClientID(t *testing.T) {
t.Setenv("DATABRICKS_CONFIG_FILE", "./testdata/.databrickscfg")
ctx := context.Background()

profile := loadTestProfile(t, ctx, "scoped-profile-with-client")

assert.Equal(t, "scoped-profile-with-client", profile.Name)
assert.Equal(t, "https://www.host2.com", profile.Host)
}
Loading