From 13ba6b73df28f64809fdc02a8713e9b06c92237e Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Tue, 20 Jan 2026 08:26:45 +0000 Subject: [PATCH 1/7] add scopes to auth describe --- cmd/auth/describe.go | 21 +++++++- cmd/auth/describe_test.go | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go index c21eab376c..b85a9b6604 100644 --- a/cmd/auth/describe.go +++ b/cmd/auth/describe.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" @@ -21,6 +22,7 @@ var authTemplate = `{{"Host:" | bold}} {{.Status.Details.Host}} {{"User:" | bold}} {{.Status.Username}} {{- end}} {{"Authenticated with:" | bold}} {{.Status.Details.AuthType}} +{{"Scopes:" | bold}} {{if (index .Status.Details.Configuration "scopes")}}{{(index .Status.Details.Configuration "scopes").Value}}{{else}}all-apis{{end}} ----- ` + configurationTemplate @@ -95,7 +97,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 } @@ -115,7 +117,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 } @@ -193,3 +195,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. +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) +} diff --git a/cmd/auth/describe_test.go b/cmd/auth/describe_test.go index de97953a84..e3eb8d7508 100644 --- a/cmd/auth/describe_test.go +++ b/cmd/auth/describe_test.go @@ -224,3 +224,112 @@ 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") + + t.Run("nil config returns original error", func(t *testing.T) { + err := wrapAuthErrorWithScopeContext(originalErr, nil) + require.Equal(t, originalErr, err) + }) + + t.Run("restricted scopes appends note", func(t *testing.T) { + cfg := &config.Config{Scopes: []string{"jobs", "pipelines"}} + err := wrapAuthErrorWithScopeContext(originalErr, cfg) + + require.ErrorIs(t, err, originalErr) + require.Contains(t, err.Error(), "restricted scopes") + require.Contains(t, err.Error(), "authentication may still be valid") + }) + + t.Run("empty scopes treated as all-apis", func(t *testing.T) { + cfg := &config.Config{} + err := wrapAuthErrorWithScopeContext(originalErr, cfg) + // Empty scopes defaults to all-apis, so original error returned + require.Equal(t, originalErr, err) + }) +} + +func TestGetWorkspaceAuthStatusErrorWithRestrictedScopes(t *testing.T) { + ctx := context.Background() + m := mocks.NewMockWorkspaceClient(t) + ctx = cmdctx.SetWorkspaceClient(ctx, m.WorkspaceClient) + + cmd := &cobra.Command{} + cmd.SetContext(ctx) + + // Simulate API failure due to restricted scopes + 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") +} From 32d73973917d82c8952bcb2b9d592c20698407b2 Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Tue, 20 Jan 2026 08:31:16 +0000 Subject: [PATCH 2/7] add scopes to auth profiles --- cmd/auth/profiles.go | 54 +++++++++++++++++++----- cmd/auth/profiles_test.go | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go index 7181f2b1c6..ab0aef50b7 100644 --- a/cmd/auth/profiles.go +++ b/cmd/auth/profiles.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io/fs" + "slices" + "strings" "sync" "time" @@ -18,12 +20,15 @@ import ( ) type profileMetadata struct { - Name string `json:"name"` - Host string `json:"host,omitempty"` - AccountID string `json:"account_id,omitempty"` - Cloud string `json:"cloud"` - AuthType string `json:"auth_type"` - Valid bool `json:"valid"` + Name string `json:"name"` + Host string `json:"host,omitempty"` + AccountID string `json:"account_id,omitempty"` + Cloud string `json:"cloud"` + AuthType string `json:"auth_type"` + Scopes string `json:"scopes,omitempty"` + ClientID string `json:"client_id,omitempty"` + Valid bool `json:"valid"` + ValidationSkipped bool `json:"validation_skipped,omitempty"` } func (c *profileMetadata) IsEmpty() bool { @@ -45,12 +50,26 @@ func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipV c.Cloud = "gcp" } + c.Scopes = strings.Join(cfg.GetScopes(), ",") + c.ClientID = cfg.ClientID + + // Check if all-apis scope is present. If not, validation may be unreliable + // because the validation API calls may not be accessible with restricted scopes. + hasAllApisScope := slices.Contains(cfg.GetScopes(), "all-apis") + if skipValidate { c.Host = cfg.CanonicalHostName() c.AuthType = cfg.AuthType return } + if !hasAllApisScope { + c.Host = cfg.CanonicalHostName() + c.AuthType = cfg.AuthType + c.ValidationSkipped = true + return + } + //nolint:staticcheck // SA1019: IsAccountClient is deprecated but is still used here to avoid breaking changes if cfg.IsAccountClient() { a, err := databricks.NewAccountClient((*databricks.Config)(cfg)) @@ -85,8 +104,8 @@ func newProfilesCommand() *cobra.Command { Short: "Lists profiles from ~/.databrickscfg", Annotations: map[string]string{ "template": cmdio.Heredoc(` - {{header "Name"}} {{header "Host"}} {{header "Valid"}} - {{range .Profiles}}{{.Name | green}} {{.Host|cyan}} {{bool .Valid}} + {{header "Name"}} {{header "Host"}} {{header "Client ID"}} {{header "Scopes"}} {{header "Valid"}} + {{range .}}{{.Name | green}} {{.Host | cyan}} {{if .ClientID}}{{.ClientID | magenta}}{{else}}{{ "-" | magenta}}{{end}} {{if .Scopes}}{{.Scopes | yellow}}{{else}}{{"all-apis" | yellow}}{{end}} {{if .ValidationSkipped}}{{ "-" | yellow}}{{else}}{{bool .Valid}}{{end}} {{end}}`), }, } @@ -125,9 +144,22 @@ func newProfilesCommand() *cobra.Command { profiles = append(profiles, profile) } wg.Wait() - return cmdio.Render(cmd.Context(), struct { - Profiles []*profileMetadata `json:"profiles"` - }{profiles}) + err = cmdio.Render(cmd.Context(), profiles) + if err != nil { + return err + } + + // Check if any profiles had validation skipped due to scopes + for _, p := range profiles { + if p.ValidationSkipped { + cmdio.LogString(cmd.Context(), + "\nNote: Validation is skipped for profiles without the 'all-apis' scope "+ + "because the validation API may not be accessible with restricted scopes.") + break + } + } + + return nil } return cmd diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index 91ff4d049f..9b14d58da7 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -44,3 +44,89 @@ func TestProfiles(t *testing.T) { assert.Equal(t, "aws", profile.Cloud) assert.Equal(t, "pat", profile.AuthType) } + +func TestProfilesWithScopes(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + // Create a config file with a profile that has scopes + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: "scoped-profile", + Host: "abc.cloud.databricks.com", + AuthType: "databricks-cli", + Scopes: []string{"jobs", "pipelines", "clusters"}, + }) + require.NoError(t, err) + + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + profile := &profileMetadata{Name: "scoped-profile"} + profile.Load(ctx, configFile, true) + + assert.Equal(t, "scoped-profile", profile.Name) + assert.Equal(t, "https://abc.cloud.databricks.com", profile.Host) + assert.Equal(t, "databricks-cli", profile.AuthType) + // Scopes are loaded from the resolved config via cfg.GetScopes() + assert.Equal(t, "clusters,jobs,pipelines", profile.Scopes) +} + +func TestProfilesWithDefaultScopes(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + // Create a config file with a profile that has no scopes + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: "default-scopes", + Host: "abc.cloud.databricks.com", + AuthType: "databricks-cli", + }) + require.NoError(t, err) + + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + profile := &profileMetadata{Name: "default-scopes"} + profile.Load(ctx, configFile, true) + + assert.Equal(t, "default-scopes", profile.Name) + // cfg.GetScopes() returns "all-apis" when no scopes are set + assert.Equal(t, "all-apis", profile.Scopes) +} + +func TestProfilesValidationSkippedForRestrictedScopes(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: "restricted", + Host: "abc.cloud.databricks.com", + AuthType: "databricks-cli", + Scopes: []string{"jobs", "pipelines"}, + }) + require.NoError(t, err) + + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + profile := &profileMetadata{Name: "restricted"} + // skipValidate=false but validation should still be skipped due to restricted scopes + profile.Load(ctx, configFile, false) + + assert.Equal(t, "restricted", profile.Name) + assert.Equal(t, "jobs,pipelines", profile.Scopes) + assert.True(t, profile.ValidationSkipped) + assert.False(t, profile.Valid) +} From ee2b9872f2c4fa630ebfe93c748ab9c27a1c5cd8 Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Tue, 20 Jan 2026 08:45:13 +0000 Subject: [PATCH 3/7] add scopes to auth login command --- cmd/auth/login.go | 16 +++++++++++++++- cmd/auth/login_test.go | 20 ++++++++++++++++++++ cmd/auth/testdata/.databrickscfg | 9 +++++++++ libs/databrickscfg/ops_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 733c404e33..74e3d0528c 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -99,12 +99,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() @@ -138,14 +141,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 @@ -209,6 +222,7 @@ depends on the existing profiles you have set in your configuration file ClusterID: cfg.ClusterID, ConfigFile: cfg.ConfigFile, ServerlessComputeID: cfg.ServerlessComputeID, + Scopes: scopesList, }) if err != nil { return err diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 4a12dedbdd..d2ebe9ae6f 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -187,3 +187,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) +} diff --git a/cmd/auth/testdata/.databrickscfg b/cmd/auth/testdata/.databrickscfg index 192839b9be..190f78e722 100644 --- a/cmd/auth/testdata/.databrickscfg +++ b/cmd/auth/testdata/.databrickscfg @@ -15,3 +15,12 @@ cluster_id = cluster-from-config [invalid-profile] # This profile is missing the required 'host' field cluster_id = some-cluster-id + +[scoped-profile] +host = https://www.host1.com +scopes = jobs,pipelines,clusters + +[scoped-profile-with-client] +host = https://www.host2.com +scopes = sql,files +client_id = my-custom-app diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index dd8484fb7b..155fa3ef0b 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -225,3 +225,31 @@ func TestSaveToProfile_ClearingPreviousProfile(t *testing.T) { assert.Equal(t, "https://foo", raw["host"]) assert.Equal(t, "databricks-cli", raw["auth_type"]) } + +func TestSaveToProfile_WithScopes(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "databrickscfg") + + err := SaveToProfile(ctx, &config.Config{ + ConfigFile: path, + Profile: "scoped", + Host: "https://myworkspace.cloud.databricks.com", + AuthType: "databricks-cli", + Scopes: []string{"jobs", "pipelines", "clusters"}, + }) + assert.NoError(t, err) + + // Verify we can read the scopes back from the config file + file, err := loadOrCreateConfigFile(path) + require.NoError(t, err) + section, err := file.GetSection("scoped") + require.NoError(t, err) + raw := section.KeysHash() + assert.Equal(t, "https://myworkspace.cloud.databricks.com", raw["host"]) + assert.Equal(t, "databricks-cli", raw["auth_type"]) + // Scopes are stored in the config file - verify they exist + assert.NotEmpty(t, raw["scopes"]) + assert.Contains(t, raw["scopes"], "jobs") + assert.Contains(t, raw["scopes"], "pipelines") + assert.Contains(t, raw["scopes"], "clusters") +} From 355231d1ea39d9ea3bc77e815ab1a805fbb9e50e Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Tue, 20 Jan 2026 08:45:38 +0000 Subject: [PATCH 4/7] add acceptance tests for scopes in auth commands --- .../cmd/auth/describe/with-scopes/script | 11 ++++++++ acceptance/cmd/auth/login/nominal/output.txt | 4 +-- .../auth/login/with-scopes/out.databrickscfg | 7 +++++ .../cmd/auth/login/with-scopes/out.test.toml | 5 ++++ .../cmd/auth/login/with-scopes/output.txt | 9 ++++++ acceptance/cmd/auth/login/with-scopes/script | 11 ++++++++ .../cmd/auth/login/with-scopes/test.toml | 3 ++ .../auth/profiles/with-scopes/out.test.toml | 5 ++++ .../cmd/auth/profiles/with-scopes/output.txt | 9 ++++++ .../cmd/auth/profiles/with-scopes/script | 28 +++++++++++++++++++ .../cmd/auth/profiles/with-scopes/test.toml | 3 ++ 11 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 acceptance/cmd/auth/describe/with-scopes/script create mode 100644 acceptance/cmd/auth/login/with-scopes/out.databrickscfg create mode 100644 acceptance/cmd/auth/login/with-scopes/out.test.toml create mode 100644 acceptance/cmd/auth/login/with-scopes/output.txt create mode 100644 acceptance/cmd/auth/login/with-scopes/script create mode 100644 acceptance/cmd/auth/login/with-scopes/test.toml create mode 100644 acceptance/cmd/auth/profiles/with-scopes/out.test.toml create mode 100644 acceptance/cmd/auth/profiles/with-scopes/output.txt create mode 100644 acceptance/cmd/auth/profiles/with-scopes/script create mode 100644 acceptance/cmd/auth/profiles/with-scopes/test.toml diff --git a/acceptance/cmd/auth/describe/with-scopes/script b/acceptance/cmd/auth/describe/with-scopes/script new file mode 100644 index 0000000000..6d53e5ca7a --- /dev/null +++ b/acceptance/cmd/auth/describe/with-scopes/script @@ -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 diff --git a/acceptance/cmd/auth/login/nominal/output.txt b/acceptance/cmd/auth/login/nominal/output.txt index b42bbd5527..01d70f5306 100644 --- a/acceptance/cmd/auth/login/nominal/output.txt +++ b/acceptance/cmd/auth/login/nominal/output.txt @@ -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 diff --git a/acceptance/cmd/auth/login/with-scopes/out.databrickscfg b/acceptance/cmd/auth/login/with-scopes/out.databrickscfg new file mode 100644 index 0000000000..7aac4e9365 --- /dev/null +++ b/acceptance/cmd/auth/login/with-scopes/out.databrickscfg @@ -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 diff --git a/acceptance/cmd/auth/login/with-scopes/out.test.toml b/acceptance/cmd/auth/login/with-scopes/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/login/with-scopes/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/login/with-scopes/output.txt b/acceptance/cmd/auth/login/with-scopes/output.txt new file mode 100644 index 0000000000..2f3a64bace --- /dev/null +++ b/acceptance/cmd/auth/login/with-scopes/output.txt @@ -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. diff --git a/acceptance/cmd/auth/login/with-scopes/script b/acceptance/cmd/auth/login/with-scopes/script new file mode 100644 index 0000000000..2a5c5332e0 --- /dev/null +++ b/acceptance/cmd/auth/login/with-scopes/script @@ -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" diff --git a/acceptance/cmd/auth/login/with-scopes/test.toml b/acceptance/cmd/auth/login/with-scopes/test.toml new file mode 100644 index 0000000000..36c0e7e237 --- /dev/null +++ b/acceptance/cmd/auth/login/with-scopes/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] diff --git a/acceptance/cmd/auth/profiles/with-scopes/out.test.toml b/acceptance/cmd/auth/profiles/with-scopes/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/profiles/with-scopes/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/profiles/with-scopes/output.txt b/acceptance/cmd/auth/profiles/with-scopes/output.txt new file mode 100644 index 0000000000..f1ee3a6fd9 --- /dev/null +++ b/acceptance/cmd/auth/profiles/with-scopes/output.txt @@ -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. diff --git a/acceptance/cmd/auth/profiles/with-scopes/script b/acceptance/cmd/auth/profiles/with-scopes/script new file mode 100644 index 0000000000..32812ad3b3 --- /dev/null +++ b/acceptance/cmd/auth/profiles/with-scopes/script @@ -0,0 +1,28 @@ +sethome "./home" + +# Create profiles with different scope configurations +cat > "./home/.databrickscfg" < Date: Thu, 22 Jan 2026 13:27:21 +0000 Subject: [PATCH 5/7] update auth describe acceptance test --- .../auth/describe/with-scopes/out.test.toml | 5 +++++ .../cmd/auth/describe/with-scopes/output.txt | 18 ++++++++++++++++++ .../cmd/auth/describe/with-scopes/test.toml | 3 +++ 3 files changed, 26 insertions(+) create mode 100644 acceptance/cmd/auth/describe/with-scopes/out.test.toml create mode 100644 acceptance/cmd/auth/describe/with-scopes/output.txt create mode 100644 acceptance/cmd/auth/describe/with-scopes/test.toml diff --git a/acceptance/cmd/auth/describe/with-scopes/out.test.toml b/acceptance/cmd/auth/describe/with-scopes/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/describe/with-scopes/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/describe/with-scopes/output.txt b/acceptance/cmd/auth/describe/with-scopes/output.txt new file mode 100644 index 0000000000..e37f7ca87e --- /dev/null +++ b/acceptance/cmd/auth/describe/with-scopes/output.txt @@ -0,0 +1,18 @@ + +>>> [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 +Scopes: clusters,jobs,pipelines +----- +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) diff --git a/acceptance/cmd/auth/describe/with-scopes/test.toml b/acceptance/cmd/auth/describe/with-scopes/test.toml new file mode 100644 index 0000000000..36c0e7e237 --- /dev/null +++ b/acceptance/cmd/auth/describe/with-scopes/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] From 32fa5fc099933df836a87d6b621e85ffabc9eff7 Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Thu, 22 Jan 2026 13:57:59 +0000 Subject: [PATCH 6/7] update describe command --- acceptance/cmd/auth/describe/with-scopes/output.txt | 1 - cmd/auth/describe.go | 1 - 2 files changed, 2 deletions(-) diff --git a/acceptance/cmd/auth/describe/with-scopes/output.txt b/acceptance/cmd/auth/describe/with-scopes/output.txt index e37f7ca87e..42fdbba153 100644 --- a/acceptance/cmd/auth/describe/with-scopes/output.txt +++ b/acceptance/cmd/auth/describe/with-scopes/output.txt @@ -6,7 +6,6 @@ Profile scoped-profile was successfully saved Host: [DATABRICKS_URL] User: [USERNAME] Authenticated with: databricks-cli -Scopes: clusters,jobs,pipelines ----- Current configuration: ✓ host: [DATABRICKS_URL] (from DATABRICKS_HOST environment variable) diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go index b85a9b6604..3301633ca8 100644 --- a/cmd/auth/describe.go +++ b/cmd/auth/describe.go @@ -22,7 +22,6 @@ var authTemplate = `{{"Host:" | bold}} {{.Status.Details.Host}} {{"User:" | bold}} {{.Status.Username}} {{- end}} {{"Authenticated with:" | bold}} {{.Status.Details.AuthType}} -{{"Scopes:" | bold}} {{if (index .Status.Details.Configuration "scopes")}}{{(index .Status.Details.Configuration "scopes").Value}}{{else}}all-apis{{end}} ----- ` + configurationTemplate From d54ef9e63ffdc33c316f280aa64152da796fa8e7 Mon Sep 17 00:00:00 2001 From: Tejas Kochar Date: Fri, 23 Jan 2026 13:37:28 +0000 Subject: [PATCH 7/7] refactor some tests and clean up some comments --- cmd/auth/describe_test.go | 52 ++++++++----- cmd/auth/profiles.go | 1 - cmd/auth/profiles_test.go | 137 ++++++++++++++------------------- libs/databrickscfg/ops_test.go | 10 +-- 4 files changed, 90 insertions(+), 110 deletions(-) diff --git a/cmd/auth/describe_test.go b/cmd/auth/describe_test.go index e3eb8d7508..a3dac9accd 100644 --- a/cmd/auth/describe_test.go +++ b/cmd/auth/describe_test.go @@ -277,27 +277,38 @@ func TestGetWorkspaceAuthStatusWithScopes(t *testing.T) { 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", + }, + } - t.Run("nil config returns original error", func(t *testing.T) { - err := wrapAuthErrorWithScopeContext(originalErr, nil) - require.Equal(t, originalErr, err) - }) - - t.Run("restricted scopes appends note", func(t *testing.T) { - cfg := &config.Config{Scopes: []string{"jobs", "pipelines"}} - err := wrapAuthErrorWithScopeContext(originalErr, cfg) - - require.ErrorIs(t, err, originalErr) - require.Contains(t, err.Error(), "restricted scopes") - require.Contains(t, err.Error(), "authentication may still be valid") - }) - - t.Run("empty scopes treated as all-apis", func(t *testing.T) { - cfg := &config.Config{} - err := wrapAuthErrorWithScopeContext(originalErr, cfg) - // Empty scopes defaults to all-apis, so original error returned - require.Equal(t, originalErr, err) - }) + 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) { @@ -308,7 +319,6 @@ func TestGetWorkspaceAuthStatusErrorWithRestrictedScopes(t *testing.T) { cmd := &cobra.Command{} cmd.SetContext(ctx) - // Simulate API failure due to restricted scopes currentUserApi := m.GetMockCurrentUserAPI() currentUserApi.EXPECT().Me(mock.Anything).Return(nil, errors.New("permission denied")) diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go index 2eb32532f1..51d9e63e16 100644 --- a/cmd/auth/profiles.go +++ b/cmd/auth/profiles.go @@ -152,7 +152,6 @@ func newProfilesCommand() *cobra.Command { return err } - // Check if any profiles had validation skipped due to scopes for _, p := range profiles { if p.ValidationSkipped { cmdio.LogString(cmd.Context(), diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index 9b14d58da7..c8a2c3b802 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -45,88 +45,63 @@ func TestProfiles(t *testing.T) { assert.Equal(t, "pat", profile.AuthType) } -func TestProfilesWithScopes(t *testing.T) { - ctx := context.Background() - dir := t.TempDir() - configFile := filepath.Join(dir, ".databrickscfg") - - // Create a config file with a profile that has scopes - err := databrickscfg.SaveToProfile(ctx, &config.Config{ - ConfigFile: configFile, - Profile: "scoped-profile", - Host: "abc.cloud.databricks.com", - AuthType: "databricks-cli", - Scopes: []string{"jobs", "pipelines", "clusters"}, - }) - require.NoError(t, err) - - t.Setenv("HOME", dir) - if runtime.GOOS == "windows" { - t.Setenv("USERPROFILE", dir) - } - - profile := &profileMetadata{Name: "scoped-profile"} - profile.Load(ctx, configFile, true) - - assert.Equal(t, "scoped-profile", profile.Name) - assert.Equal(t, "https://abc.cloud.databricks.com", profile.Host) - assert.Equal(t, "databricks-cli", profile.AuthType) - // Scopes are loaded from the resolved config via cfg.GetScopes() - assert.Equal(t, "clusters,jobs,pipelines", profile.Scopes) -} - -func TestProfilesWithDefaultScopes(t *testing.T) { - ctx := context.Background() - dir := t.TempDir() - configFile := filepath.Join(dir, ".databrickscfg") - - // Create a config file with a profile that has no scopes - err := databrickscfg.SaveToProfile(ctx, &config.Config{ - ConfigFile: configFile, - Profile: "default-scopes", - Host: "abc.cloud.databricks.com", - AuthType: "databricks-cli", - }) - require.NoError(t, err) - - t.Setenv("HOME", dir) - if runtime.GOOS == "windows" { - t.Setenv("USERPROFILE", dir) +func TestProfilesScopes(t *testing.T) { + tests := []struct { + name string + scopes []string + expectedScopes string + skipValidate bool + expectValidationSkipped bool + }{ + { + name: "scopes are sorted alphabetically", + scopes: []string{"jobs", "pipelines", "clusters"}, + expectedScopes: "clusters,jobs,pipelines", + skipValidate: true, + }, + { + name: "default scopes when none configured", + scopes: nil, + expectedScopes: "all-apis", + skipValidate: true, + }, + { + name: "validation skipped for restricted scopes", + scopes: []string{"jobs", "pipelines"}, + expectedScopes: "jobs,pipelines", + skipValidate: false, + expectValidationSkipped: true, + }, } - profile := &profileMetadata{Name: "default-scopes"} - profile.Load(ctx, configFile, true) - - assert.Equal(t, "default-scopes", profile.Name) - // cfg.GetScopes() returns "all-apis" when no scopes are set - assert.Equal(t, "all-apis", profile.Scopes) -} - -func TestProfilesValidationSkippedForRestrictedScopes(t *testing.T) { - ctx := context.Background() - dir := t.TempDir() - configFile := filepath.Join(dir, ".databrickscfg") - - err := databrickscfg.SaveToProfile(ctx, &config.Config{ - ConfigFile: configFile, - Profile: "restricted", - Host: "abc.cloud.databricks.com", - AuthType: "databricks-cli", - Scopes: []string{"jobs", "pipelines"}, - }) - require.NoError(t, err) - - t.Setenv("HOME", dir) - if runtime.GOOS == "windows" { - t.Setenv("USERPROFILE", dir) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: "test-profile", + Host: "abc.cloud.databricks.com", + AuthType: "databricks-cli", + Scopes: tc.scopes, + }) + require.NoError(t, err) + + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + profile := &profileMetadata{Name: "test-profile"} + profile.Load(ctx, configFile, tc.skipValidate) + + assert.Equal(t, tc.expectedScopes, profile.Scopes) + if tc.expectValidationSkipped { + assert.True(t, profile.ValidationSkipped) + assert.False(t, profile.Valid) + } + }) } - - profile := &profileMetadata{Name: "restricted"} - // skipValidate=false but validation should still be skipped due to restricted scopes - profile.Load(ctx, configFile, false) - - assert.Equal(t, "restricted", profile.Name) - assert.Equal(t, "jobs,pipelines", profile.Scopes) - assert.True(t, profile.ValidationSkipped) - assert.False(t, profile.Valid) } diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 155fa3ef0b..ed81da2d10 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -237,19 +237,15 @@ func TestSaveToProfile_WithScopes(t *testing.T) { AuthType: "databricks-cli", Scopes: []string{"jobs", "pipelines", "clusters"}, }) - assert.NoError(t, err) + require.NoError(t, err) - // Verify we can read the scopes back from the config file file, err := loadOrCreateConfigFile(path) require.NoError(t, err) section, err := file.GetSection("scoped") require.NoError(t, err) raw := section.KeysHash() + assert.Len(t, raw, 3) assert.Equal(t, "https://myworkspace.cloud.databricks.com", raw["host"]) assert.Equal(t, "databricks-cli", raw["auth_type"]) - // Scopes are stored in the config file - verify they exist - assert.NotEmpty(t, raw["scopes"]) - assert.Contains(t, raw["scopes"], "jobs") - assert.Contains(t, raw["scopes"], "pipelines") - assert.Contains(t, raw["scopes"], "clusters") + assert.Equal(t, "jobs,pipelines,clusters", raw["scopes"]) }