mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: allow member users to generate support bundles (#23040)
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. <details> <summary> Changes </summary> ### `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
This commit is contained in:
+26
-17
@@ -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 {
|
||||
|
||||
+30
-3
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user