fix(coderd): reject agent requests from suspended owners (#28513)

## What

`ExtractWorkspaceAgentAndLatestBuild` computed the workspace owner's
RBAC subject via `UserRBACSubject`, but discarded the returned user
status. As a result, workspace agent requests were still authorized even
when the owner's account was suspended.

This change captures `userStatus` and rejects the request with `401
Unauthorized` when the owner is not `Active`, returning a clear message
directing the user to contact an admin to reactivate the account.

## Changes

- `coderd/httpmw/workspaceagent.go`: stop discarding the owner status
from `UserRBACSubject`; return `401` when the owner is not active.
- `coderd/httpmw/workspaceagent_test.go`: add an `InactiveUser` test
that suspends the owner and asserts a `401` with the expected message.

## Testing

- New `TestWorkspaceAgent/InactiveUser` unit test covers the
suspended-owner path.

---

> This PR was created by Coder Agents on behalf of @hwang251.
This commit is contained in:
Hank Hwang
2026-08-24 15:25:21 -07:00
committed by GitHub
parent c029869a04
commit 2f50b2d740
2 changed files with 42 additions and 1 deletions
+7 -1
View File
@@ -109,7 +109,7 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil
return
}
subject, _, err := UserRBACSubject(
subject, userStatus, err := UserRBACSubject(
ctx,
opts.DB,
row.WorkspaceTable.OwnerID,
@@ -129,6 +129,12 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil
})
return
}
if userStatus != database.UserStatusActive {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", userStatus),
})
return
}
ctx = context.WithValue(ctx, workspaceAgentContextKey{}, row.WorkspaceAgent)
ctx = context.WithValue(ctx, latestBuildContextKey{}, row.WorkspaceBuild)
+35
View File
@@ -1,7 +1,10 @@
package httpmw_test
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -61,6 +64,38 @@ func TestWorkspaceAgent(t *testing.T) {
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("InactiveUser", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
authToken := uuid.New()
req, rtr, workspace, _ := setup(t, db, authToken, httpmw.ExtractWorkspaceAgentAndLatestBuild(
httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{
DB: db,
Optional: false,
}),
)
_, err := db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{
ID: workspace.OwnerID,
Status: database.UserStatusSuspended,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
rw := httptest.NewRecorder()
req.Header.Set(codersdk.SessionTokenHeader, authToken.String())
rtr.ServeHTTP(rw, req)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
var response codersdk.Response
require.NoError(t, json.Unmarshal(body, &response))
require.Contains(t, response.Message, `User is not active (status = "suspended")`)
})
t.Run("Latest", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)