From 5de40e795ea564fa3a15a52afc6c83bfb73c6bee Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 24 Jun 2026 11:56:44 +0000 Subject: [PATCH 1/6] Make --profile take precedence over auth environment variables When --profile is set explicitly, host and auth credentials from the profile now win over DATABRICKS_HOST/DATABRICKS_TOKEN and other auth env vars. Previously the SDK's env-first loader order silently shadowed the selected profile (#5096). --- NEXT_CHANGELOG.md | 1 + cmd/root/auth.go | 20 ++++++++-- cmd/root/auth_test.go | 65 +++++++++++++++++++++++++++++++ libs/databrickscfg/loader.go | 47 ++++++++++++++++++++++ libs/databrickscfg/loader_test.go | 16 ++++++++ 5 files changed, 145 insertions(+), 4 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 989a1f273b0..27463bf6c62 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -8,6 +8,7 @@ * `workspace export-dir` no longer aborts when a workspace object's name is not a legal local filename (e.g. a notebook named `New Notebook 2026-05-04 13:54:24` whose `:` is illegal on Windows). Such files are now exported under a sanitized name with a warning and the export completes ([#5171](https://github.com/databricks/cli/issues/5171)). * `ssh connect` now opens an interactive `bash` login shell by default instead of the compute image's default `/bin/sh`, falling back gracefully when `bash` is unavailable. Passing an explicit remote command (`-- `) is unaffected ([#5687](https://github.com/databricks/cli/pull/5687)). * `ssh connect` interactive sessions now start in the user's workspace home folder (`/Workspace/Users/`) instead of the OS home directory, falling back to the OS home when that folder is unavailable ([#5688](https://github.com/databricks/cli/pull/5688)). +* An explicit `--profile` now takes precedence over authentication environment variables (`DATABRICKS_HOST`, `DATABRICKS_TOKEN`, etc.). Previously these env vars silently shadowed the selected profile's host and credentials ([#5096](https://github.com/databricks/cli/issues/5096)). ### Bundles * Add documentation for the common bundle resource fields `permissions`, `lifecycle`, and `grants` in the JSON schema, so they surface in editor completions and the docs. diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 84c09e8a7ec..8bc99bb08e7 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -195,15 +195,20 @@ func MustAnyClient(cmd *cobra.Command, args []string) (bool, error) { func MustAccountClient(cmd *cobra.Command, args []string) error { cfg := &config.Config{} + ctx := cmd.Context() // The command-line profile flag takes precedence over DATABRICKS_CONFIG_PROFILE. pr, hasProfileFlag := profileFlagValue(cmd) if hasProfileFlag { cfg.Profile = pr + // An explicit --profile must take precedence over authentication + // environment variables; see the matching comment in MustWorkspaceClient + // and issue #5096. + cfg.Loaders = []config.Loader{databrickscfg.ResolveNonAuthFromEnv, config.ConfigFile} + } else { + auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) } - ctx := cmd.Context() - auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) ctx = cmdctx.SetConfigUsed(ctx, cfg) cmd.SetContext(ctx) @@ -324,9 +329,16 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { profile, hasProfileFlag := profileFlagValue(cmd) if hasProfileFlag { cfg.Profile = profile + // An explicit --profile must take precedence over authentication + // environment variables (DATABRICKS_HOST, DATABRICKS_TOKEN, ...). + // The SDK's default loader reads the environment before the config + // file and never overwrites an already-set field, so without this the + // env vars would shadow the selected profile (issue #5096). Load only + // non-auth attributes from the environment, then the profile. + cfg.Loaders = []config.Loader{databrickscfg.ResolveNonAuthFromEnv, config.ConfigFile} + } else { + auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) } - - auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) resolveDefaultProfile(ctx, cfg) _, isTargetFlagSet := targetFlagValue(cmd) diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index b2a85174098..989872f5fb4 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -442,6 +442,71 @@ token = flag-token } } +func TestMustWorkspaceClientProfileFlagOverridesAuthEnv(t *testing.T) { + testutil.CleanupEnvironment(t) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + err := os.WriteFile(configFile, []byte(` +[tst-svc] +host = https://tst.cloud.databricks.com +token = tst-token +`), 0o600) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + // direnv-style auth env vars pointing at a different (dev) workspace. Before + // the fix for #5096 these shadowed the profile selected with --profile. + t.Setenv("DATABRICKS_HOST", "https://dev.cloud.databricks.com") + t.Setenv("DATABRICKS_TOKEN", "dev-token") + + ctx := cmdio.MockDiscard(t.Context()) + ctx = SkipLoadBundle(ctx) + cmd := New(ctx) + + err = cmd.Flag("profile").Value.Set("tst-svc") + require.NoError(t, err) + + err = MustWorkspaceClient(cmd, []string{}) + require.NoError(t, err) + + w := cmdctx.WorkspaceClient(cmd.Context()) + require.NotNil(t, w) + // The explicitly selected profile must win over the auth env vars. + assert.Equal(t, "tst-svc", w.Config.Profile) + assert.Equal(t, "https://tst.cloud.databricks.com", w.Config.Host) + assert.Equal(t, "tst-token", w.Config.Token) +} + +func TestMustAccountClientProfileFlagOverridesAuthEnv(t *testing.T) { + testutil.CleanupEnvironment(t) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + err := os.WriteFile(configFile, []byte(` +[acc-tst] +host = https://accounts.azuredatabricks.net/ +account_id = 1111 +token = tst-token +`), 0o600) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + t.Setenv("DATABRICKS_HOST", "https://accounts.azuredatabricks.net/") + t.Setenv("DATABRICKS_TOKEN", "dev-token") + + cmd := New(t.Context()) + err = cmd.Flag("profile").Value.Set("acc-tst") + require.NoError(t, err) + + err = MustAccountClient(cmd, []string{}) + require.NoError(t, err) + + a := cmdctx.AccountClient(cmd.Context()) + require.NotNil(t, a) + // The explicitly selected profile must win over the auth env vars. + assert.Equal(t, "acc-tst", a.Config.Profile) + assert.Equal(t, "tst-token", a.Config.Token) +} + func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) { testutil.CleanupEnvironment(t) t.Setenv("PATH", "") diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 5d942b0b7c0..8dd63c387e5 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -14,6 +14,20 @@ import ( var ResolveProfileFromHost = profileFromHostLoader{} +// ResolveNonAuthFromEnv reads configuration from environment variables, except +// for the host and any authentication credential. It is meant to replace the +// SDK's default environment loader when the user has explicitly selected a +// profile (via the --profile flag), so that the profile fully determines the +// host and authentication. +// +// The SDK's default loader order is environment first, then config file, and a +// loader never overwrites a field that is already set. As a result auth env +// vars (DATABRICKS_HOST, DATABRICKS_TOKEN, ...) shadow the selected profile. +// Skipping them here lets the subsequent config-file loader populate host and +// auth from the profile instead. See +// https://github.com/databricks/cli/issues/5096. +var ResolveNonAuthFromEnv = nonAuthEnvLoader{} + var errNoMatchingProfiles = errors.New("no matching config profiles found") type errMultipleProfiles []string @@ -60,6 +74,39 @@ func findMatchingProfile(configFile *config.File, matcher func(*ini.Section) boo return matching[0], nil } +// hostAttrName is the SDK config attribute name for the Databricks host. The +// host has no `auth` struct tag, so it is excluded from auth-only checks by +// name rather than via HasAuthAttribute. +const hostAttrName = "host" + +type nonAuthEnvLoader struct{} + +func (nonAuthEnvLoader) Name() string { + return "environment (excluding auth)" +} + +func (nonAuthEnvLoader) Configure(cfg *config.Config) error { + for _, attr := range config.ConfigAttributes { + // Leave the host and authentication credentials for the config file + // (i.e. the selected profile) to provide. + if attr.Name == hostAttrName || attr.HasAuthAttribute() { + continue + } + // Match the SDK loader semantics: don't overwrite a value previously set. + if !attr.IsZero(cfg) { + continue + } + v, _ := attr.ReadEnv() + if v == "" { + continue + } + if err := attr.SetS(cfg, v); err != nil { + return err + } + } + return nil +} + type profileFromHostLoader struct{} func (l profileFromHostLoader) Name() string { diff --git a/libs/databrickscfg/loader_test.go b/libs/databrickscfg/loader_test.go index 29d5ebfd1cc..0216fe1b924 100644 --- a/libs/databrickscfg/loader_test.go +++ b/libs/databrickscfg/loader_test.go @@ -34,6 +34,22 @@ func TestLoaderSkipsExistingAuth(t *testing.T) { assert.NoError(t, err) } +func TestResolveNonAuthFromEnvSkipsHostAndAuth(t *testing.T) { + t.Setenv("DATABRICKS_HOST", "https://env.test") + t.Setenv("DATABRICKS_TOKEN", "env-token") + t.Setenv("DATABRICKS_CLUSTER_ID", "env-cluster") + + cfg := &config.Config{} + err := ResolveNonAuthFromEnv.Configure(cfg) + require.NoError(t, err) + + // Host and auth credentials are left for the profile (config file) to set. + assert.Empty(t, cfg.Host) + assert.Empty(t, cfg.Token) + // Non-auth attributes are still populated from the environment. + assert.Equal(t, "env-cluster", cfg.ClusterID) +} + func TestLoaderSkipsExplicitAuthType(t *testing.T) { cfg := config.Config{ Loaders: []config.Loader{ From 24b2adc06ccc17864c6573e93270f13e02e8b68d Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 24 Jun 2026 12:25:58 +0000 Subject: [PATCH 2/6] Cover bundle profile and auth_type/discovery_url env precedence Extend the --profile precedence fix (#5096): - ResolveNonAuthFromEnv now also skips auth_type and discovery_url, which are tagged auth:"-" in the SDK and so are invisible to HasAuthAttribute, letting DATABRICKS_AUTH_TYPE/DATABRICKS_DISCOVERY_URL shadow the profile. It also records the env source so `auth describe` and debug output match the SDK loader. - Workspace.Client uses ResolveNonAuthFromEnv when a profile is set (from --profile or workspace.profile) so env auth vars no longer shadow the profile for bundle commands. - Use the reserved .test TLD for new test fixture hosts so the SDK's well-known host metadata resolver fast-fails instead of stalling on a live network lookup. --- bundle/config/workspace.go | 16 +++++++++++++--- bundle/config/workspace_test.go | 25 +++++++++++++++++++++++++ cmd/root/auth_test.go | 12 +++++++----- libs/databrickscfg/loader.go | 27 ++++++++++++++++++++------- libs/databrickscfg/loader_test.go | 8 +++++++- 5 files changed, 72 insertions(+), 16 deletions(-) diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index 1300a87a78c..c56654accd9 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -176,9 +176,19 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er cfg := w.Config(ctx) - // If only the host is configured, we try and unambiguously match it to - // a profile in the user's databrickscfg file. Override the default loaders. - if w.Host != "" && w.Profile == "" { + switch { + case w.Profile != "": + // An explicit profile (from --profile or workspace.profile) must take + // precedence over authentication environment variables, mirroring + // MustWorkspaceClient. The SDK's default loader reads the environment + // before the config file and never overwrites an already-set field, so + // without this DATABRICKS_HOST/DATABRICKS_TOKEN would shadow the + // selected profile (issue #5096). Load only non-auth attributes from + // the environment, then the profile. + cfg.Loaders = []config.Loader{databrickscfg.ResolveNonAuthFromEnv, config.ConfigFile} + case w.Host != "": + // If only the host is configured, we try and unambiguously match it to + // a profile in the user's databrickscfg file. Override the default loaders. cfg.Loaders = []config.Loader{ // Load auth creds from env vars config.ConfigAttributes, diff --git a/bundle/config/workspace_test.go b/bundle/config/workspace_test.go index b1898db77c8..83c15f3ac2b 100644 --- a/bundle/config/workspace_test.go +++ b/bundle/config/workspace_test.go @@ -155,6 +155,31 @@ func TestWorkspaceClientNormalizesHostBeforeProfileResolution(t *testing.T) { assert.Equal(t, "ws2", client.Config.Profile) } +func TestWorkspaceClientProfileOverridesAuthEnv(t *testing.T) { + // An explicit profile (from --profile or workspace.profile) must win over + // authentication environment variables, mirroring MustWorkspaceClient. + // See https://github.com/databricks/cli/issues/5096. + setupWorkspaceTest(t) + + err := databrickscfg.SaveToProfile(t.Context(), &config.Config{ + Profile: "tst", + Host: "https://tst.cloud.databricks.test", + Token: "tst-token", + }) + require.NoError(t, err) + + // direnv-style auth env vars pointing at a different (dev) workspace. + t.Setenv("DATABRICKS_HOST", "https://dev.cloud.databricks.test") + t.Setenv("DATABRICKS_TOKEN", "dev-token") + + w := Workspace{Profile: "tst"} + client, err := w.Client(t.Context()) + require.NoError(t, err) + assert.Equal(t, "tst", client.Config.Profile) + assert.Equal(t, "https://tst.cloud.databricks.test", client.Config.Host) + assert.Equal(t, "tst-token", client.Config.Token) +} + func TestWorkspaceConfigHTTPTimeout(t *testing.T) { for _, tc := range []struct { envVal string diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index 989872f5fb4..d1dc7126261 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -448,7 +448,7 @@ func TestMustWorkspaceClientProfileFlagOverridesAuthEnv(t *testing.T) { configFile := filepath.Join(t.TempDir(), ".databrickscfg") err := os.WriteFile(configFile, []byte(` [tst-svc] -host = https://tst.cloud.databricks.com +host = https://tst.cloud.databricks.test token = tst-token `), 0o600) require.NoError(t, err) @@ -456,7 +456,7 @@ token = tst-token t.Setenv("DATABRICKS_CONFIG_FILE", configFile) // direnv-style auth env vars pointing at a different (dev) workspace. Before // the fix for #5096 these shadowed the profile selected with --profile. - t.Setenv("DATABRICKS_HOST", "https://dev.cloud.databricks.com") + t.Setenv("DATABRICKS_HOST", "https://dev.cloud.databricks.test") t.Setenv("DATABRICKS_TOKEN", "dev-token") ctx := cmdio.MockDiscard(t.Context()) @@ -473,7 +473,7 @@ token = tst-token require.NotNil(t, w) // The explicitly selected profile must win over the auth env vars. assert.Equal(t, "tst-svc", w.Config.Profile) - assert.Equal(t, "https://tst.cloud.databricks.com", w.Config.Host) + assert.Equal(t, "https://tst.cloud.databricks.test", w.Config.Host) assert.Equal(t, "tst-token", w.Config.Token) } @@ -483,14 +483,15 @@ func TestMustAccountClientProfileFlagOverridesAuthEnv(t *testing.T) { configFile := filepath.Join(t.TempDir(), ".databrickscfg") err := os.WriteFile(configFile, []byte(` [acc-tst] -host = https://accounts.azuredatabricks.net/ +host = https://accounts.cloud.databricks.test account_id = 1111 token = tst-token `), 0o600) require.NoError(t, err) t.Setenv("DATABRICKS_CONFIG_FILE", configFile) - t.Setenv("DATABRICKS_HOST", "https://accounts.azuredatabricks.net/") + // Auth env vars pointing at a different account host. The profile must win. + t.Setenv("DATABRICKS_HOST", "https://accounts.dev.databricks.test") t.Setenv("DATABRICKS_TOKEN", "dev-token") cmd := New(t.Context()) @@ -504,6 +505,7 @@ token = tst-token require.NotNil(t, a) // The explicitly selected profile must win over the auth env vars. assert.Equal(t, "acc-tst", a.Config.Profile) + assert.Equal(t, "https://accounts.cloud.databricks.test", a.Config.Host) assert.Equal(t, "tst-token", a.Config.Token) } diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 8dd63c387e5..13a3d7b7da2 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -74,10 +74,20 @@ func findMatchingProfile(configFile *config.File, matcher func(*ini.Section) boo return matching[0], nil } -// hostAttrName is the SDK config attribute name for the Databricks host. The -// host has no `auth` struct tag, so it is excluded from auth-only checks by -// name rather than via HasAuthAttribute. -const hostAttrName = "host" +// nonAuthEnvSkipAttrs lists SDK config attribute names that nonAuthEnvLoader +// must not read from the environment, beyond those caught by HasAuthAttribute. +// +// - host: has no `auth` struct tag, so HasAuthAttribute can't see it. +// - auth_type, discovery_url: tagged `auth:"-"` in the SDK, which the SDK +// normalizes to an empty auth tag (internal), so HasAuthAttribute reports +// false even though both select/steer the authentication method. Leaving +// them to the env would let DATABRICKS_AUTH_TYPE / DATABRICKS_DISCOVERY_URL +// shadow the selected profile, the same bug as #5096. +var nonAuthEnvSkipAttrs = map[string]bool{ + "host": true, + "auth_type": true, + "discovery_url": true, +} type nonAuthEnvLoader struct{} @@ -87,22 +97,25 @@ func (nonAuthEnvLoader) Name() string { func (nonAuthEnvLoader) Configure(cfg *config.Config) error { for _, attr := range config.ConfigAttributes { - // Leave the host and authentication credentials for the config file + // Leave the host and authentication settings for the config file // (i.e. the selected profile) to provide. - if attr.Name == hostAttrName || attr.HasAuthAttribute() { + if nonAuthEnvSkipAttrs[attr.Name] || attr.HasAuthAttribute() { continue } // Match the SDK loader semantics: don't overwrite a value previously set. if !attr.IsZero(cfg) { continue } - v, _ := attr.ReadEnv() + v, envName := attr.ReadEnv() if v == "" { continue } if err := attr.SetS(cfg, v); err != nil { return err } + // Record the source so `databricks auth describe` and debug output + // attribute the value to the environment, matching the SDK loader. + cfg.SetAttrSource(&attr, config.Source{Type: config.SourceEnv, Name: envName}) } return nil } diff --git a/libs/databrickscfg/loader_test.go b/libs/databrickscfg/loader_test.go index 0216fe1b924..a8a03864939 100644 --- a/libs/databrickscfg/loader_test.go +++ b/libs/databrickscfg/loader_test.go @@ -37,15 +37,21 @@ func TestLoaderSkipsExistingAuth(t *testing.T) { func TestResolveNonAuthFromEnvSkipsHostAndAuth(t *testing.T) { t.Setenv("DATABRICKS_HOST", "https://env.test") t.Setenv("DATABRICKS_TOKEN", "env-token") + // auth_type and discovery_url are tagged auth:"-" in the SDK, so + // HasAuthAttribute can't catch them; they must still be skipped (#5096). + t.Setenv("DATABRICKS_AUTH_TYPE", "oauth-m2m") + t.Setenv("DATABRICKS_DISCOVERY_URL", "https://discovery.env.test") t.Setenv("DATABRICKS_CLUSTER_ID", "env-cluster") cfg := &config.Config{} err := ResolveNonAuthFromEnv.Configure(cfg) require.NoError(t, err) - // Host and auth credentials are left for the profile (config file) to set. + // Host and auth settings are left for the profile (config file) to set. assert.Empty(t, cfg.Host) assert.Empty(t, cfg.Token) + assert.Empty(t, cfg.AuthType) + assert.Empty(t, cfg.DiscoveryURL) // Non-auth attributes are still populated from the environment. assert.Equal(t, "env-cluster", cfg.ClusterID) } From a673c11a34104af1fce1a25a09300869cd4581cd Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 24 Jun 2026 13:04:33 +0000 Subject: [PATCH 3/6] Let env fill auth fields a selected profile leaves empty A host-only profile combined with DATABRICKS_TOKEN previously failed because the profile loader chain stopped at the config file. Append config.ConfigAttributes after the profile so the environment can fill auth fields the profile does not provide, while the profile still wins for any field it sets (#5096). --- bundle/config/workspace.go | 12 ++++++++--- bundle/config/workspace_test.go | 23 ++++++++++++++++++++++ cmd/root/auth.go | 33 ++++++++++++++++++++++++------- cmd/root/auth_test.go | 35 +++++++++++++++++++++++++++++++++ libs/databrickscfg/loader.go | 12 ++++++----- 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index c56654accd9..ba7961a7472 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -183,9 +183,15 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er // MustWorkspaceClient. The SDK's default loader reads the environment // before the config file and never overwrites an already-set field, so // without this DATABRICKS_HOST/DATABRICKS_TOKEN would shadow the - // selected profile (issue #5096). Load only non-auth attributes from - // the environment, then the profile. - cfg.Loaders = []config.Loader{databrickscfg.ResolveNonAuthFromEnv, config.ConfigFile} + // selected profile (issue #5096). Load non-auth attributes from the + // environment, then the profile, then let the environment fill any auth + // fields the profile did not provide (e.g. a host-only profile combined + // with DATABRICKS_TOKEN). + cfg.Loaders = []config.Loader{ + databrickscfg.ResolveNonAuthFromEnv, + config.ConfigFile, + config.ConfigAttributes, + } case w.Host != "": // If only the host is configured, we try and unambiguously match it to // a profile in the user's databrickscfg file. Override the default loaders. diff --git a/bundle/config/workspace_test.go b/bundle/config/workspace_test.go index 83c15f3ac2b..724137f33f8 100644 --- a/bundle/config/workspace_test.go +++ b/bundle/config/workspace_test.go @@ -180,6 +180,29 @@ func TestWorkspaceClientProfileOverridesAuthEnv(t *testing.T) { assert.Equal(t, "tst-token", client.Config.Token) } +func TestWorkspaceClientProfileFillsAuthFromEnv(t *testing.T) { + // A host-only profile relies on the environment for credentials. The profile + // must take precedence for the host, but the env must still fill the token + // the profile does not provide (#5096). + setupWorkspaceTest(t) + + err := databrickscfg.SaveToProfile(t.Context(), &config.Config{ + Profile: "host-only", + Host: "https://tst.cloud.databricks.test", + }) + require.NoError(t, err) + + t.Setenv("DATABRICKS_TOKEN", "env-token") + + w := Workspace{Profile: "host-only"} + client, err := w.Client(t.Context()) + require.NoError(t, err) + assert.Equal(t, "host-only", client.Config.Profile) + assert.Equal(t, "https://tst.cloud.databricks.test", client.Config.Host) + // The token is not in the profile, so it is filled from the environment. + assert.Equal(t, "env-token", client.Config.Token) +} + func TestWorkspaceConfigHTTPTimeout(t *testing.T) { for _, tc := range []struct { envVal string diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 8bc99bb08e7..32884237d9a 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -31,6 +31,28 @@ import ( // SDK removed the variable entirely in v0.127.0, so we now own it here. var errNotWorkspaceClient = errors.New("invalid Databricks Workspace configuration - host is not a workspace host") +// profileAuthLoaders is the SDK loader chain to use when the user explicitly +// selects a profile (via --profile or workspace.profile). The selected profile +// must determine the host and authentication, taking precedence over auth +// environment variables (DATABRICKS_HOST, DATABRICKS_TOKEN, ...). The SDK's +// default chain reads the environment before the config file and never +// overwrites an already-set field, so the env vars would otherwise shadow the +// profile (issue #5096). +// +// The order matters: +// 1. ResolveNonAuthFromEnv loads non-auth attributes from the environment +// (e.g. cluster_id), preserving the env-wins precedence for those. +// 2. ConfigFile loads the selected profile, populating host and auth. +// 3. ConfigAttributes loads any remaining attributes from the environment, +// filling auth fields the profile did not provide (e.g. a host-only +// profile combined with DATABRICKS_TOKEN). It never overwrites a value the +// profile already set, so the profile still wins for #5096. +var profileAuthLoaders = []config.Loader{ + databrickscfg.ResolveNonAuthFromEnv, + config.ConfigFile, + config.ConfigAttributes, +} + type ErrNoWorkspaceProfiles struct { path string } @@ -204,7 +226,7 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { // An explicit --profile must take precedence over authentication // environment variables; see the matching comment in MustWorkspaceClient // and issue #5096. - cfg.Loaders = []config.Loader{databrickscfg.ResolveNonAuthFromEnv, config.ConfigFile} + cfg.Loaders = profileAuthLoaders } else { auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) } @@ -330,12 +352,9 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { if hasProfileFlag { cfg.Profile = profile // An explicit --profile must take precedence over authentication - // environment variables (DATABRICKS_HOST, DATABRICKS_TOKEN, ...). - // The SDK's default loader reads the environment before the config - // file and never overwrites an already-set field, so without this the - // env vars would shadow the selected profile (issue #5096). Load only - // non-auth attributes from the environment, then the profile. - cfg.Loaders = []config.Loader{databrickscfg.ResolveNonAuthFromEnv, config.ConfigFile} + // environment variables (DATABRICKS_HOST, DATABRICKS_TOKEN, ...); + // see profileAuthLoaders and issue #5096. + cfg.Loaders = profileAuthLoaders } else { auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) } diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index d1dc7126261..8f4274521ea 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -477,6 +477,41 @@ token = tst-token assert.Equal(t, "tst-token", w.Config.Token) } +func TestMustWorkspaceClientProfileFlagFillsAuthFromEnv(t *testing.T) { + testutil.CleanupEnvironment(t) + + // A host-only profile relies on the environment for credentials. This is a + // common CI pattern: the host lives in .databrickscfg while DATABRICKS_TOKEN + // is injected by the runner. The profile must take precedence for the host, + // but the env must still fill the token the profile does not provide (#5096). + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + err := os.WriteFile(configFile, []byte(` +[host-only] +host = https://tst.cloud.databricks.test +`), 0o600) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + t.Setenv("DATABRICKS_TOKEN", "env-token") + + ctx := cmdio.MockDiscard(t.Context()) + ctx = SkipLoadBundle(ctx) + cmd := New(ctx) + + err = cmd.Flag("profile").Value.Set("host-only") + require.NoError(t, err) + + err = MustWorkspaceClient(cmd, []string{}) + require.NoError(t, err) + + w := cmdctx.WorkspaceClient(cmd.Context()) + require.NotNil(t, w) + assert.Equal(t, "host-only", w.Config.Profile) + assert.Equal(t, "https://tst.cloud.databricks.test", w.Config.Host) + // The token is not in the profile, so it is filled from the environment. + assert.Equal(t, "env-token", w.Config.Token) +} + func TestMustAccountClientProfileFlagOverridesAuthEnv(t *testing.T) { testutil.CleanupEnvironment(t) diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 13a3d7b7da2..a5e6c91844e 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -15,16 +15,18 @@ import ( var ResolveProfileFromHost = profileFromHostLoader{} // ResolveNonAuthFromEnv reads configuration from environment variables, except -// for the host and any authentication credential. It is meant to replace the -// SDK's default environment loader when the user has explicitly selected a -// profile (via the --profile flag), so that the profile fully determines the -// host and authentication. +// for the host and any authentication credential. It runs before the config +// file loader when the user has explicitly selected a profile (via the +// --profile flag or workspace.profile), so that the profile takes precedence +// over auth environment variables. // // The SDK's default loader order is environment first, then config file, and a // loader never overwrites a field that is already set. As a result auth env // vars (DATABRICKS_HOST, DATABRICKS_TOKEN, ...) shadow the selected profile. // Skipping them here lets the subsequent config-file loader populate host and -// auth from the profile instead. See +// auth from the profile instead. A trailing config.ConfigAttributes loader can +// still fill auth fields the profile leaves empty (e.g. a host-only profile +// combined with DATABRICKS_TOKEN). See // https://github.com/databricks/cli/issues/5096. var ResolveNonAuthFromEnv = nonAuthEnvLoader{} From b557435c1f237746d381a2062861f61eb4413476 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 25 Jun 2026 08:23:03 +0000 Subject: [PATCH 4/6] Address review: dedupe profile loader chain, widen auth-env skip list Consolidate the profile-precedence loader chain into a single shared databrickscfg.ProfileAuthLoaders, used by both cmd/root and bundle config so the two copies can't drift. Add audience and cloud to the env skip list (both steer auth but are tagged auth:"-" so HasAuthAttribute misses them) and document the inclusion criterion. Clarify that the gap-fill of auth fields from the environment and the env-first precedence of DATABRICKS_CONFIG_PROFILE are intentional, and that NormalizeDatabricksConfigFromEnv is deliberately skipped under --profile. Add an acceptance test covering --profile winning over DATABRICKS_HOST/ DATABRICKS_TOKEN and a host-only profile filling its token from the environment (#5096). --- NEXT_CHANGELOG.md | 2 +- .../profile-overrides-env/out.test.toml | 3 ++ .../describe/profile-overrides-env/output.txt | 36 +++++++++++++ .../describe/profile-overrides-env/script | 26 +++++++++ .../describe/profile-overrides-env/test.toml | 3 ++ bundle/config/workspace.go | 17 +++--- cmd/root/auth.go | 41 ++++++-------- libs/databrickscfg/loader.go | 54 ++++++++++++++++--- libs/databrickscfg/loader_test.go | 9 +++- 9 files changed, 147 insertions(+), 44 deletions(-) create mode 100644 acceptance/cmd/auth/describe/profile-overrides-env/out.test.toml create mode 100644 acceptance/cmd/auth/describe/profile-overrides-env/output.txt create mode 100644 acceptance/cmd/auth/describe/profile-overrides-env/script create mode 100644 acceptance/cmd/auth/describe/profile-overrides-env/test.toml diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 27463bf6c62..acce19eba16 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -8,7 +8,7 @@ * `workspace export-dir` no longer aborts when a workspace object's name is not a legal local filename (e.g. a notebook named `New Notebook 2026-05-04 13:54:24` whose `:` is illegal on Windows). Such files are now exported under a sanitized name with a warning and the export completes ([#5171](https://github.com/databricks/cli/issues/5171)). * `ssh connect` now opens an interactive `bash` login shell by default instead of the compute image's default `/bin/sh`, falling back gracefully when `bash` is unavailable. Passing an explicit remote command (`-- `) is unaffected ([#5687](https://github.com/databricks/cli/pull/5687)). * `ssh connect` interactive sessions now start in the user's workspace home folder (`/Workspace/Users/`) instead of the OS home directory, falling back to the OS home when that folder is unavailable ([#5688](https://github.com/databricks/cli/pull/5688)). -* An explicit `--profile` now takes precedence over authentication environment variables (`DATABRICKS_HOST`, `DATABRICKS_TOKEN`, etc.). Previously these env vars silently shadowed the selected profile's host and credentials ([#5096](https://github.com/databricks/cli/issues/5096)). +* An explicitly selected profile (the `--profile` flag or a bundle's `workspace.profile`) now takes precedence over authentication environment variables (`DATABRICKS_HOST`, `DATABRICKS_TOKEN`, etc.). Previously these env vars silently shadowed the selected profile's host and credentials. Environment variables still fill auth fields the profile leaves empty (e.g. a host-only profile combined with `DATABRICKS_TOKEN`). A profile picked up from `DATABRICKS_CONFIG_PROFILE` keeps the SDK's default env-first precedence ([#5096](https://github.com/databricks/cli/issues/5096)). ### Bundles * Add documentation for the common bundle resource fields `permissions`, `lifecycle`, and `grants` in the JSON schema, so they surface in editor completions and the docs. diff --git a/acceptance/cmd/auth/describe/profile-overrides-env/out.test.toml b/acceptance/cmd/auth/describe/profile-overrides-env/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/cmd/auth/describe/profile-overrides-env/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/describe/profile-overrides-env/output.txt b/acceptance/cmd/auth/describe/profile-overrides-env/output.txt new file mode 100644 index 00000000000..60872b97469 --- /dev/null +++ b/acceptance/cmd/auth/describe/profile-overrides-env/output.txt @@ -0,0 +1,36 @@ + +=== Describe with --profile overrides auth env vars (#5096) + +>>> [CLI] auth describe --profile my-workspace +Host: [DATABRICKS_URL] +User: [USERNAME] +Authenticated with: pat +----- +Current configuration: + ✓ host: [DATABRICKS_URL] (from [TEST_TMP_DIR]/home/.databrickscfg config file) + ✓ workspace_id: [NUMID] + ✓ token: ******** (from [TEST_TMP_DIR]/home/.databrickscfg config file) + ✓ profile: my-workspace (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 + +=== Describe with a host-only --profile fills the token from the environment (#5096) + +>>> [CLI] auth describe --profile host-only +Host: [DATABRICKS_URL] +User: [USERNAME] +Authenticated with: pat +----- +Current configuration: + ✓ host: [DATABRICKS_URL] (from [TEST_TMP_DIR]/home/.databrickscfg config file) + ✓ workspace_id: [NUMID] + ✓ token: ******** (from DATABRICKS_TOKEN environment variable) + ✓ profile: host-only (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 diff --git a/acceptance/cmd/auth/describe/profile-overrides-env/script b/acceptance/cmd/auth/describe/profile-overrides-env/script new file mode 100644 index 00000000000..2a675a673da --- /dev/null +++ b/acceptance/cmd/auth/describe/profile-overrides-env/script @@ -0,0 +1,26 @@ +sethome "./home" + +# A profile carries full credentials; a second profile carries only a host. +cat > "./home/.databrickscfg" < Date: Thu, 25 Jun 2026 12:14:39 +0000 Subject: [PATCH 5/6] Address review: skip routing IDs, guard SDK internal env attrs Fold workspace_id/account_id into the profile auth skip list so a selected profile wins over DATABRICKS_WORKSPACE_ID/DATABRICKS_ACCOUNT_ID (env still gap-fills when the profile omits them). Add a test that fails when a future SDK bump introduces a new unclassified auth-steering internal env attribute, and dedupe the rationale comments onto ProfileAuthLoaders. --- bundle/config/workspace.go | 12 ++----- cmd/root/auth.go | 30 ++++++++---------- libs/databrickscfg/loader.go | 52 ++++++++++++++++++++----------- libs/databrickscfg/loader_test.go | 42 ++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 45 deletions(-) diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index a72a6d7d4a9..2f9e52149b5 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -179,15 +179,9 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er switch { case w.Profile != "": // An explicit profile (from --profile or workspace.profile) must take - // precedence over authentication environment variables, mirroring - // MustWorkspaceClient. The SDK's default loader reads the environment - // before the config file and never overwrites an already-set field, so - // without this DATABRICKS_HOST/DATABRICKS_TOKEN would shadow the - // selected profile (issue #5096). See databrickscfg.ProfileAuthLoaders - // for the loader order and rationale. - // - // This also covers a bundle that sets both host and profile: the - // profile now wins for auth (previously the env did), and the existing + // precedence over auth environment variables; see + // databrickscfg.ProfileAuthLoaders (#5096). When the bundle sets both + // host and profile the profile now wins for auth, and the // ValidateConfigAndProfileHost check below still enforces that the // bundle host matches the profile host. cfg.Loaders = databrickscfg.ProfileAuthLoaders diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 70827c15f0c..2d4b4cba324 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -201,12 +201,11 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { pr, hasProfileFlag := profileFlagValue(cmd) if hasProfileFlag { cfg.Profile = pr - // An explicit --profile must take precedence over authentication - // environment variables; see the matching comment in MustWorkspaceClient - // and issue #5096. We deliberately skip NormalizeDatabricksConfigFromEnv - // here: with --profile the host comes from the profile, not from - // DATABRICKS_HOST, so promoting that env var's ?o=/?a= query params - // would be wrong. + // An explicit --profile takes precedence over auth environment + // variables; see databrickscfg.ProfileAuthLoaders (#5096). We also skip + // NormalizeDatabricksConfigFromEnv here: with --profile the host comes + // from the profile, not from DATABRICKS_HOST, so promoting that env + // var's ?o=/?a= query params would be wrong. cfg.Loaders = databrickscfg.ProfileAuthLoaders } else { auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) @@ -332,17 +331,14 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { profile, hasProfileFlag := profileFlagValue(cmd) if hasProfileFlag { cfg.Profile = profile - // An explicit --profile must take precedence over authentication - // environment variables (DATABRICKS_HOST, DATABRICKS_TOKEN, ...); - // see databrickscfg.ProfileAuthLoaders and issue #5096. - // - // We deliberately skip NormalizeDatabricksConfigFromEnv here: with - // --profile the host comes from the profile, not from DATABRICKS_HOST, - // so promoting that env var's ?o=/?a= query params would be wrong. The - // one edge this drops is a host-less profile combined with a SPOG-style - // DATABRICKS_HOST (https://host/?o=123): the workspace_id is no longer - // extracted from the query, which is an accepted trade-off for that - // unusual combination. + // An explicit --profile takes precedence over auth environment + // variables; see databrickscfg.ProfileAuthLoaders (#5096). We also skip + // NormalizeDatabricksConfigFromEnv here: with --profile the host comes + // from the profile, not from DATABRICKS_HOST, so promoting that env + // var's ?o=/?a= query params would be wrong. The one edge this drops is + // a host-less profile combined with a SPOG-style DATABRICKS_HOST + // (https://host/?o=123): the workspace_id is no longer extracted from + // the query, an accepted trade-off for that unusual combination. cfg.Loaders = databrickscfg.ProfileAuthLoaders } else { auth.NormalizeDatabricksConfigFromEnv(ctx, cfg) diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 3c0f411e2eb..26318b57339 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -32,8 +32,11 @@ var ResolveNonAuthFromEnv = nonAuthEnvLoader{} // ProfileAuthLoaders is the SDK loader chain to use when the user has // explicitly selected a profile (via the --profile flag or a bundle's -// workspace.profile). The selected profile must determine the host and -// authentication, taking precedence over auth environment variables +// workspace.profile). It is the single source of truth for that precedence +// rule; call sites should reference it rather than restating the rationale. +// +// The selected profile must determine the host, routing identifiers, and +// authentication, taking precedence over the matching environment variables // (DATABRICKS_HOST, DATABRICKS_TOKEN, ...). The SDK's default chain reads the // environment before the config file and never overwrites an already-set // field, so the env vars would otherwise shadow the profile (issue #5096). @@ -43,15 +46,15 @@ var ResolveNonAuthFromEnv = nonAuthEnvLoader{} // env-first precedence; see the call sites in cmd/root for the rationale. // // The order matters: -// 1. ResolveNonAuthFromEnv loads non-auth attributes from the environment -// (e.g. cluster_id), preserving the env-wins precedence for those. -// 2. ConfigFile loads the selected profile, populating host and auth. +// 1. ResolveNonAuthFromEnv loads non-auth, non-routing attributes from the +// environment (e.g. cluster_id), preserving env-wins precedence for those. +// 2. ConfigFile loads the selected profile, populating host, routing and auth. // 3. ConfigAttributes loads any remaining attributes from the environment, -// filling auth fields the profile did not provide (e.g. a host-only -// profile combined with DATABRICKS_TOKEN). It never overwrites a value the -// profile already set, so the profile still wins for #5096. This gap-fill -// is a deliberate, tested contract (host-only profiles are a common CI -// pattern where the credential is injected via the environment). +// filling fields the profile did not provide (e.g. a host-only profile +// combined with DATABRICKS_TOKEN). It never overwrites a value the profile +// already set, so the profile still wins for #5096. This gap-fill is a +// deliberate, tested contract (host-only profiles are a common CI pattern +// where the credential is injected via the environment). var ProfileAuthLoaders = []config.Loader{ ResolveNonAuthFromEnv, config.ConfigFile, @@ -107,15 +110,23 @@ func findMatchingProfile(configFile *config.File, matcher func(*ini.Section) boo // nonAuthEnvSkipAttrs lists SDK config attribute names that nonAuthEnvLoader // must not read from the environment, beyond those caught by HasAuthAttribute. // -// Criterion: an attribute belongs here if it identifies the target workspace -// (host) or selects/steers the authentication method, but the SDK does NOT tag -// it `auth:"..."` (so HasAuthAttribute can't catch it). The SDK collapses an -// `auth:"-"` tag to an empty auth tag (marking the field "internal"), which is -// why these auth-steering fields slip past HasAuthAttribute and must be listed -// explicitly. Leaving any of them to the environment would let the matching env -// var shadow the selected profile, the same bug as #5096. +// Criterion: an attribute belongs here if it identifies the target +// workspace/account (host, routing IDs) or selects/steers the authentication +// method, but the SDK does NOT tag it `auth:"..."` (so HasAuthAttribute can't +// catch it). The SDK collapses an `auth:"-"` tag to an empty auth tag (marking +// the field "internal"), which is why these auth-steering fields slip past +// HasAuthAttribute and must be listed explicitly. Leaving any of them to the +// environment would let the matching env var shadow the selected profile, the +// same bug as #5096. Skipping here only changes precedence: the trailing +// ConfigAttributes loader still fills any of these the profile leaves empty +// from the environment (the same gap-fill host and credentials get). // // - host: has no `auth` struct tag at all. +// - workspace_id (DATABRICKS_WORKSPACE_ID): routing identifier; a profile +// that sets it must win, or a stray env var routes the profile's +// credentials to a different workspace. +// - account_id (DATABRICKS_ACCOUNT_ID): account routing identifier, same +// reasoning as workspace_id. // - auth_type (DATABRICKS_AUTH_TYPE): forces a specific auth method. // - discovery_url (DATABRICKS_DISCOVERY_URL): redirects OIDC discovery. // - audience (DATABRICKS_TOKEN_AUDIENCE): selects the token audience for @@ -124,9 +135,14 @@ func findMatchingProfile(configFile *config.File, matcher func(*ini.Section) boo // // Non-auth env-backed attributes tagged `auth:"-"` (e.g. oauth_callback_port, // debug_headers, rate_limit) are intentionally NOT skipped: they don't change -// which credentials authenticate the request, so env-wins precedence is fine. +// which credentials authenticate the request or where it is routed, so +// env-wins precedence is fine. TestNonAuthEnvSkipAttrsCoverSDKInternalEnvAttrs +// guards that every auth-steering internal attribute stays classified across +// SDK bumps. var nonAuthEnvSkipAttrs = map[string]bool{ "host": true, + "workspace_id": true, + "account_id": true, "auth_type": true, "discovery_url": true, "audience": true, diff --git a/libs/databrickscfg/loader_test.go b/libs/databrickscfg/loader_test.go index e5aad07da6e..77a0e59ecef 100644 --- a/libs/databrickscfg/loader_test.go +++ b/libs/databrickscfg/loader_test.go @@ -44,23 +44,63 @@ func TestResolveNonAuthFromEnvSkipsHostAndAuth(t *testing.T) { t.Setenv("DATABRICKS_DISCOVERY_URL", "https://discovery.env.test") t.Setenv("DATABRICKS_TOKEN_AUDIENCE", "env-audience") t.Setenv("DATABRICKS_CLOUD", "azure") + // workspace_id and account_id are routing identifiers; a profile that sets + // them must win, so they are skipped too (#5096). + t.Setenv("DATABRICKS_WORKSPACE_ID", "env-workspace") + t.Setenv("DATABRICKS_ACCOUNT_ID", "env-account") t.Setenv("DATABRICKS_CLUSTER_ID", "env-cluster") cfg := &config.Config{} err := ResolveNonAuthFromEnv.Configure(cfg) require.NoError(t, err) - // Host and auth settings are left for the profile (config file) to set. + // Host, routing and auth settings are left for the profile (config file) to set. assert.Empty(t, cfg.Host) assert.Empty(t, cfg.Token) assert.Empty(t, cfg.AuthType) assert.Empty(t, cfg.DiscoveryURL) assert.Empty(t, cfg.TokenAudience) assert.Empty(t, cfg.Cloud) + assert.Empty(t, cfg.WorkspaceID) + assert.Empty(t, cfg.AccountID) // Non-auth attributes are still populated from the environment. assert.Equal(t, "env-cluster", cfg.ClusterID) } +// TestNonAuthEnvSkipAttrsCoverSDKInternalEnvAttrs guards against an SDK bump +// silently re-introducing #5096. nonAuthEnvSkipAttrs and HasAuthAttribute +// together must classify every env-backed attribute that steers authentication. +// The dangerous category is attributes the SDK tags `auth:"-"` (Internal, so +// HasAuthAttribute returns false) yet read from the environment: if the SDK +// adds a new auth-steering one, it would shadow the selected profile again. +// +// Every Internal env-backed attribute must therefore be either skipped +// (auth-steering) or listed below as a reviewed env-first attribute (it does +// not change which credentials authenticate or where the request is routed). +// A new SDK attribute fails this test until a human classifies it. +func TestNonAuthEnvSkipAttrsCoverSDKInternalEnvAttrs(t *testing.T) { + knownEnvFirstInternal := map[string]bool{ + "oauth_callback_port": true, + "disable_oauth_refresh_token": true, + "debug_truncate_bytes": true, + "debug_headers": true, + "rate_limit": true, + } + + for _, attr := range config.ConfigAttributes { + if !attr.Internal || len(attr.EnvVars) == 0 { + continue + } + if nonAuthEnvSkipAttrs[attr.Name] || knownEnvFirstInternal[attr.Name] { + continue + } + t.Errorf("SDK config attribute %q (env %v) is internal (auth:\"-\") but unclassified: "+ + "add it to nonAuthEnvSkipAttrs if it steers auth/routing, or to "+ + "knownEnvFirstInternal if env-first precedence is safe (#5096)", + attr.Name, attr.EnvVars) + } +} + func TestLoaderSkipsExplicitAuthType(t *testing.T) { cfg := config.Config{ Loaders: []config.Loader{ From 043d3bbc9580af96f6236e05fcfdabdd868685aa Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 25 Jun 2026 17:59:55 +0000 Subject: [PATCH 6/6] Address review: apply profile precedence to databricks api --profile databricks api resolved the profile "mirroring MustWorkspaceClient precedence" but still let auth env vars shadow an explicitly selected profile, leaving #5096 unfixed for that command. Set databrickscfg.ProfileAuthLoaders (and skip NormalizeDatabricksConfigFromEnv) when --profile is set, matching the other call sites, and add an acceptance test covering both the override and host-only gap-fill cases. --- .../api/profile-overrides-env/out.test.toml | 3 ++ .../cmd/api/profile-overrides-env/output.txt | 44 +++++++++++++++++++ .../cmd/api/profile-overrides-env/script | 29 ++++++++++++ .../cmd/api/profile-overrides-env/test.toml | 3 ++ cmd/api/api.go | 15 ++++++- 5 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 acceptance/cmd/api/profile-overrides-env/out.test.toml create mode 100644 acceptance/cmd/api/profile-overrides-env/output.txt create mode 100644 acceptance/cmd/api/profile-overrides-env/script create mode 100644 acceptance/cmd/api/profile-overrides-env/test.toml diff --git a/acceptance/cmd/api/profile-overrides-env/out.test.toml b/acceptance/cmd/api/profile-overrides-env/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/cmd/api/profile-overrides-env/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/api/profile-overrides-env/output.txt b/acceptance/cmd/api/profile-overrides-env/output.txt new file mode 100644 index 00000000000..f56150d9b88 --- /dev/null +++ b/acceptance/cmd/api/profile-overrides-env/output.txt @@ -0,0 +1,44 @@ + +=== api --profile overrides auth env vars (#5096) + +>>> [CLI] api get /api/2.0/clusters/list --profile my-workspace +{} + +>>> print_requests.py --get //api/2.0/clusters/list +{ + "headers": { + "Authorization": [ + "Bearer [DATABRICKS_TOKEN]" + ], + "User-Agent": [ + "cli/[DEV_VERSION] databricks-sdk-go/[SDK_VERSION] go/[GO_VERSION] os/[OS] cmd/api_get cmd-exec-id/[UUID] interactive/none auth/pat" + ], + "X-Databricks-Workspace-Id": [ + "[NUMID]" + ] + }, + "method": "GET", + "path": "/api/2.0/clusters/list" +} + +=== api host-only --profile fills the token from the environment (#5096) + +>>> [CLI] api get /api/2.0/clusters/list --profile host-only +{} + +>>> print_requests.py --get //api/2.0/clusters/list +{ + "headers": { + "Authorization": [ + "Bearer [DATABRICKS_TOKEN]" + ], + "User-Agent": [ + "cli/[DEV_VERSION] databricks-sdk-go/[SDK_VERSION] go/[GO_VERSION] os/[OS] cmd/api_get cmd-exec-id/[UUID] interactive/none auth/pat" + ], + "X-Databricks-Workspace-Id": [ + "[NUMID]" + ] + }, + "method": "GET", + "path": "/api/2.0/clusters/list" +} diff --git a/acceptance/cmd/api/profile-overrides-env/script b/acceptance/cmd/api/profile-overrides-env/script new file mode 100644 index 00000000000..91f283c2dcb --- /dev/null +++ b/acceptance/cmd/api/profile-overrides-env/script @@ -0,0 +1,29 @@ +sethome "./home" + +# A profile carries full credentials; a second profile carries only a host. Both +# point at the test server, while the auth env vars below point elsewhere. +cat > "./home/.databrickscfg" <