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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions cmd/root/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ 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.
func isPATOnSPOGWithoutWorkspaceID(cfg *config.Config) bool {
return cfg.AuthType == auth.AuthTypePat &&
cfg.WorkspaceID == "" &&
auth.HasUnifiedHostSignal(cfg.DiscoveryURL)
}

// 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> to the profile (or set DATABRICKS_WORKSPACE_ID) to the workspace the token was minted in")
}
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)
}

func profileFlagValue(cmd *cobra.Command) (string, bool) {
profileFlag := cmd.Flag("profile")
if profileFlag == nil {
Expand Down Expand Up @@ -189,6 +207,14 @@ func MustAccountClient(cmd *cobra.Command, args []string) error {
// Helper function to create a workspace client or prompt once if the given configuration is not valid.
func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPrompt bool) (*databricks.WorkspaceClient, error) {
w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg))
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
115 changes: 115 additions & 0 deletions cmd/root/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package root

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -454,6 +456,119 @@ 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 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 TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID(t *testing.T) {
testutil.CleanupEnvironment(t)
t.Setenv("PATH", "")

cfg := &config.Config{
Host: "https://spog.example.test/",
AccountID: "abc-123",
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.
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(`{"account_id":"abc-123","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 TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) {
testutil.CleanupEnvironment(t)
t.Setenv("PATH", "")
Expand Down
Loading