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..42fdbba153 --- /dev/null +++ b/acceptance/cmd/auth/describe/with-scopes/output.txt @@ -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) 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/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" +] 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" < 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 @@ -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 diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index eb8635f133..6a57eca56f 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -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) +} diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go index 14281e9e56..51d9e63e16 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 + } + switch cfg.ConfigType() { case config.AccountConfig: a, err := databricks.NewAccountClient((*databricks.Config)(cfg)) @@ -88,8 +107,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}}`), }, } @@ -128,9 +147,21 @@ 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 + } + + 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..c8a2c3b802 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -44,3 +44,64 @@ func TestProfiles(t *testing.T) { assert.Equal(t, "aws", profile.Cloud) assert.Equal(t, "pat", profile.AuthType) } + +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, + }, + } + + 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) + } + }) + } +} diff --git a/cmd/auth/testdata/.databrickscfg b/cmd/auth/testdata/.databrickscfg index ca1a063076..0976463566 100644 --- a/cmd/auth/testdata/.databrickscfg +++ b/cmd/auth/testdata/.databrickscfg @@ -16,6 +16,15 @@ cluster_id = cluster-from-config # 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 + [unified-workspace] host = https://unified.databricks.com account_id = test-unified-account diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index dd8484fb7b..ed81da2d10 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -225,3 +225,27 @@ 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"}, + }) + require.NoError(t, err) + + 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"]) + assert.Equal(t, "jobs,pipelines,clusters", raw["scopes"]) +}