Skip to content

Commit 61a1131

Browse files
authored
Enrich 401/403 errors with auth identity context (#4576)
## Why When CLI commands fail with 401 (unauthorized) or 403 (forbidden), the error message shows only the raw API error (e.g., `PERMISSION_DENIED: ...`) with no indication of which profile, host, or auth method was used. This makes debugging auth issues unnecessarily difficult — the first thing users and support ask is "which identity am I using?" ## Changes - **`libs/auth/error.go`** — New `EnrichAuthError` function that detects `apierr.APIError` with status 401 or 403 and appends: - Identity context: profile, host, auth type (each omitted if empty) - Auth-type-aware remediation steps (OAuth → `auth login`, PAT → regenerate token, Azure CLI → `az login`, M2M → check credentials, etc.) - `auth describe` command with correct flags for workspace/account/unified contexts (including `--workspace-id`) - Profile nudge when using env-var-based auth - **`libs/auth/error.go`** — New `BuildDescribeCommand` that builds `databricks auth describe` flags directly from `*config.Config` (avoids information loss from OAuthArgument conversion, e.g., workspace-id) - **`cmd/root/root.go`** — Call `EnrichAuthError` in the centralized `Execute` error path, gated on `cmdctx.HasConfigUsed`. Covers both client creation errors (PersistentPreRunE) and command execution errors (RunE) - **`acceptance/workspace/jobs/create-error/output.txt`** — Regenerated golden file ### Scope limitations Commands that bypass `MustWorkspaceClient`/`MustAccountClient` and don't call `SetConfigUsed` (e.g., `databricks api`) won't get enrichment. This can be addressed in a follow-up. ### Example output <img width="1354" height="173" alt="image" src="https://github.com/user-attachments/assets/901687e6-d7dc-4837-9715-bb1405bd1280" /> https://github.com/user-attachments/assets/2fa2445f-c15b-4dfa-b785-255c4e621f6d **403 with profile:** ``` Error: PERMISSION_DENIED: User does not have permission. Profile: my-profile Host: https://myworkspace.cloud.databricks.com Auth type: pat Next steps: - Verify you have the required permissions for this operation - Check your identity: databricks auth describe --profile my-profile ``` **401 without profile (env var auth):** ``` Error: UNAUTHENTICATED: Token expired. Host: https://myworkspace.cloud.databricks.com Auth type: pat Next steps: - Regenerate your access token - Check your identity: databricks auth describe --host https://myworkspace.cloud.databricks.com - Consider configuring a profile: databricks configure --profile <name> ``` ## Test plan ### Automated - [x] `go test ./libs/auth/...` — unit tests for `EnrichAuthError`, `BuildDescribeCommand`, error preservation, all auth types, unified host workspace-id - [x] `go test ./cmd/root/...` — integration tests for Execute wiring (with ConfigUsed, without, ErrAlreadyPrinted) - [x] `go test ./acceptance -run TestAccept/workspace/jobs/create-error` — acceptance test with updated golden file - [x] `make checks` — whitespace, tidy, links ### Manual testing **Force a 401 (expired/invalid token):** ```bash # Option A: Set a bogus token via env vars (no profile) DATABRICKS_HOST=https://<your-workspace>.cloud.databricks.com \ DATABRICKS_TOKEN=invalid-token-abc123 \ /tmp/databricks-test clusters list # Option B: Create a profile with a bad token cat >> ~/.databrickscfg << 'EOF' [bad-token-test] host = https://<your-workspace>.cloud.databricks.com token = dapi_invalid_token_for_testing EOF /tmp/databricks-test clusters list --profile bad-token-test # Clean up after: # Remove the [bad-token-test] section from ~/.databrickscfg ``` **Verify non-auth errors are unaffected:** ```bash # A 404 should show no enrichment: /tmp/databricks-test clusters get --cluster-id nonexistent-id --profile <your-profile> ```
1 parent 794469d commit 61a1131

File tree

5 files changed

+524
-0
lines changed

5 files changed

+524
-0
lines changed

acceptance/workspace/jobs/create-error/output.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,12 @@
22
>>> [CLI] jobs create --json {"name":"abc"}
33
Error: Invalid access token.
44

5+
Host: [DATABRICKS_URL]
6+
Auth type: Personal Access Token (pat)
7+
8+
Next steps:
9+
- Verify you have the required permissions for this operation
10+
- Check your identity: databricks auth describe
11+
- Consider configuring a profile: databricks configure --profile <name>
12+
513
Exit code: 1

cmd/root/root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/databricks/cli/internal/build"
1515
"github.com/databricks/cli/libs/agent"
16+
"github.com/databricks/cli/libs/auth"
1617
"github.com/databricks/cli/libs/cmdctx"
1718
"github.com/databricks/cli/libs/cmdio"
1819
"github.com/databricks/cli/libs/dbr"
@@ -147,6 +148,10 @@ Stack Trace:
147148
// Run the command
148149
cmd, err = cmd.ExecuteContextC(ctx)
149150
if err != nil && !errors.Is(err, ErrAlreadyPrinted) {
151+
if cmdctx.HasConfigUsed(cmd.Context()) {
152+
cfg := cmdctx.ConfigUsed(cmd.Context())
153+
err = auth.EnrichAuthError(cmd.Context(), cfg, err)
154+
}
150155
fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err.Error())
151156
}
152157

cmd/root/root_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package root
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
8+
"github.com/databricks/cli/libs/cmdctx"
9+
"github.com/databricks/databricks-sdk-go/apierr"
10+
"github.com/databricks/databricks-sdk-go/config"
11+
"github.com/spf13/cobra"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestExecuteEnrichesAuthErrors(t *testing.T) {
17+
ctx := context.Background()
18+
stderr := &bytes.Buffer{}
19+
20+
cmd := &cobra.Command{
21+
Use: "test",
22+
SilenceUsage: true,
23+
SilenceErrors: true,
24+
RunE: func(cmd *cobra.Command, args []string) error {
25+
return &apierr.APIError{
26+
StatusCode: 403,
27+
ErrorCode: "PERMISSION_DENIED",
28+
Message: "no access",
29+
}
30+
},
31+
}
32+
cmd.SetErr(stderr)
33+
34+
// Simulate MustWorkspaceClient setting config in context via PersistentPreRunE.
35+
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
36+
cfg := &config.Config{
37+
Host: "https://test.cloud.databricks.com",
38+
Profile: "test-profile",
39+
AuthType: "pat",
40+
}
41+
ctx := cmdctx.SetConfigUsed(cmd.Context(), cfg)
42+
cmd.SetContext(ctx)
43+
return nil
44+
}
45+
46+
err := Execute(ctx, cmd)
47+
require.Error(t, err)
48+
49+
output := stderr.String()
50+
assert.Contains(t, output, "no access")
51+
assert.Contains(t, output, "Next steps:")
52+
}
53+
54+
func TestExecuteNoEnrichmentWithoutConfigUsed(t *testing.T) {
55+
ctx := context.Background()
56+
stderr := &bytes.Buffer{}
57+
58+
cmd := &cobra.Command{
59+
Use: "test",
60+
SilenceUsage: true,
61+
SilenceErrors: true,
62+
RunE: func(cmd *cobra.Command, args []string) error {
63+
return &apierr.APIError{
64+
StatusCode: 403,
65+
ErrorCode: "PERMISSION_DENIED",
66+
Message: "no access",
67+
}
68+
},
69+
}
70+
cmd.SetErr(stderr)
71+
72+
err := Execute(ctx, cmd)
73+
require.Error(t, err)
74+
75+
output := stderr.String()
76+
assert.Contains(t, output, "no access")
77+
assert.NotContains(t, output, "Profile:")
78+
assert.NotContains(t, output, "Next steps:")
79+
}
80+
81+
func TestExecuteErrAlreadyPrintedNotEnriched(t *testing.T) {
82+
ctx := context.Background()
83+
stderr := &bytes.Buffer{}
84+
85+
cmd := &cobra.Command{
86+
Use: "test",
87+
SilenceUsage: true,
88+
SilenceErrors: true,
89+
RunE: func(cmd *cobra.Command, args []string) error {
90+
return ErrAlreadyPrinted
91+
},
92+
}
93+
cmd.SetErr(stderr)
94+
95+
err := Execute(ctx, cmd)
96+
require.Error(t, err)
97+
assert.Empty(t, stderr.String())
98+
}

libs/auth/error.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,54 @@ package auth
33
import (
44
"context"
55
"errors"
6+
"fmt"
7+
"net/http"
68
"strings"
79

10+
"github.com/databricks/databricks-sdk-go/apierr"
11+
"github.com/databricks/databricks-sdk-go/config"
812
"github.com/databricks/databricks-sdk-go/credentials/u2m"
913
)
1014

15+
// Auth type names returned by credential providers.
16+
const (
17+
AuthTypeDatabricksCli = "databricks-cli"
18+
AuthTypePat = "pat"
19+
AuthTypeBasic = "basic"
20+
AuthTypeAzureCli = "azure-cli"
21+
AuthTypeOAuthM2M = "oauth-m2m"
22+
AuthTypeAzureMSI = "azure-msi"
23+
AuthTypeAzureSecret = "azure-client-secret"
24+
AuthTypeGoogleCreds = "google-credentials"
25+
AuthTypeGoogleID = "google-id"
26+
AuthTypeGitHubOIDC = "github-oidc-azure"
27+
AuthTypeMetadataService = "metadata-service"
28+
)
29+
30+
// authTypeDisplayNames maps auth type identifiers to human-readable names.
31+
var authTypeDisplayNames = map[string]string{
32+
AuthTypeDatabricksCli: "OAuth (databricks-cli)",
33+
AuthTypePat: "Personal Access Token (pat)",
34+
AuthTypeBasic: "Basic",
35+
AuthTypeAzureCli: "Azure CLI (azure-cli)",
36+
AuthTypeOAuthM2M: "OAuth Machine-to-Machine (oauth-m2m)",
37+
AuthTypeAzureMSI: "Azure Managed Identity (azure-msi)",
38+
AuthTypeAzureSecret: "Azure Client Secret (azure-client-secret)",
39+
AuthTypeGoogleCreds: "Google Credentials (google-credentials)",
40+
AuthTypeGoogleID: "Google Default Credentials (google-id)",
41+
AuthTypeGitHubOIDC: "GitHub OIDC for Azure (github-oidc-azure)",
42+
AuthTypeMetadataService: "Metadata Service (metadata-service)",
43+
}
44+
45+
// AuthTypeDisplayName returns a human-readable name for the given auth type.
46+
// Falls back to the raw identifier if no display name is registered.
47+
func AuthTypeDisplayName(authType string) string {
48+
if name, ok := authTypeDisplayNames[strings.ToLower(authType)]; ok {
49+
return name
50+
}
51+
return authType
52+
}
53+
1154
// RewriteAuthError rewrites the error message for invalid refresh token error.
1255
// It returns whether the error was rewritten and the rewritten error.
1356
func RewriteAuthError(ctx context.Context, host, accountId, profile string, err error) (bool, error) {
@@ -27,6 +70,102 @@ func RewriteAuthError(ctx context.Context, host, accountId, profile string, err
2770
return false, err
2871
}
2972

73+
// EnrichAuthError appends identity context and remediation steps to 401/403 API errors.
74+
// For non-API errors or other status codes, the original error is returned unchanged.
75+
func EnrichAuthError(ctx context.Context, cfg *config.Config, err error) error {
76+
var apiErr *apierr.APIError
77+
if !errors.As(err, &apiErr) {
78+
return err
79+
}
80+
if apiErr.StatusCode != http.StatusUnauthorized && apiErr.StatusCode != http.StatusForbidden {
81+
return err
82+
}
83+
84+
var b strings.Builder
85+
86+
// Identity context.
87+
if cfg.Profile != "" {
88+
fmt.Fprintf(&b, "\nProfile: %s", cfg.Profile)
89+
}
90+
if cfg.Host != "" {
91+
fmt.Fprintf(&b, "\nHost: %s", cfg.Host)
92+
}
93+
if cfg.AuthType != "" {
94+
fmt.Fprintf(&b, "\nAuth type: %s", AuthTypeDisplayName(cfg.AuthType))
95+
}
96+
97+
fmt.Fprint(&b, "\n\nNext steps:")
98+
99+
if apiErr.StatusCode == http.StatusUnauthorized {
100+
writeReauthSteps(ctx, cfg, &b)
101+
} else {
102+
fmt.Fprint(&b, "\n - Verify you have the required permissions for this operation")
103+
}
104+
105+
// Always suggest checking identity.
106+
fmt.Fprintf(&b, "\n - Check your identity: %s", BuildDescribeCommand(cfg))
107+
108+
// Nudge toward profiles when using env-var-based auth.
109+
if cfg.Profile == "" {
110+
fmt.Fprint(&b, "\n - Consider configuring a profile: databricks configure --profile <name>")
111+
}
112+
113+
return fmt.Errorf("%w\n%s", err, b.String())
114+
}
115+
116+
// writeReauthSteps writes auth-type-aware re-authentication suggestions for 401 errors.
117+
func writeReauthSteps(ctx context.Context, cfg *config.Config, b *strings.Builder) {
118+
switch strings.ToLower(cfg.AuthType) {
119+
case AuthTypeDatabricksCli:
120+
// When profile is set, BuildLoginCommand uses --profile and ignores
121+
// the OAuthArgument, so skip the conversion entirely.
122+
if cfg.Profile != "" {
123+
fmt.Fprintf(b, "\n - Re-authenticate: databricks auth login --profile %s", cfg.Profile)
124+
return
125+
}
126+
oauthArg, argErr := AuthArguments{
127+
Host: cfg.Host,
128+
AccountID: cfg.AccountID,
129+
WorkspaceID: cfg.WorkspaceID,
130+
IsUnifiedHost: cfg.Experimental_IsUnifiedHost,
131+
}.ToOAuthArgument()
132+
if argErr != nil {
133+
fmt.Fprint(b, "\n - Re-authenticate: databricks auth login")
134+
return
135+
}
136+
loginCmd := BuildLoginCommand(ctx, "", oauthArg)
137+
// For unified hosts, BuildLoginCommand (via OAuthArgument) doesn't carry
138+
// workspace-id. Append it so the command is actionable.
139+
if cfg.Experimental_IsUnifiedHost && cfg.WorkspaceID != "" {
140+
loginCmd += " --workspace-id " + cfg.WorkspaceID
141+
}
142+
fmt.Fprintf(b, "\n - Re-authenticate: %s", loginCmd)
143+
144+
case AuthTypePat:
145+
if cfg.Profile != "" {
146+
fmt.Fprintf(b, "\n - Regenerate your access token or run: databricks configure --profile %s", cfg.Profile)
147+
} else {
148+
fmt.Fprint(b, "\n - Regenerate your access token")
149+
}
150+
151+
case AuthTypeBasic:
152+
if cfg.Profile != "" {
153+
fmt.Fprintf(b, "\n - Check your username/password or run: databricks configure --profile %s", cfg.Profile)
154+
} else {
155+
fmt.Fprint(b, "\n - Check your username and password")
156+
}
157+
158+
case AuthTypeAzureCli:
159+
fmt.Fprint(b, "\n - Re-authenticate with Azure: az login")
160+
161+
case AuthTypeOAuthM2M:
162+
fmt.Fprint(b, "\n - Check your service principal client ID and secret")
163+
164+
default:
165+
fmt.Fprint(b, "\n - Check your authentication credentials")
166+
}
167+
}
168+
30169
// BuildLoginCommand builds the login command for the given OAuth argument or profile.
31170
func BuildLoginCommand(ctx context.Context, profile string, arg u2m.OAuthArgument) string {
32171
cmd := []string{
@@ -48,3 +187,14 @@ func BuildLoginCommand(ctx context.Context, profile string, arg u2m.OAuthArgumen
48187
}
49188
return strings.Join(cmd, " ")
50189
}
190+
191+
// BuildDescribeCommand builds the describe command for the given config.
192+
// When a profile is set, it uses --profile. Otherwise it emits a bare command
193+
// since `databricks auth describe` resolves env vars (DATABRICKS_HOST, etc.)
194+
// automatically.
195+
func BuildDescribeCommand(cfg *config.Config) string {
196+
if cfg.Profile != "" {
197+
return "databricks auth describe --profile " + cfg.Profile
198+
}
199+
return "databricks auth describe"
200+
}

0 commit comments

Comments
 (0)