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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion cmd/root/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ func initProfileFlag(cmd *cobra.Command) {
cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion)
}

// isPATOnSPOGWithoutWorkspaceID reports whether the resolved config is a PAT
// profile pointing at a SPOG host with no workspace_id set. The SDK strips the
// routing identifier from the request, which lands on the account-plane where
// PATs aren't accepted. The legacy "none" sentinel (auth.WorkspaceIDNone) is
// treated as empty here, matching the convention used elsewhere in the repo
// (e.g. libs/databrickscfg/profile/profiler.go).
func isPATOnSPOGWithoutWorkspaceID(cfg *config.Config) bool {
return cfg.AuthType == auth.AuthTypePat &&
(cfg.WorkspaceID == "" || cfg.WorkspaceID == auth.WorkspaceIDNone) &&
auth.HasUnifiedHostSignal(cfg.DiscoveryURL)
Comment thread
simonfaltum marked this conversation as resolved.
}

// patSPOGNoWorkspaceIDError describes the configuration gap and how to fix it.
func patSPOGNoWorkspaceIDError(profileName string) error {
if profileName == "" {
return errors.New("personal access token (PAT) auth on this host requires a workspace_id; PATs are workspace-scoped, but no workspace_id is set. Add workspace_id = <id> (or set DATABRICKS_WORKSPACE_ID) to the profile associated with this PAT token")
}
return fmt.Errorf("profile %q uses PAT auth on a SPOG host but is missing workspace_id; PATs are workspace-scoped, so the request can't be routed. Edit the profile to add workspace_id = <id> matching the workspace the token was minted in", profileName)
}

// ErrAccountOnlyProfile signals that the resolved profile has an account_id
// but no workspace_id, so workspace APIs can't be reached. Workspace-only
// commands surface this as an actionable error; MustAnyClient (used by `auth
Expand Down Expand Up @@ -221,9 +241,20 @@ func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPromp
//
// We require cfg.Profile to be set so we don't reject env-var-only
// configs targeting a unified host where workspace APIs are also
// served from the account host.
// served from the account host. This branch runs first so MustAnyClient
// can recognize ErrAccountOnlyProfile and fall through to the account
// client; the PAT-on-SPOG check below handles the remaining cases
// (env-var-only configs and profiles without account_id resolved).
return nil, accountOnlyProfileError(cfg.Profile)
}
if err == nil && isPATOnSPOGWithoutWorkspaceID(cfg) {
// PATs are workspace-scoped. On a SPOG host without workspace_id the
// SDK can't add the routing identifier, the backend treats the call as
// account-plane, and PATs aren't accepted there. The result is an
// opaque "Credential was not sent" error from the auth endpoint;
// rewrite up front so the user sees what's actually wrong.
return nil, patSPOGNoWorkspaceIDError(cfg.Profile)
}
if err == nil {
err = w.Config.Authenticate(emptyHttpRequest(ctx))
}
Expand Down
128 changes: 128 additions & 0 deletions cmd/root/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package root

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"github.com/databricks/cli/internal/testutil"
"github.com/databricks/cli/libs/auth"
"github.com/databricks/cli/libs/cmdctx"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/databricks-sdk-go"
Expand Down Expand Up @@ -454,6 +457,62 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) {
assert.ErrorIs(t, err, databricks.ErrNotAccountClient)
}

func TestIsPATOnSPOGWithoutWorkspaceID(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
want bool
}{
{
name: "pat on spog without workspace_id",
cfg: &config.Config{
AuthType: "pat",
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
},
want: true,
},
{
name: "pat on spog with workspace_id is fine",
cfg: &config.Config{
AuthType: "pat",
WorkspaceID: "12345",
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
},
want: false,
},
{
name: "pat on spog with legacy 'none' sentinel is treated as missing",
cfg: &config.Config{
AuthType: "pat",
WorkspaceID: auth.WorkspaceIDNone,
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
},
want: true,
},
{
name: "pat on classic workspace host is fine",
cfg: &config.Config{
AuthType: "pat",
DiscoveryURL: "https://workspace.example.test/oidc/.well-known/oauth-authorization-server",
},
want: false,
},
{
name: "u2m on spog is not affected (handled by other paths)",
cfg: &config.Config{
AuthType: "databricks-cli",
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isPATOnSPOGWithoutWorkspaceID(tt.cfg))
})
}
}

func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -490,6 +549,75 @@ func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) {
}
}

func TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID(t *testing.T) {
testutil.CleanupEnvironment(t)
t.Setenv("PATH", "")

// No AccountID is set, so the account-only profile detector (which requires
// AccountID) does not fire and the PAT-on-SPOG detector is exercised.
cfg := &config.Config{
Host: "https://spog.example.test/",
Token: "dapi-fake",
Profile: "spog-pat",
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc-123/.well-known/oauth-authorization-server",
AuthType: "pat",
HTTPTransport: noNetworkTransport,
}

w, err := workspaceClientOrPrompt(t.Context(), cfg, false)
assert.Nil(t, w)
require.Error(t, err)
assert.Contains(t, err.Error(), `profile "spog-pat"`)
assert.Contains(t, err.Error(), "workspace_id")
assert.Contains(t, err.Error(), "PAT")
}

// TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile exercises the
// real .databrickscfg shape from the bug bash: `host` + `token` only, no
// `auth_type`, no `workspace_id`. The SDK populates AuthType during
// NewWorkspaceClient via its credential probe, so the PAT-on-SPOG detector
// must keep working after going through that path.
func TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile(t *testing.T) {
// testutil.CleanupEnvironment calls os.Clearenv(), which wipes Windows
// essentials like SystemRoot and breaks Winsock initialization for
// subsequent net.Listen calls. We only need a clean DATABRICKS_CONFIG_FILE
// for this test; set it directly with t.Setenv so the rest of the
// environment (notably the Windows networking stack) keeps working.
t.Setenv("DATABRICKS_AUTH_TYPE", "")
t.Setenv("DATABRICKS_HOST", "")
t.Setenv("DATABRICKS_TOKEN", "")
t.Setenv("DATABRICKS_CONFIG_PROFILE", "")
t.Setenv("PATH", "")

// Mock .well-known/databricks-config to return an account-scoped OIDC
// endpoint so the SDK populates cfg.DiscoveryURL with the SPOG signal.
// Omit account_id so AccountID stays unset; otherwise the account-only
// profile detector would intercept this case before the PAT-on-SPOG check.
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/databricks-config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"oidc_endpoint":"https://spog.example.test/oidc/accounts/abc-123"}`))
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

configFile := filepath.Join(t.TempDir(), ".databrickscfg")
require.NoError(t, os.WriteFile(configFile, fmt.Appendf(nil, `
[spog-pat]
host = %s
token = dapi-fake
`, server.URL), 0o600))
t.Setenv("DATABRICKS_CONFIG_FILE", configFile)

cfg := &config.Config{Profile: "spog-pat"}
w, err := workspaceClientOrPrompt(t.Context(), cfg, false)
assert.Nil(t, w)
require.Error(t, err)
assert.Contains(t, err.Error(), `profile "spog-pat"`)
assert.Contains(t, err.Error(), "workspace_id")
assert.Contains(t, err.Error(), "PAT")
}

func TestMustAnyClientFallsThroughOnAccountOnlyProfile(t *testing.T) {
testutil.CleanupEnvironment(t)
t.Setenv("PATH", "")
Expand Down
Loading