mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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:
+53
-12
@@ -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
|
||||
|
||||
|
||||
+20
-8
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user