From bf58e8a402e52c937218c4c7e7fcd68121b3e47c Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Mon, 6 Jul 2026 13:04:48 -0700 Subject: [PATCH] fix(coderd): require deployment-wide workspace read permissions for WatchAllWorkspaceBuilds endpoint (#26985) --- coderd/workspaces.go | 14 +++++++++++++ coderd/workspaces_test.go | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 9d429ccbf5..4fd4ed6d1c 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2249,6 +2249,20 @@ func (api *API) watchWorkspace( func (api *API) watchAllWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + // This endpoint streams build events for every workspace in the deployment + // with no per-item filtering, so require deployment-wide read on workspaces. + // Without this, any authenticated user could enumerate all workspace names + // and observe build activity across organizations they do not belong to. + if !api.Authorize(r, policy.ActionRead, rbac.ResourceWorkspace.All()) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "You are not authorized to watch all workspace builds.", + Detail: "This requires permission to read all workspaces, which is granted by the " + + "Owner or Template Admin role. Please contact an administrator about your " + + "permissions if you feel this is an error.", + }) + return + } + // Buffer enough updates to avoid blocking the pubsub callback while we're // accepting the WebSocket connection. Accepting the connection signals to // the client that the server is subscribed and ready to forward events. diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 20284c4bbf..2c4627d366 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4079,6 +4079,49 @@ func TestWatchAllWorkspaceBuilds(t *testing.T) { require.Equal(t, workspace2.ID, update.WorkspaceID) } +func TestWatchAllWorkspaceBuildsAuthorization(t *testing.T) { + t.Parallel() + + // Enable the workspace build updates experiment. + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceBuildUpdates)} + }), + }) + owner := coderdtest.CreateFirstUser(t, client) + + t.Run("MemberForbidden", func(t *testing.T) { + t.Parallel() + + // A plain member has no deployment-wide workspace read permission and + // must not be able to open the all-builds stream. + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := memberClient.WatchAllWorkspaceBuilds(ctx) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) + + t.Run("TemplateAdminAllowed", func(t *testing.T) { + t.Parallel() + + // Template admins have site-wide workspace read and may open the stream. + taClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + decoder, err := taClient.WatchAllWorkspaceBuilds(ctx) + require.NoError(t, err) + defer decoder.Close() + }) +} + func mustLocation(t *testing.T, location string) *time.Location { t.Helper() loc, err := time.LoadLocation(location)