From fe82d0aeb9252260bf0c96b1304f5d25a7ec88ba Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 18 Mar 2026 13:43:10 +0000 Subject: [PATCH] fix: allow member users to generate support bundles (#23040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes AIGOV-141 The `coder support bundle` command previously required admin permissions (`Read DeploymentConfig`) and would abort entirely for non-admin `member` users with: ``` failed authorization check: cannot Read DeploymentValues ``` This change makes the command **degrade gracefully** instead of failing outright.
Changes ### `support/support.go` - **`Run()`**: The authorization check for `Read DeploymentValues` is now a soft warning instead of a hard gate. Unauthenticated users (401) still fail, but authenticated users with insufficient permissions proceed with reduced data. - **`DeploymentInfo()`**: `DeploymentConfig` and `DebugHealth` fetches now handle 403/401 responses gracefully, matching the existing pattern used by `DeploymentStats`, `Entitlements`, and `HealthSettings`. - **`NetworkInfo()`**: Coordinator debug and tailnet debug fetches now check response status codes for 403/401 before reading the body. ### `cli/support.go` - **`summarizeBundle()`**: No longer returns early when `Config` or `HealthReport` is nil. Instead prints warnings and continues summarizing available data (e.g., netcheck). ### Tests - `MissingPrivilege` → `MemberNoWorkspace`: Asserts member users can generate a bundle successfully with degraded admin-only data. - `NoPrivilege` → `MemberCanGenerateBundle`: Asserts the CLI produces a valid zip bundle for member users. - All existing tests continue to pass (`NoAuth`, `OK`, `OK_NoWorkspace`, `DontPanic`, etc.). ## Behavior matrix | User type | Before | After | |---|---|---| | **Admin** | Full bundle | Full bundle (no change) | | **Member** | Hard error | Bundle with degraded admin-only data | | **Unauthenticated** | Hard error | Hard error (no change) | Related to PRODUCT-182 --- cli/support.go | 43 +++++++++++++--------- cli/support_test.go | 33 +++++++++++++++-- docs/support/support-bundle.md | 57 +++++++++++++++-------------- support/support.go | 65 +++++++++++++++++++++++++++------- support/support_test.go | 28 ++++++++++----- 5 files changed, 159 insertions(+), 67 deletions(-) diff --git a/cli/support.go b/cli/support.go index 83a9945084..07290a7e63 100644 --- a/cli/support.go +++ b/cli/support.go @@ -113,6 +113,20 @@ func (r *RootCmd) supportBundle() *serpent.Command { ) cliLog.Debug(inv.Context(), "invocation", slog.F("args", strings.Join(os.Args, " "))) + // Bypass rate limiting for support bundle collection since it makes many API calls. + // Note: this can only be done by the owner user. + if ok, err := support.CanGenerateFull(inv.Context(), client); err == nil && ok { + cliLog.Debug(inv.Context(), "running as owner") + client.HTTPClient.Transport = &codersdk.HeaderTransport{ + Transport: client.HTTPClient.Transport, + Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}}, + } + } else if !ok { + cliLog.Warn(inv.Context(), "not running as owner, not all information available") + } else { + cliLog.Error(inv.Context(), "failed to look up current user", slog.Error(err)) + } + // Check if we're running inside a workspace if val, found := os.LookupEnv("CODER"); found && val == "true" { cliui.Warn(inv.Stderr, "Running inside Coder workspace; this can affect results!") @@ -200,12 +214,6 @@ func (r *RootCmd) supportBundle() *serpent.Command { _, _ = fmt.Fprintln(inv.Stderr, "pprof data collection will take approximately 30 seconds...") } - // Bypass rate limiting for support bundle collection since it makes many API calls. - client.HTTPClient.Transport = &codersdk.HeaderTransport{ - Transport: client.HTTPClient.Transport, - Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}}, - } - deps := support.Deps{ Client: client, // Support adds a sink so we don't need to supply one ourselves. @@ -354,19 +362,20 @@ func summarizeBundle(inv *serpent.Invocation, bun *support.Bundle) { return } - if bun.Deployment.Config == nil { - cliui.Error(inv.Stdout, "No deployment configuration available!") - return + var docsURL string + if bun.Deployment.Config != nil { + docsURL = bun.Deployment.Config.Values.DocsURL.String() + } else { + cliui.Warn(inv.Stdout, "No deployment configuration available. This may require the Owner role.") } - docsURL := bun.Deployment.Config.Values.DocsURL.String() - if bun.Deployment.HealthReport == nil { - cliui.Error(inv.Stdout, "No deployment health report available!") - return - } - deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL) - if len(deployHealthSummary) > 0 { - cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...) + if bun.Deployment.HealthReport != nil { + deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL) + if len(deployHealthSummary) > 0 { + cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...) + } + } else { + cliui.Warn(inv.Stdout, "No deployment health report available.") } if bun.Network.Netcheck == nil { diff --git a/cli/support_test.go b/cli/support_test.go index 4587e52c60..14e017508b 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -132,12 +132,35 @@ func TestSupportBundle(t *testing.T) { assertBundleContents(t, path, true, false, []string{secretValue}) }) - t.Run("NoPrivilege", func(t *testing.T) { + t.Run("MemberCanGenerateBundle", func(t *testing.T) { t.Parallel() - inv, root := clitest.New(t, "support", "bundle", memberWorkspace.Workspace.Name, "--yes") + + d := t.TempDir() + path := filepath.Join(d, "bundle.zip") + inv, root := clitest.New(t, "support", "bundle", memberWorkspace.Workspace.Name, "--output-file", path, "--yes") clitest.SetupConfig(t, memberClient, root) err := inv.Run() - require.ErrorContains(t, err, "failed authorization check") + require.NoError(t, err) + r, err := zip.OpenReader(path) + require.NoError(t, err, "open zip file") + defer r.Close() + fileNames := make(map[string]struct{}, len(r.File)) + for _, f := range r.File { + fileNames[f.Name] = struct{}{} + } + // These should always be present in the zip structure, even if + // the content is null/empty for non-admin users. + for _, name := range []string{ + "deployment/buildinfo.json", + "deployment/config.json", + "workspace/workspace.json", + "logs.txt", + "cli_logs.txt", + "network/netcheck.json", + "network/interfaces.json", + } { + require.Contains(t, fileNames, name) + } }) // This ensures that the CLI does not panic when trying to generate a support bundle @@ -159,6 +182,10 @@ func TestSupportBundle(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Logf("received request: %s %s", r.Method, r.URL) switch r.URL.Path { + case "/api/v2/users/me": + resp := codersdk.User{} + w.WriteHeader(http.StatusOK) + assert.NoError(t, json.NewEncoder(w).Encode(resp)) case "/api/v2/authcheck": // Fake auth check resp := codersdk.AuthorizationResponse{ diff --git a/docs/support/support-bundle.md b/docs/support/support-bundle.md index 1741dbfb66..11bd7a4183 100644 --- a/docs/support/support-bundle.md +++ b/docs/support/support-bundle.md @@ -27,32 +27,32 @@ A brief overview of all files contained in the bundle is provided below: > Detailed descriptions of all the information available in the bundle is > out of scope, as support bundles are primarily intended for internal use. -| Filename | Description | -|-----------------------------------|------------------------------------------------------------------------------------------------------------| -| `agent/agent.json` | The agent used to connect to the workspace with environment variables stripped. | -| `agent/agent_magicsock.html` | The contents of the HTTP debug endpoint of the agent's Tailscale Wireguard connection. | -| `agent/client_magicsock.html` | The contents of the HTTP debug endpoint of the client's Tailscale Wireguard connection. | -| `agent/listening_ports.json` | The listening ports detected by the selected agent running in the workspace. | -| `agent/logs.txt` | The logs of the selected agent running in the workspace. | -| `agent/manifest.json` | The manifest of the selected agent with environment variables stripped. | -| `agent/startup_logs.txt` | Startup logs of the workspace agent. | -| `agent/prometheus.txt` | The contents of the agent's Prometheus endpoint. | -| `cli_logs.txt` | Logs from running the `coder support bundle` command. | -| `deployment/buildinfo.json` | Coder version and build information. | -| `deployment/config.json` | Deployment [configuration](../reference/api/general.md#get-deployment-config), with secret values removed. | -| `deployment/experiments.json` | Any [experiments](../reference/cli/server.md#--experiments) currently enabled for the deployment. | -| `deployment/health.json` | A snapshot of the [health status](../admin/monitoring/health-check.md) of the deployment. | -| `logs.txt` | Logs from the `codersdk.Client` used to generate the bundle. | -| `network/connection_info.json` | Information used by workspace agents used to connect to Coder (DERP map etc.) | -| `network/coordinator_debug.html` | Peers currently connected to each Coder instance and the tunnels established between peers. | -| `network/netcheck.json` | Results of running `coder netcheck` locally. | -| `network/tailnet_debug.html` | Tailnet coordinators, their heartbeat ages, connected peers, and tunnels. | -| `workspace/build_logs.txt` | Build logs of the selected workspace. | -| `workspace/workspace.json` | Details of the selected workspace. | -| `workspace/parameters.json` | Build parameters of the selected workspace. | -| `workspace/template.json` | The template currently in use by the selected workspace. | -| `workspace/template_file.zip` | The source code of the template currently in use by the selected workspace. | -| `workspace/template_version.json` | The template version currently in use by the selected workspace. | +| Filename | Description | +|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| +| `agent/agent.json` | The agent used to connect to the workspace with environment variables stripped. | +| `agent/agent_magicsock.html` | The contents of the HTTP debug endpoint of the agent's Tailscale Wireguard connection. | +| `agent/client_magicsock.html` | The contents of the HTTP debug endpoint of the client's Tailscale Wireguard connection. | +| `agent/listening_ports.json` | The listening ports detected by the selected agent running in the workspace. | +| `agent/logs.txt` | The logs of the selected agent running in the workspace. | +| `agent/manifest.json` | The manifest of the selected agent with environment variables stripped. | +| `agent/startup_logs.txt` | Startup logs of the workspace agent. | +| `agent/prometheus.txt` | The contents of the agent's Prometheus endpoint. | +| `cli_logs.txt` | Logs from running the `coder support bundle` command. | +| `deployment/buildinfo.json` | Coder version and build information. | +| `deployment/config.json` | Deployment [configuration](../reference/api/general.md#get-deployment-config), with secret values removed. *Requires Owner role.* | +| `deployment/experiments.json` | Any [experiments](../reference/cli/server.md#--experiments) currently enabled for the deployment. | +| `deployment/health.json` | A snapshot of the [health status](../admin/monitoring/health-check.md) of the deployment. *Requires Owner role.* | +| `logs.txt` | Logs from the `codersdk.Client` used to generate the bundle. | +| `network/connection_info.json` | Information used by workspace agents used to connect to Coder (DERP map etc.) | +| `network/coordinator_debug.html` | Peers currently connected to each Coder instance and the tunnels established between peers. *Requires Owner role.* | +| `network/netcheck.json` | Results of running `coder netcheck` locally. | +| `network/tailnet_debug.html` | Tailnet coordinators, their heartbeat ages, connected peers, and tunnels. *Requires Owner role.* | +| `workspace/build_logs.txt` | Build logs of the selected workspace. | +| `workspace/workspace.json` | Details of the selected workspace. | +| `workspace/parameters.json` | Build parameters of the selected workspace. | +| `workspace/template.json` | The template currently in use by the selected workspace. | +| `workspace/template_file.zip` | The source code of the template currently in use by the selected workspace. | +| `workspace/template_version.json` | The template version currently in use by the selected workspace. | ## How do I generate a Support Bundle? @@ -67,7 +67,10 @@ A brief overview of all files contained in the bundle is provided below: > experiencing workspace connectivity issues. 3. Ensure you are [logged in](../reference/cli/login.md#login) to your Coder - deployment as a user with the Owner privilege. + deployment. Any authenticated user can generate a support bundle. Users with + the Owner role will get the most complete bundle; non-admin users will still + get a useful bundle but some admin-only data will be omitted (see the note + below). 4. Run `coder support bundle [owner/workspace]`, and respond `yes` to the prompt. The support bundle will be generated in the current directory with diff --git a/support/support.go b/support/support.go index de490741b8..3c634dd9ac 100644 --- a/support/support.go +++ b/support/support.go @@ -163,6 +163,11 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge eg.Go(func() error { dc, err := client.DeploymentConfig(ctx) if err != nil { + if cerr, ok := codersdk.AsError(err); ok && (cerr.StatusCode() == http.StatusForbidden || cerr.StatusCode() == http.StatusUnauthorized) { + log.Warn(ctx, "unable to fetch deployment config", + slog.F("status", cerr.StatusCode())) + return nil + } return xerrors.Errorf("fetch deployment config: %w", err) } d.Config = dc @@ -172,6 +177,11 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge eg.Go(func() error { hr, err := healthsdk.New(client).DebugHealth(ctx) if err != nil { + if cerr, ok := codersdk.AsError(err); ok && (cerr.StatusCode() == http.StatusForbidden || cerr.StatusCode() == http.StatusUnauthorized) { + log.Warn(ctx, "unable to fetch health report", + slog.F("status", cerr.StatusCode())) + return nil + } return xerrors.Errorf("fetch health report: %w", err) } d.HealthReport = &hr @@ -365,6 +375,12 @@ func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) return xerrors.Errorf("fetch coordinator debug page: %w", err) } defer coordResp.Body.Close() + if coordResp.StatusCode == http.StatusForbidden || coordResp.StatusCode == http.StatusUnauthorized { + _, _ = io.Copy(io.Discard, coordResp.Body) + log.Warn(ctx, "unable to fetch coordinator debug page", + slog.F("status", coordResp.StatusCode)) + return nil + } bs, err := io.ReadAll(coordResp.Body) if err != nil { return xerrors.Errorf("read coordinator debug page: %w", err) @@ -379,6 +395,12 @@ func NetworkInfo(ctx context.Context, client *codersdk.Client, log slog.Logger) return xerrors.Errorf("fetch tailnet debug page: %w", err) } defer tailResp.Body.Close() + if tailResp.StatusCode == http.StatusForbidden || tailResp.StatusCode == http.StatusUnauthorized { + _, _ = io.Copy(io.Discard, tailResp.Body) + log.Warn(ctx, "unable to fetch tailnet debug page", + slog.F("status", tailResp.StatusCode)) + return nil + } bs, err := io.ReadAll(tailResp.Body) if err != nil { return xerrors.Errorf("read tailnet debug page: %w", err) @@ -976,11 +998,11 @@ func PprofInfoFromAgent(ctx context.Context, conn workspacesdk.AgentConn, log sl return &p } -// Run generates a support bundle with the given dependencies. -func Run(ctx context.Context, d *Deps) (*Bundle, error) { - var b Bundle - if d.Client == nil { - return nil, xerrors.Errorf("developer error: missing client!") +// CanGenerateFull checks if the user can generate a 'full' support bundle or +// only has permissions to generate a 'partial' bundle. +func CanGenerateFull(ctx context.Context, client *codersdk.Client) (bool, error) { + if client == nil { + return false, xerrors.Errorf("developer error: missing client!") } authChecks := map[string]codersdk.AuthorizationCheck{ @@ -992,6 +1014,28 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) { }, } + authResp, err := client.AuthCheck(ctx, codersdk.AuthorizationRequest{Checks: authChecks}) + if err != nil { + // If the auth check itself fails (e.g., 401 Unauthorized + // because there is no valid session), this is a hard error. + return false, xerrors.Errorf("check authorization: %w", err) + } + for _, v := range authResp { + if !v { // all checks must pass + return false, nil + } + } + + return true, nil +} + +// Run generates a support bundle with the given dependencies. +func Run(ctx context.Context, d *Deps) (*Bundle, error) { + var b Bundle + if d.Client == nil { + return nil, xerrors.Errorf("developer error: missing client!") + } + // Ensure we capture logs from the client. var logw strings.Builder d.Log = d.Log.AppendSinks(sloghuman.Sink(&logw)) @@ -1000,15 +1044,12 @@ func Run(ctx context.Context, d *Deps) (*Bundle, error) { b.Logs = strings.Split(logw.String(), "\n") }() - authResp, err := d.Client.AuthCheck(ctx, codersdk.AuthorizationRequest{Checks: authChecks}) + // No point running without auth as minimal information available. + me, err := d.Client.User(ctx, codersdk.Me) if err != nil { - return &b, xerrors.Errorf("check authorization: %w", err) - } - for k, v := range authResp { - if !v { - return &b, xerrors.Errorf("failed authorization check: cannot %s", k) - } + return nil, err } + d.Log.Info(ctx, "running as user", slog.F("me", me)) totalCap := d.WorkspacesTotalCap diff --git a/support/support_test.go b/support/support_test.go index cbb4783c36..e2c628b5c0 100644 --- a/support/support_test.go +++ b/support/support_test.go @@ -162,14 +162,12 @@ func TestRun(t *testing.T) { Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named("bundle").Leveled(slog.LevelDebug), }) var sdkErr *codersdk.Error - require.NotNil(t, bun) + require.Nil(t, bun) require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) - require.NotEmpty(t, bun) - require.NotEmpty(t, bun.Logs) }) - t.Run("MissingPrivilege", func(t *testing.T) { + t.Run("MemberNoWorkspace", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := coderdtest.New(t, &coderdtest.Options{ @@ -179,11 +177,25 @@ func TestRun(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) bun, err := support.Run(ctx, &support.Deps{ Client: memberClient, - Log: testutil.Logger(t).Named("bundle"), + Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Named("bundle").Leveled(slog.LevelDebug), }) - require.ErrorContains(t, err, "failed authorization check") - require.NotEmpty(t, bun) - require.NotEmpty(t, bun.Logs) + require.NoError(t, err) + require.NotNil(t, bun) + // Member should still get build info. + assertNotNilNotEmpty(t, bun.Deployment.BuildInfo, "deployment build info should be present") + // Experiments may be empty if none are configured, but should not error. + require.NotNil(t, bun.Deployment.Experiments, "deployment experiments should not be nil") + // Member should NOT get admin-only deployment information. + assert.Nil(t, bun.Deployment.Config, "member should not see deployment config") + assert.Nil(t, bun.Deployment.HealthReport, "member should not see health report") + // Member should still get network info from client-side checks. + assertNotNilNotEmpty(t, bun.Network.ConnectionInfo, "agent connection info should be present") + assertNotNilNotEmpty(t, bun.Network.Interfaces, "network interfaces should be present") + assertNotNilNotEmpty(t, bun.Network.Netcheck, "network netcheck should be present") + // No workspace specified. + assert.Empty(t, bun.Workspace.Workspace, "did not expect workspace to be present") + assert.Empty(t, bun.Agent, "did not expect agent to be present") + assertNotNilNotEmpty(t, bun.Logs, "bundle logs should be present") }) }