Skip to content

Commit aaebbe8

Browse files
authored
auth: clear error for PAT profile on SPOG without workspace_id (#5341)
## Why Personal access tokens are workspace-scoped. When a PAT profile points at a SPOG host (account-scoped OIDC discovery) without \`workspace_id\`, the SDK can't add the routing identifier, the request lands on the account-plane where PATs aren't accepted, and the user sees the opaque error \"Credential was not sent or was of an unsupported type for this API\" from the auth endpoint. Reported in a bug bash. Reproduced against \`db-deco-test.databricks.com\`: \`\`\` [spog-pat-no-wid] host = https://db-deco-test.databricks.com token = dapi... $ databricks auth describe --profile spog-pat-no-wid Unable to authenticate: Credential was not sent or was of an unsupported type for this API. [ReqId: ...] \`\`\` ## Changes - Before: PAT-on-SPOG without \`workspace_id\` failed with the opaque credentials error. - Now: \`workspaceClientOrPrompt\` detects the combination (\`auth_type=pat\` + SPOG discovery signal + \`workspace_id\` empty) before any API call and returns a message that names the profile, explains the routing constraint, and points at the fix: add \`workspace_id = <id>\` for the workspace the token was minted in. \`databricks auth describe --profile spog-pat-no-wid\` now prints: \`\`\` Unable to authenticate: profile \"spog-pat-no-wid\" 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 \`\`\` ## Test plan - [x] Reproduced against \`db-deco-test.databricks.com\` with a PAT profile that has no \`workspace_id\`; saw the opaque \"Credential was not sent\" error. - [x] Same profile with the fix → clear, actionable error message naming the profile and pointing at the fix (verified with \`auth describe\` and \`current-user me\`). - [x] Existing \`spog-deco-aws\` (PAT with valid \`workspace_id\`) still works against the same host — no regression. - [x] New unit tests: \`TestIsPATOnSPOGWithoutWorkspaceID\`, \`TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID\` - [x] \`go test ./cmd/root/...\`, \`./task checks\`, \`./task lint-q\`
1 parent 5a2669c commit aaebbe8

2 files changed

Lines changed: 160 additions & 1 deletion

File tree

cmd/root/auth.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,26 @@ func initProfileFlag(cmd *cobra.Command) {
5252
cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion)
5353
}
5454

55+
// isPATOnSPOGWithoutWorkspaceID reports whether the resolved config is a PAT
56+
// profile pointing at a SPOG host with no workspace_id set. The SDK strips the
57+
// routing identifier from the request, which lands on the account-plane where
58+
// PATs aren't accepted. The legacy "none" sentinel (auth.WorkspaceIDNone) is
59+
// treated as empty here, matching the convention used elsewhere in the repo
60+
// (e.g. libs/databrickscfg/profile/profiler.go).
61+
func isPATOnSPOGWithoutWorkspaceID(cfg *config.Config) bool {
62+
return cfg.AuthType == auth.AuthTypePat &&
63+
(cfg.WorkspaceID == "" || cfg.WorkspaceID == auth.WorkspaceIDNone) &&
64+
auth.HasUnifiedHostSignal(cfg.DiscoveryURL)
65+
}
66+
67+
// patSPOGNoWorkspaceIDError describes the configuration gap and how to fix it.
68+
func patSPOGNoWorkspaceIDError(profileName string) error {
69+
if profileName == "" {
70+
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")
71+
}
72+
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)
73+
}
74+
5575
// ErrAccountOnlyProfile signals that the resolved profile has an account_id
5676
// but no workspace_id, so workspace APIs can't be reached. Workspace-only
5777
// commands surface this as an actionable error; MustAnyClient (used by `auth
@@ -221,9 +241,20 @@ func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPromp
221241
//
222242
// We require cfg.Profile to be set so we don't reject env-var-only
223243
// configs targeting a unified host where workspace APIs are also
224-
// served from the account host.
244+
// served from the account host. This branch runs first so MustAnyClient
245+
// can recognize ErrAccountOnlyProfile and fall through to the account
246+
// client; the PAT-on-SPOG check below handles the remaining cases
247+
// (env-var-only configs and profiles without account_id resolved).
225248
return nil, accountOnlyProfileError(cfg.Profile)
226249
}
250+
if err == nil && isPATOnSPOGWithoutWorkspaceID(cfg) {
251+
// PATs are workspace-scoped. On a SPOG host without workspace_id the
252+
// SDK can't add the routing identifier, the backend treats the call as
253+
// account-plane, and PATs aren't accepted there. The result is an
254+
// opaque "Credential was not sent" error from the auth endpoint;
255+
// rewrite up front so the user sees what's actually wrong.
256+
return nil, patSPOGNoWorkspaceIDError(cfg.Profile)
257+
}
227258
if err == nil {
228259
err = w.Config.Authenticate(emptyHttpRequest(ctx))
229260
}

cmd/root/auth_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package root
22

33
import (
44
"context"
5+
"fmt"
56
"net/http"
7+
"net/http/httptest"
68
"os"
79
"path/filepath"
810
"testing"
911
"time"
1012

1113
"github.com/databricks/cli/internal/testutil"
14+
"github.com/databricks/cli/libs/auth"
1215
"github.com/databricks/cli/libs/cmdctx"
1316
"github.com/databricks/cli/libs/cmdio"
1417
"github.com/databricks/databricks-sdk-go"
@@ -454,6 +457,62 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) {
454457
assert.ErrorIs(t, err, databricks.ErrNotAccountClient)
455458
}
456459

460+
func TestIsPATOnSPOGWithoutWorkspaceID(t *testing.T) {
461+
tests := []struct {
462+
name string
463+
cfg *config.Config
464+
want bool
465+
}{
466+
{
467+
name: "pat on spog without workspace_id",
468+
cfg: &config.Config{
469+
AuthType: "pat",
470+
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
471+
},
472+
want: true,
473+
},
474+
{
475+
name: "pat on spog with workspace_id is fine",
476+
cfg: &config.Config{
477+
AuthType: "pat",
478+
WorkspaceID: "12345",
479+
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
480+
},
481+
want: false,
482+
},
483+
{
484+
name: "pat on spog with legacy 'none' sentinel is treated as missing",
485+
cfg: &config.Config{
486+
AuthType: "pat",
487+
WorkspaceID: auth.WorkspaceIDNone,
488+
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
489+
},
490+
want: true,
491+
},
492+
{
493+
name: "pat on classic workspace host is fine",
494+
cfg: &config.Config{
495+
AuthType: "pat",
496+
DiscoveryURL: "https://workspace.example.test/oidc/.well-known/oauth-authorization-server",
497+
},
498+
want: false,
499+
},
500+
{
501+
name: "u2m on spog is not affected (handled by other paths)",
502+
cfg: &config.Config{
503+
AuthType: "databricks-cli",
504+
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc/.well-known/oauth-authorization-server",
505+
},
506+
want: false,
507+
},
508+
}
509+
for _, tt := range tests {
510+
t.Run(tt.name, func(t *testing.T) {
511+
assert.Equal(t, tt.want, isPATOnSPOGWithoutWorkspaceID(tt.cfg))
512+
})
513+
}
514+
}
515+
457516
func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) {
458517
tests := []struct {
459518
name string
@@ -490,6 +549,75 @@ func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) {
490549
}
491550
}
492551

552+
func TestWorkspaceClientOrPromptRejectsPATOnSPOGWithoutWorkspaceID(t *testing.T) {
553+
testutil.CleanupEnvironment(t)
554+
t.Setenv("PATH", "")
555+
556+
// No AccountID is set, so the account-only profile detector (which requires
557+
// AccountID) does not fire and the PAT-on-SPOG detector is exercised.
558+
cfg := &config.Config{
559+
Host: "https://spog.example.test/",
560+
Token: "dapi-fake",
561+
Profile: "spog-pat",
562+
DiscoveryURL: "https://spog.example.test/oidc/accounts/abc-123/.well-known/oauth-authorization-server",
563+
AuthType: "pat",
564+
HTTPTransport: noNetworkTransport,
565+
}
566+
567+
w, err := workspaceClientOrPrompt(t.Context(), cfg, false)
568+
assert.Nil(t, w)
569+
require.Error(t, err)
570+
assert.Contains(t, err.Error(), `profile "spog-pat"`)
571+
assert.Contains(t, err.Error(), "workspace_id")
572+
assert.Contains(t, err.Error(), "PAT")
573+
}
574+
575+
// TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile exercises the
576+
// real .databrickscfg shape from the bug bash: `host` + `token` only, no
577+
// `auth_type`, no `workspace_id`. The SDK populates AuthType during
578+
// NewWorkspaceClient via its credential probe, so the PAT-on-SPOG detector
579+
// must keep working after going through that path.
580+
func TestWorkspaceClientOrPromptRejectsPATOnSPOGFromConfigFile(t *testing.T) {
581+
// testutil.CleanupEnvironment calls os.Clearenv(), which wipes Windows
582+
// essentials like SystemRoot and breaks Winsock initialization for
583+
// subsequent net.Listen calls. We only need a clean DATABRICKS_CONFIG_FILE
584+
// for this test; set it directly with t.Setenv so the rest of the
585+
// environment (notably the Windows networking stack) keeps working.
586+
t.Setenv("DATABRICKS_AUTH_TYPE", "")
587+
t.Setenv("DATABRICKS_HOST", "")
588+
t.Setenv("DATABRICKS_TOKEN", "")
589+
t.Setenv("DATABRICKS_CONFIG_PROFILE", "")
590+
t.Setenv("PATH", "")
591+
592+
// Mock .well-known/databricks-config to return an account-scoped OIDC
593+
// endpoint so the SDK populates cfg.DiscoveryURL with the SPOG signal.
594+
// Omit account_id so AccountID stays unset; otherwise the account-only
595+
// profile detector would intercept this case before the PAT-on-SPOG check.
596+
mux := http.NewServeMux()
597+
mux.HandleFunc("/.well-known/databricks-config", func(w http.ResponseWriter, r *http.Request) {
598+
w.Header().Set("Content-Type", "application/json")
599+
_, _ = w.Write([]byte(`{"oidc_endpoint":"https://spog.example.test/oidc/accounts/abc-123"}`))
600+
})
601+
server := httptest.NewServer(mux)
602+
t.Cleanup(server.Close)
603+
604+
configFile := filepath.Join(t.TempDir(), ".databrickscfg")
605+
require.NoError(t, os.WriteFile(configFile, fmt.Appendf(nil, `
606+
[spog-pat]
607+
host = %s
608+
token = dapi-fake
609+
`, server.URL), 0o600))
610+
t.Setenv("DATABRICKS_CONFIG_FILE", configFile)
611+
612+
cfg := &config.Config{Profile: "spog-pat"}
613+
w, err := workspaceClientOrPrompt(t.Context(), cfg, false)
614+
assert.Nil(t, w)
615+
require.Error(t, err)
616+
assert.Contains(t, err.Error(), `profile "spog-pat"`)
617+
assert.Contains(t, err.Error(), "workspace_id")
618+
assert.Contains(t, err.Error(), "PAT")
619+
}
620+
493621
func TestMustAnyClientFallsThroughOnAccountOnlyProfile(t *testing.T) {
494622
testutil.CleanupEnvironment(t)
495623
t.Setenv("PATH", "")

0 commit comments

Comments
 (0)