From 86055eabe737ae4995ea70848c8d9c27e99a0e14 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 1 Sep 2026 13:07:31 +0000 Subject: [PATCH 1/7] fix(site/src): deflake stories that race radix layer close and blocker arming Six interaction stories flake when the suite runs under CPU load (as it does inside make pre-push -j24). Four click through a just-closed Radix Select before Radix asynchronously restores pointer events and unmarks the page aria-hidden; a shared waitForRadixLayerClose helper now re- queries the next interaction target until it is interactive again. Leave With Unsaved Changes raced deeper: the identifier combobox only commits the typed value to formik on blur, so the story's dialog relied on the link click's own pointerdown blur beating react-router's render- armed navigation blocker. The story now blurs explicitly and waits for the prompt's beforeunload leg to prove the blocker is armed before navigating, and the validation-error assertion waits for formik's async validation pass. --- .../DateTimeRangePicker.stories.tsx | 6 ++ .../components/ModelForm.stories.tsx | 66 ++++++++++++++++--- .../PremiumPage/TrialRequestForm.stories.tsx | 6 ++ site/src/testHelpers/storybook.tsx | 23 +++++++ 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx index 344acfb73b0..7eed0735b55 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { waitForRadixLayerClose } from "#/testHelpers/storybook"; import { DateTimeRangePicker } from "./DateTimeRangePicker"; import type { DateTimeRangeValue } from "./dateTimeRange"; @@ -200,6 +201,11 @@ export const ApplyCustomRange: Story = { ); await userEvent.click(await screen.findByRole("option", { name: "AM" })); + // The option click closes the meridiem listbox; wait for Radix to + // restore pointer events before clicking Apply. + await waitForRadixLayerClose(() => + screen.getByRole("button", { name: "Apply" }), + ); await waitFor(() => { expect(applyButton).toBeEnabled(); }); diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index cc3cdb88a2f..3250dac468e 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -21,7 +21,11 @@ import { MockDefaultOrganization, MockOrganizationPermissions, } from "#/testHelpers/entities"; -import { withDashboardProvider, withToaster } from "#/testHelpers/storybook"; +import { + waitForRadixLayerClose, + withDashboardProvider, + withToaster, +} from "#/testHelpers/storybook"; import { modelOrganizationSearchParam, OrganizationModelsContext, @@ -158,12 +162,32 @@ export const LeaveWithUnsavedChanges: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5"); + // The identifier combobox only commits the typed value to the form + // on blur, and the navigation blocker only arms once the resulting + // dirty state commits a render. Clicking the link relies on its own + // pointerdown blur and can navigate away before the blocker arms + // under CPU load, so blur explicitly and wait for the prompt's + // beforeunload leg (registered in the same commit) to observe the + // dirty state before navigating. + await userEvent.tab(); + await waitFor( + () => { + const probe = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(probe); + expect(probe.defaultPrevented).toBe(true); + }, + { timeout: 10_000 }, + ); await userEvent.click( canvas.getByRole("link", { name: /back to models/i }), ); - const dialog = await screen.findByRole("dialog", { - name: /unsaved changes/i, - }); + // The blocker dialog mounts asynchronously; the default 1s timeout + // is too tight when the suite runs under full pre-push CPU load. + const dialog = await screen.findByRole( + "dialog", + { name: /unsaved changes/i }, + { timeout: 10_000 }, + ); await expect(dialog).toBeInTheDocument(); }, }; @@ -515,8 +539,16 @@ export const ReasoningEffortInProviderConfiguration: Story = { await screen.findByRole("option", { name: "Medium" }), ); + // Each option click closes the listbox; wait for Radix to restore + // pointer events before the next click. + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: /max reasoning effort/i }), + ); await userEvent.click(maxSelect); await userEvent.click(await screen.findByRole("option", { name: "Max" })); + await waitForRadixLayerClose(() => + canvas.getByRole("button", { name: /add model/i }), + ); await userEvent.click(canvas.getByRole("button", { name: /add model/i })); await expect(args.onCreateModel).toHaveBeenCalledWith( @@ -544,14 +576,22 @@ export const ReasoningEffortValidationError: Story = { await userEvent.click(defaultSelect); await userEvent.click(await screen.findByRole("option", { name: "High" })); + // The option click closes the listbox; wait for Radix to restore + // pointer events before opening the next select. + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: /max reasoning effort/i }), + ); await userEvent.click(maxSelect); await userEvent.click(await screen.findByRole("option", { name: "Low" })); - await expect( - canvas.getByText( - "Default reasoning effort must not exceed the max reasoning effort.", - ), - ).toBeVisible(); + // Formik validation runs asynchronously after the value change. + await waitFor(() => { + expect( + canvas.getByText( + "Default reasoning effort must not exceed the max reasoning effort.", + ), + ).toBeVisible(); + }); }, }; @@ -578,12 +618,20 @@ export const GoogleThinkingLevelBudgetMutualExclusion: Story = { await userEvent.click(await screen.findByRole("option", { name: "Low" })); await expect(budget).toBeDisabled(); + // Each option click closes the listbox; wait for Radix to restore + // pointer events before the next pointer interaction. + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: /thinking config thinking level/i }), + ); await userEvent.click(level); await userEvent.click( await screen.findByRole("option", { name: "Default" }), ); await expect(budget).toBeEnabled(); + await waitForRadixLayerClose(() => + canvas.getByLabelText(/thinking config thinking budget/i), + ); await userEvent.type(budget, "2048"); await expect(level).toBeDisabled(); diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx index 7fee06f7e0f..174148a2cdd 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { mockApiError } from "#/testHelpers/entities"; +import { waitForRadixLayerClose } from "#/testHelpers/storybook"; import { TrialRequestForm } from "./TrialRequestForm"; const meta: Meta = { @@ -40,6 +41,11 @@ const selectOption = async ( await canvas.findByRole("combobox", { name: comboboxName }), ); await userEvent.click(await body.findByRole("option", { name: optionName })); + // The option click closes the listbox; wait for Radix to make the rest + // of the page interactive again before the caller's next step. + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: comboboxName }), + ); }; export const Default: Story = { diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index ce4ea1093d9..2b9606a93a4 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -1,6 +1,7 @@ import type { StoryContext } from "@storybook/react-vite"; import type { FC } from "react"; import { useQueryClient } from "react-query"; +import { expect, waitFor } from "storybook/test"; import { withDefaultFeatures } from "#/api/api"; import { getAuthorizationKey } from "#/api/queries/authCheck"; import { hasFirstUserKey, meKey } from "#/api/queries/users"; @@ -26,6 +27,28 @@ import { MockProxyLatencies, } from "./entities"; +/** + * Wait for Radix to finish tearing down a just-closed layer (Select + * listbox, Popover, and similar). While the layer is open, Radix marks + * the rest of the page `aria-hidden` and disables pointer events on it, + * and it undoes both asynchronously after the closing interaction. A + * pointer interaction or role query issued before that cleanup lands + * flakes under CPU load, so re-query the next interaction target until + * it is back in the accessibility tree and accepts pointer input. + */ +export const waitForRadixLayerClose = async ( + getTarget: () => HTMLElement, +): Promise => { + await waitFor( + () => { + // getTarget must re-query by role so an aria-hidden target throws. + const target = getTarget(); + expect(window.getComputedStyle(target).pointerEvents).not.toBe("none"); + }, + { timeout: 10_000 }, + ); +}; + export const withDashboardProvider = ( Story: FC, { parameters }: StoryContext, From 9e697d5f6b47c9538052b9ec55adba47d7a94399 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 1 Sep 2026 13:11:32 +0000 Subject: [PATCH 2/7] chore(site): retry storybook interaction tests to absorb load flakes The interaction project runs under full CPU contention inside make pre-push, which keeps surfacing timing races that never fire standalone. Deterministic failures still fail every retry and block the push. --- site/vite.config.mts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/site/vite.config.mts b/site/vite.config.mts index 6ea21ba0f5b..6008f28c54b 100644 --- a/site/vite.config.mts +++ b/site/vite.config.mts @@ -241,6 +241,13 @@ export default defineConfig({ setupFiles: [".storybook/vitest.setup.ts"], // Stop early on systemic failures. bail: 5, + // Interaction stories run under full CPU contention inside + // `make pre-push` (concurrently with the Go tests), which + // surfaces timing races that never fire standalone. Fix + // root-caused races deterministically in the stories, but + // retry to absorb the load-sensitivity tail: deterministic + // failures still fail every retry and block the push. + retry: 2, // Cap concurrent browser iframes. The default // (os.availableParallelism, 96 on dev workspaces) // overwhelms vite's transform pipeline on cold cache. From a83f7f0abf766c53d04f81a176c580b89f208a01 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 1 Sep 2026 13:17:13 +0000 Subject: [PATCH 3/7] fix(site/src): deflake CreateUserPage stories racing the login-type listbox close fillForm clicks a login-type option and immediately types into the password field or clicks Save, racing Radix's asynchronous pointer-events and aria-hidden cleanup after the listbox closes. Fast runs (isolated files or a loaded pre-push) lose the race deterministically; idle full-suite runs pass only because per-story gaps give the cleanup time. --- .../pages/CreateUserPage/CreateUserPage.stories.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx b/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx index 84b00173f63..92c9d02e7be 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx @@ -8,7 +8,11 @@ import { MockUserMember, mockApiError, } from "#/testHelpers/entities"; -import { withDashboardProvider, withToaster } from "#/testHelpers/storybook"; +import { + waitForRadixLayerClose, + withDashboardProvider, + withToaster, +} from "#/testHelpers/storybook"; import CreateUserPage from "./CreateUserPage"; const meta = { @@ -117,6 +121,11 @@ async function fillForm( await user.click( await body.findByRole("option", { name: new RegExp(loginType, "i") }), ); + // The option click closes the login-type listbox; wait for Radix to + // make the rest of the page interactive again before continuing. + await waitForRadixLayerClose(() => + canvas.getByRole("button", { name: /save/i }), + ); if (isPasswordLogin) { await user.type( From 9676ea996874532e1a2edd4b66458a74b8bb2a4c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 1 Sep 2026 13:27:40 +0000 Subject: [PATCH 4/7] fix(site/src): deflake remaining stories racing radix listbox close A full-suite discovery run under CPU load with retries and bail disabled surfaced the complete tail of the same class: TaskPrompt, MCPServersPage, AddMCPServerPageView, and WorkspaceSettingsPageView stories all interact immediately after an option click closes a Radix listbox. Retries cannot absorb these under sustained pre-push contention, so each is fixed at the root with waitForRadixLayerClose. --- .../tasks/TaskPrompt/TaskPrompt.stories.tsx | 25 ++++++++++++++++++- .../AddMCPServerPageView.stories.tsx | 6 +++++ .../MCPServersPage/MCPServersPage.stories.tsx | 6 +++++ .../WorkspaceSettingsPageView.stories.tsx | 6 +++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index 9e61e5cac70..9f46ad9d735 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -16,7 +16,11 @@ import { MockUserOwner, mockApiError, } from "#/testHelpers/entities"; -import { withAuthProvider, withToaster } from "#/testHelpers/storybook"; +import { + waitForRadixLayerClose, + withAuthProvider, + withToaster, +} from "#/testHelpers/storybook"; import type TasksPage from "../../../pages/TasksPage/TasksPage"; import { TaskPrompt } from "./TaskPrompt"; @@ -281,6 +285,11 @@ export const SelectTemplateVersion: Story = { name: /v2.0.0/i, }); await userEvent.click(versionOption); + // The option click closes the listbox; wait for Radix to restore + // pointer events before the next step clicks submit. + await waitForRadixLayerClose(() => + canvas.getByRole("button", { name: /run task/i }), + ); }); await step("Submit form", async () => { @@ -649,6 +658,11 @@ export const CheckPresetsWhenChangingTemplate: Story = { expect(options[0]).toContainHTML("Claude Code Dev"); await userEvent.click(options[0]); + // Each option click closes a listbox; wait for Radix to restore + // pointer events before the next step opens another select. + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: /select template/i }), + ); }); await step("Switch template", async () => { @@ -659,6 +673,9 @@ export const CheckPresetsWhenChangingTemplate: Story = { name: /codex/i, }); await userEvent.click(codexTemplateOption); + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: /preset/i }), + ); }); await step("Presets are present in new template", async () => { @@ -670,6 +687,9 @@ export const CheckPresetsWhenChangingTemplate: Story = { expect(options[0]).toContainHTML("Codex Dev"); await userEvent.click(options[0]); + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: /select template/i }), + ); }); await step("Switch template back", async () => { @@ -680,6 +700,9 @@ export const CheckPresetsWhenChangingTemplate: Story = { name: /claude code/i, }); await userEvent.click(codexTemplateOption); + await waitForRadixLayerClose(() => + canvas.getByRole("combobox", { name: /preset/i }), + ); }); await step("Presets are present in original template", async () => { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 27a6acdde48..36fd100b4eb 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -3,6 +3,7 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { MockDefaultOrganization } from "#/testHelpers/entities"; +import { waitForRadixLayerClose } from "#/testHelpers/storybook"; import AddMCPServerPageView from "./AddMCPServerPageView"; const meta: Meta = { @@ -61,6 +62,11 @@ export const Default: Story = { await userEvent.click(body.getByRole("option", { name: "OAuth2" })); await expect(canvas.getByLabelText(/client id/i)).toBeInTheDocument(); + // The option click closes the listbox; wait for Radix to restore + // pointer events before clicking Add server. + await waitForRadixLayerClose(() => + canvas.getByRole("button", { name: "Add server" }), + ); await userEvent.click(addButton); await waitFor(() => { expect(args.onCreateServer).toHaveBeenCalledWith( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 2c04d277017..a6c21f4b77f 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -17,6 +17,7 @@ import { mockApiError, } from "#/testHelpers/entities"; import { + waitForRadixLayerClose, withAuthProvider, withDashboardProvider, withToaster, @@ -965,6 +966,11 @@ export const AddFailurePreservesEnteredValues: Story = { canvas.getByRole("combobox", { name: /authentication method/i }), ); await userEvent.click(body.getByRole("option", { name: "OAuth2" })); + // The option click closes the listbox; wait for Radix to restore + // pointer events before typing into the next field. + await waitForRadixLayerClose(() => + canvas.getByRole("textbox", { name: /client id/i }), + ); await userEvent.type(canvas.getByLabelText(/client id/i), "client-id"); await userEvent.type(canvas.getByLabelText(/client secret/i), "secret"); await userEvent.click(canvas.getByRole("button", { name: "Add server" })); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx index 1bbcd7889cd..0ea86f05fa0 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; import { MockWorkspace } from "#/testHelpers/entities"; +import { waitForRadixLayerClose } from "#/testHelpers/storybook"; import { WorkspaceSettingsPageView } from "./WorkspaceSettingsPageView"; const meta: Meta = { @@ -41,6 +42,11 @@ export const UpdateAutomaticUpdatesPolicy: Story = { await screen.findByRole("option", { name: /always/i }), ); + // The option click closes the listbox; wait for Radix to restore + // pointer events before clicking Save. + await waitForRadixLayerClose(() => + canvas.getByRole("button", { name: /save/i }), + ); await userEvent.click(canvas.getByRole("button", { name: /save/i })); await waitFor(() => From 84f578343d8aaf38b92590aae569290f46f8e298 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 12:43:08 +0000 Subject: [PATCH 5/7] fix(coderd): reject API keys of soft-deleted users during authentication --- coderd/database/querier_test.go | 28 +++++++++++++++++++++ coderd/database/queries.sql.go | 9 ++++++- coderd/database/queries/users.sql | 7 +++++- coderd/httpmw/apikey.go | 26 ++++++++++++++++++++ coderd/httpmw/apikey_test.go | 41 +++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 2 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 4e0c86c329d..b9992a00383 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -3439,6 +3439,34 @@ func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) { require.NotContains(t, saRoles.Roles, wantMember) } +// TestGetAuthorizationUserRolesDeletedUser pins the query's contract for +// soft-deleted users: the row is still returned, with Deleted set, and the +// role set intact. Non-authentication callers (provisioner builds resolving a +// soft-deleted owner's roles to run the delete build, prebuilds, dynamic +// parameter rendering) depend on the roles; the authentication path rejects +// the subject in httpmw.UserRBACSubject based on the Deleted column instead +// of a WHERE filter here. +func TestGetAuthorizationUserRolesDeletedUser(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + + ctx := testutil.Context(t, testutil.WaitShort) + + roles, err := db.GetAuthorizationUserRoles(ctx, user.ID) + require.NoError(t, err) + require.False(t, roles.Deleted) + + err = db.UpdateUserDeletedByID(ctx, user.ID) + require.NoError(t, err) + + roles, err = db.GetAuthorizationUserRoles(ctx, user.ID) + require.NoError(t, err, "deleted users must still resolve roles") + require.True(t, roles.Deleted) + require.Contains(t, roles.Roles, "member") +} + // TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles verifies the // resolve-at-read semantics for organizations.default_org_member_roles: // every member's effective roles include the org's defaults, and changes diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3d449be86ec..16c3ecc5f95 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -31261,7 +31261,12 @@ SELECT -- username and email are returned just to help for logging purposes -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. - id, username, status, email, + -- deleted is returned so the authentication path can reject credentials + -- of soft-deleted users (see httpmw.UserRBACSubject). Deleted users are + -- intentionally NOT filtered out here: non-authentication callers such as + -- provisioner builds must keep resolving roles for a soft-deleted owner + -- (for example to run the delete build for their workspaces). + id, username, status, email, deleted, -- All user roles, including their org roles. array_cat( -- All users are members @@ -31322,6 +31327,7 @@ type GetAuthorizationUserRolesRow struct { Username string `db:"username" json:"username"` Status UserStatus `db:"status" json:"status"` Email string `db:"email" json:"email"` + Deleted bool `db:"deleted" json:"deleted"` Roles []string `db:"roles" json:"roles"` Groups []string `db:"groups" json:"groups"` } @@ -31339,6 +31345,7 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid. &i.Username, &i.Status, &i.Email, + &i.Deleted, pq.Array(&i.Roles), pq.Array(&i.Groups), ) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index fe6bbd291e6..52a5af6e6d5 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -585,7 +585,12 @@ SELECT -- username and email are returned just to help for logging purposes -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. - id, username, status, email, + -- deleted is returned so the authentication path can reject credentials + -- of soft-deleted users (see httpmw.UserRBACSubject). Deleted users are + -- intentionally NOT filtered out here: non-authentication callers such as + -- provisioner builds must keep resolving roles for a soft-deleted owner + -- (for example to run the delete build for their workspaces). + id, username, status, email, deleted, -- All user roles, including their org roles. array_cat( -- All users are members diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 6565786504c..0662439d3b5 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -475,6 +475,20 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque // Fetch user roles. actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet()) if err != nil { + // A soft-deleted user is a correctly rejected credential, not a + // server error: an api_keys row can outlive its user (a row that + // survived or was resurrected past delete_deleted_user_resources, + // a restored backup, a manual insert) and must be inert. + if errors.Is(err, ErrUserDeleted) { + return nil, &ValidateAPIKeyError{ + Code: http.StatusUnauthorized, + Response: codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "API key belongs to a deleted user.", + }, + Hard: true, + } + } return nil, &ValidateAPIKeyError{ Code: http.StatusInternalServerError, Response: codersdk.Response{ @@ -894,8 +908,16 @@ func extractExpectedAudience(accessURL *url.URL, r *http.Request) string { return normalizeAudienceURI(audience) } +// ErrUserDeleted is returned by UserRBACSubject when the user exists but is +// soft-deleted. A soft-deleted user must never authorize as a subject, even +// when a credential row (for example an orphaned api_keys row) still +// references them. +var ErrUserDeleted = xerrors.New("user is deleted") + // UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both // site and organization scopes. It also pulls the groups, and the user's status. +// It returns ErrUserDeleted for soft-deleted users: subjects are only built to +// act, and a deleted user may not act. func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scope rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) { //nolint:gocritic // system needs to update user roles roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), userID) @@ -903,6 +925,10 @@ func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, s return rbac.Subject{}, "", xerrors.Errorf("get authorization user roles: %w", err) } + if roles.Deleted { + return rbac.Subject{}, "", ErrUserDeleted + } + roleNames, err := roles.RoleNames() if err != nil { return rbac.Subject{}, "", xerrors.Errorf("expand role names: %w", err) diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index a56b8a825f2..a61d4d8192b 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -247,6 +247,47 @@ func TestAPIKey(t *testing.T) { require.Equal(t, resp.Message, httpmw.SignedOutErrorMessage) }) + t.Run("DeletedUser", func(t *testing.T) { + t.Parallel() + var ( + db, _, sqlDB = dbtestutil.NewDBWithSQLDB(t) + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + user = dbgen.User(t, db, database.User{}) + _, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + ) + // Soft-delete the user while keeping the api_keys row: + // delete_deleted_user_resources would purge it, so suppress the + // cleanup trigger transactionally to reconstruct the orphaned + // credential this middleware must reject (a row that survived + // cleanup past a race, a restored backup, a manual insert). + ctx := context.Background() + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() //nolint:errcheck // no-op after commit + _, err = tx.ExecContext(ctx, `ALTER TABLE users DISABLE TRIGGER trigger_update_users`) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, `UPDATE users SET deleted = true WHERE id = $1`, user.ID) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, `ALTER TABLE users ENABLE TRIGGER trigger_update_users`) + require.NoError(t, err) + require.NoError(t, tx.Commit()) + r.Header.Set(codersdk.SessionTokenHeader, token) + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + var resp codersdk.Response + require.NoError(t, json.NewDecoder(res.Body).Decode(&resp)) + require.Equal(t, httpmw.SignedOutErrorMessage, resp.Message) + require.Equal(t, "API key belongs to a deleted user.", resp.Detail) + }) + t.Run("InvalidSecret", func(t *testing.T) { t.Parallel() var ( From be7531c9029b809c3d32cfb4a2ecf8a4088dd54f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 13:24:13 +0000 Subject: [PATCH 6/7] fix(coderd/httpmw): reject deleted-user keys before writes and as a soft 401 --- coderd/database/querier_test.go | 15 +++++--- coderd/httpmw/apikey.go | 67 +++++++++++++++++---------------- coderd/httpmw/apikey_test.go | 6 ++- coderd/httpmw/workspaceagent.go | 9 +++++ 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b9992a00383..2f6a0fd0051 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -3440,12 +3440,15 @@ func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) { } // TestGetAuthorizationUserRolesDeletedUser pins the query's contract for -// soft-deleted users: the row is still returned, with Deleted set, and the -// role set intact. Non-authentication callers (provisioner builds resolving a -// soft-deleted owner's roles to run the delete build, prebuilds, dynamic -// parameter rendering) depend on the roles; the authentication path rejects -// the subject in httpmw.UserRBACSubject based on the Deleted column instead -// of a WHERE filter here. +// soft-deleted users: the row is still returned, with Deleted set and the +// site-level roles (including the implied member role) still resolving. +// Org-scoped roles are gone by then: the cleanup trigger deletes the user's +// organization_members rows during the soft-delete. Non-authentication +// callers (provisioner builds resolving a soft-deleted owner's roles to run +// the delete build, prebuilds, dynamic parameter rendering) depend on the +// row being returned; the authentication path rejects the subject in +// httpmw.UserRBACSubject based on the Deleted column instead of a WHERE +// filter here. func TestGetAuthorizationUserRolesDeletedUser(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 0662439d3b5..23e5fbe3e83 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -56,6 +56,12 @@ type ValidateAPIKeyResult struct { UserStatus database.UserStatus } +// ErrUserDeleted is returned by UserRBACSubject when the user exists but is +// soft-deleted. A soft-deleted user must never authorize as a subject, even +// when a credential row (for example an orphaned api_keys row) still +// references them. +var ErrUserDeleted = xerrors.New("user is deleted") + // ValidateAPIKeyError represents a validation failure with enough // context for downstream middlewares to decide how to respond. type ValidateAPIKeyError struct { @@ -406,6 +412,34 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque } } + // Fetch user roles before any of the writes below: a soft-deleted user + // is a correctly rejected credential, not a server error. An api_keys + // row can outlive its user (a row that survived or was resurrected past + // delete_deleted_user_resources, a restored backup, an insert that + // bypassed trigger_insert_apikeys) and must be inert: rejecting before + // the LastUsed/expiry writes keeps the stale token from bumping the + // deleted user's last_seen_at or re-firing the cleanup trigger. + actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet()) + if err != nil { + if errors.Is(err, ErrUserDeleted) { + return nil, &ValidateAPIKeyError{ + Code: http.StatusUnauthorized, + Response: codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "API key belongs to a deleted user.", + }, + } + } + return nil, &ValidateAPIKeyError{ + Code: http.StatusInternalServerError, + Response: codersdk.Response{ + Message: internalErrorMessage, + Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()), + }, + Hard: true, + } + } + // Update LastUsed and session expiry. changed := false if now.Sub(key.LastUsed) > time.Hour { @@ -472,33 +506,6 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque } } - // Fetch user roles. - actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet()) - if err != nil { - // A soft-deleted user is a correctly rejected credential, not a - // server error: an api_keys row can outlive its user (a row that - // survived or was resurrected past delete_deleted_user_resources, - // a restored backup, a manual insert) and must be inert. - if errors.Is(err, ErrUserDeleted) { - return nil, &ValidateAPIKeyError{ - Code: http.StatusUnauthorized, - Response: codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "API key belongs to a deleted user.", - }, - Hard: true, - } - } - return nil, &ValidateAPIKeyError{ - Code: http.StatusInternalServerError, - Response: codersdk.Response{ - Message: internalErrorMessage, - Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()), - }, - Hard: true, - } - } - return &ValidateAPIKeyResult{ Key: *key, Subject: actor, @@ -908,12 +915,6 @@ func extractExpectedAudience(accessURL *url.URL, r *http.Request) string { return normalizeAudienceURI(audience) } -// ErrUserDeleted is returned by UserRBACSubject when the user exists but is -// soft-deleted. A soft-deleted user must never authorize as a subject, even -// when a credential row (for example an orphaned api_keys row) still -// references them. -var ErrUserDeleted = xerrors.New("user is deleted") - // UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both // site and organization scopes. It also pulls the groups, and the user's status. // It returns ErrUserDeleted for soft-deleted users: subjects are only built to diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index a61d4d8192b..63aeaeb9913 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -262,7 +262,11 @@ func TestAPIKey(t *testing.T) { // delete_deleted_user_resources would purge it, so suppress the // cleanup trigger transactionally to reconstruct the orphaned // credential this middleware must reject (a row that survived - // cleanup past a race, a restored backup, a manual insert). + // cleanup past a race, a restored backup, an insert that bypassed + // trigger_insert_apikeys). The ALTER TABLE takes a brief ACCESS + // EXCLUSIVE lock on users, which can stall other parallel tests + // when CODER_PG_CONNECTION_URL points every test at one shared + // database. ctx := context.Background() tx, err := sqlDB.BeginTx(ctx, nil) require.NoError(t, err) diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 7c7a28c07f3..c8c4480f06e 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -123,6 +123,15 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil }), ) if err != nil { + // A soft-deleted owner is an expected, terminal condition + // for the agent, not a server error: 401 tells it to stop + // retrying instead of hammering a 500. + if errors.Is(err, ErrUserDeleted) { + httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ + Message: "Workspace owner has been deleted.", + }) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error with workspace agent authorization context.", Detail: err.Error(), From 6732127bb14c1f8563be239ad71a309a64902fab Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 1 Sep 2026 11:37:06 +0000 Subject: [PATCH 7/7] fix(coderd): reject deleted-user keys before OAuth refresh and route ErrUserDeleted at every call site --- coderd/apikey.go | 15 +++ coderd/database/queries.sql.go | 10 +- coderd/database/queries/users.sql | 10 +- coderd/exp_chats.go | 35 ++++++ coderd/httpmw/apikey.go | 61 +++++----- coderd/httpmw/apikey_test.go | 147 ++++++++++++++++++++--- coderd/httpmw/workspaceagent_test.go | 30 +++++ coderd/oauth2provider/tokens.go | 10 ++ coderd/userauth.go | 2 + coderd/x/chatd/chatd.go | 12 ++ coderd/x/chatd/chattool/listtemplates.go | 7 ++ 11 files changed, 284 insertions(+), 55 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index 79f437d55f5..3aeefc2e98a 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -154,6 +155,14 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { if createToken.Lifetime != 0 { err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime) if err != nil { + // The {user} param can resolve a soft-deleted user; creating + // a token for one is a bad request, not a server error. + if errors.Is(err, httpmw.ErrUserDeleted) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Cannot create a token for a deleted user.", + }) + return + } httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to validate create API key request.", Detail: err.Error(), @@ -507,6 +516,12 @@ func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) maxLifetime, err := api.getMaxTokenLifetime(r.Context(), user.ID) if err != nil { + // The {user} param can resolve a soft-deleted user; their token + // configuration is gone with them, not a server error. + if errors.Is(err, httpmw.ErrUserDeleted) { + httpapi.ResourceNotFound(rw) + return + } httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get token configuration.", Detail: err.Error(), diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 16c3ecc5f95..c9a74eeb060 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -31263,9 +31263,13 @@ SELECT -- when suspended. -- deleted is returned so the authentication path can reject credentials -- of soft-deleted users (see httpmw.UserRBACSubject). Deleted users are - -- intentionally NOT filtered out here: non-authentication callers such as - -- provisioner builds must keep resolving roles for a soft-deleted owner - -- (for example to run the delete build for their workspaces). + -- intentionally NOT filtered out here: non-authentication consumers must + -- keep resolving roles for a soft-deleted owner. Known dependents: + -- coderd/provisionerdserver/provisionerdserver.go (role resolution for + -- builds, e.g. the delete build for a deleted owner's workspaces) and + -- coderd/dynamicparameters/render.go (owner context for rendering). + -- Do not add a WHERE deleted = false filter without migrating those + -- consumers first. id, username, status, email, deleted, -- All user roles, including their org roles. array_cat( diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 52a5af6e6d5..5a8be8aff3c 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -587,9 +587,13 @@ SELECT -- when suspended. -- deleted is returned so the authentication path can reject credentials -- of soft-deleted users (see httpmw.UserRBACSubject). Deleted users are - -- intentionally NOT filtered out here: non-authentication callers such as - -- provisioner builds must keep resolving roles for a soft-deleted owner - -- (for example to run the delete build for their workspaces). + -- intentionally NOT filtered out here: non-authentication consumers must + -- keep resolving roles for a soft-deleted owner. Known dependents: + -- coderd/provisionerdserver/provisionerdserver.go (role resolution for + -- builds, e.g. the delete build for a deleted owner's workspaces) and + -- coderd/dynamicparameters/render.go (owner context for rendering). + -- Do not add a WHERE deleted = false filter without migrating those + -- consumers first. id, username, status, email, deleted, -- All user roles, including their org roles. array_cat( diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index b41a7746b6b..0a4343e2042 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3553,6 +3553,14 @@ func (api *API) chatCreateWorkspace( ) (codersdk.Workspace, error) { actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll) if err != nil { + // Chats are not purged when their owner is soft-deleted, so a + // chat tool call can still act for a deleted owner. Surface a + // structured response instead of an opaque wrapped error. + if errors.Is(err, httpmw.ErrUserDeleted) { + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ + Message: "Chat owner has been deleted.", + }) + } return codersdk.Workspace{}, xerrors.Errorf("load user authorization: %w", err) } ctx = dbauthz.As(ctx, actor) @@ -3630,6 +3638,14 @@ func (api *API) chatStartWorkspace( ) (codersdk.WorkspaceBuild, error) { actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll) if err != nil { + // Chats are not purged when their owner is soft-deleted, so a + // chat tool call can still act for a deleted owner. Surface a + // structured response instead of an opaque wrapped error. + if errors.Is(err, httpmw.ErrUserDeleted) { + return codersdk.WorkspaceBuild{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ + Message: "Chat owner has been deleted.", + }) + } return codersdk.WorkspaceBuild{}, xerrors.Errorf("load user authorization: %w", err) } ctx = dbauthz.As(ctx, actor) @@ -3706,6 +3722,14 @@ func (api *API) chatStopWorkspace( ) (codersdk.WorkspaceBuild, error) { actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll) if err != nil { + // Chats are not purged when their owner is soft-deleted, so a + // chat tool call can still act for a deleted owner. Surface a + // structured response instead of an opaque wrapped error. + if errors.Is(err, httpmw.ErrUserDeleted) { + return codersdk.WorkspaceBuild{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ + Message: "Chat owner has been deleted.", + }) + } return codersdk.WorkspaceBuild{}, xerrors.Errorf("load user authorization: %w", err) } ctx = dbauthz.As(ctx, actor) @@ -5187,6 +5211,14 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http if apiKey.UserID != member.UserID { memberSubject, _, err := httpmw.UserRBACSubject(ctx, api.Database, member.UserID, rbac.ScopeAll) if err != nil { + // A deleted member is a bad request target, not a server + // error. + if errors.Is(err, httpmw.ErrUserDeleted) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Cannot set a model override for a deleted user.", + }) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error validating model config override.", Detail: err.Error(), @@ -6403,6 +6435,9 @@ func (api *API) downloadChatFile(rw http.ResponseWriter, r *http.Request) { return } + // Fails closed for every error, including ErrUserDeleted: a signed + // download URL minted for a since-deleted user must stop working, and + // this unauthenticated endpoint intentionally leaks nothing beyond 404. subject, status, err := httpmw.UserRBACSubject(ctx, api.Database, claims.UserID, rbac.ScopeAll) if err != nil || status != database.UserStatusActive { httpapi.ResourceNotFound(rw) diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 23e5fbe3e83..1e76863d4ff 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -240,9 +240,10 @@ func PrecheckAPIKey(cfg ValidateAPIKeyConfig) func(http.Handler) http.Handler { // - Token extraction and parsing // - Database lookup + secret hash validation // - Expiry check +// - User role lookup (UserRBACSubject): rejects soft-deleted users +// before any write below // - OIDC/OAuth token refresh (if applicable) // - API key LastUsed / ExpiresAt DB updates -// - User role lookup (UserRBACSubject) // // It does NOT: // - Write HTTP error responses @@ -277,6 +278,36 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque } } + // Fetch user roles before any of the writes below: a soft-deleted user + // is a correctly rejected credential, not a server error. An api_keys + // row can outlive its user (a row that survived or was resurrected past + // delete_deleted_user_resources, a restored backup, an insert that + // bypassed trigger_insert_apikeys) and must be inert: rejecting before + // the OIDC/GitHub token refresh and the LastUsed/expiry writes keeps + // the stale token from calling the IdP with the deleted user's refresh + // token, rewriting user_links, bumping the deleted user's last_seen_at, + // or re-firing the cleanup trigger. + actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet()) + if err != nil { + if errors.Is(err, ErrUserDeleted) { + return nil, &ValidateAPIKeyError{ + Code: http.StatusUnauthorized, + Response: codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "API key belongs to a deleted user.", + }, + } + } + return nil, &ValidateAPIKeyError{ + Code: http.StatusInternalServerError, + Response: codersdk.Response{ + Message: internalErrorMessage, + Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()), + }, + Hard: true, + } + } + // Refresh OIDC/GitHub tokens if applicable. if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC { //nolint:gocritic // System needs to fetch UserLink to check if it's valid. @@ -412,34 +443,6 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque } } - // Fetch user roles before any of the writes below: a soft-deleted user - // is a correctly rejected credential, not a server error. An api_keys - // row can outlive its user (a row that survived or was resurrected past - // delete_deleted_user_resources, a restored backup, an insert that - // bypassed trigger_insert_apikeys) and must be inert: rejecting before - // the LastUsed/expiry writes keeps the stale token from bumping the - // deleted user's last_seen_at or re-firing the cleanup trigger. - actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet()) - if err != nil { - if errors.Is(err, ErrUserDeleted) { - return nil, &ValidateAPIKeyError{ - Code: http.StatusUnauthorized, - Response: codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "API key belongs to a deleted user.", - }, - } - } - return nil, &ValidateAPIKeyError{ - Code: http.StatusInternalServerError, - Response: codersdk.Response{ - Message: internalErrorMessage, - Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()), - }, - Hard: true, - } - } - // Update LastUsed and session expiry. changed := false if now.Sub(key.LastUsed) > time.Hour { diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 63aeaeb9913..79539fcd323 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -3,6 +3,7 @@ package httpmw_test import ( "context" "crypto/sha256" + "database/sql" "encoding/json" "fmt" "io" @@ -45,6 +46,28 @@ func randomAPIKeyParts() (id string, secret string, hashedSecret []byte) { return id, secret, hashedSecret } +// softDeleteUserKeepRows soft-deletes a user while keeping their +// dependent rows (api_keys, user_links): delete_deleted_user_resources +// would purge them, so suppress triggers for this transaction with +// session_replication_role = replica, which is a plain per-transaction +// GUC (no DDL, no ACCESS EXCLUSIVE lock on users that could stall other +// parallel tests when CODER_PG_CONNECTION_URL points every test at one +// shared database). This reconstructs the orphaned credentials the +// middleware must reject (rows that survived cleanup past a race, a +// restored backup, an insert that bypassed trigger_insert_apikeys). +func softDeleteUserKeepRows(t *testing.T, sqlDB *sql.DB, userID uuid.UUID) { + t.Helper() + ctx := context.Background() + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() //nolint:errcheck // no-op after commit + _, err = tx.ExecContext(ctx, `SET LOCAL session_replication_role = replica`) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, `UPDATE users SET deleted = true WHERE id = $1`, userID) + require.NoError(t, err) + require.NoError(t, tx.Commit()) +} + func TestAPIKey(t *testing.T) { t.Parallel() @@ -254,33 +277,100 @@ func TestAPIKey(t *testing.T) { r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() user = dbgen.User(t, db, database.User{}) - _, token = dbgen.APIKey(t, db, database.APIKey{ - UserID: user.ID, + // LastUsed is old enough that an accepted request would + // rewrite it, pinning that rejection precedes the write. + sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LastUsed: dbtime.Now().AddDate(0, 0, -1), }) ) - // Soft-delete the user while keeping the api_keys row: - // delete_deleted_user_resources would purge it, so suppress the - // cleanup trigger transactionally to reconstruct the orphaned - // credential this middleware must reject (a row that survived - // cleanup past a race, a restored backup, an insert that bypassed - // trigger_insert_apikeys). The ALTER TABLE takes a brief ACCESS - // EXCLUSIVE lock on users, which can stall other parallel tests - // when CODER_PG_CONNECTION_URL points every test at one shared - // database. - ctx := context.Background() - tx, err := sqlDB.BeginTx(ctx, nil) - require.NoError(t, err) - defer tx.Rollback() //nolint:errcheck // no-op after commit - _, err = tx.ExecContext(ctx, `ALTER TABLE users DISABLE TRIGGER trigger_update_users`) + softDeleteUserKeepRows(t, sqlDB, user.ID) + ctx := testutil.Context(t, testutil.WaitShort) + deletedUser, err := db.GetUserByID(ctx, user.ID) require.NoError(t, err) - _, err = tx.ExecContext(ctx, `UPDATE users SET deleted = true WHERE id = $1`, user.ID) + require.True(t, deletedUser.Deleted) + + r.Header.Set(codersdk.SessionTokenHeader, token) + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + var resp codersdk.Response + require.NoError(t, json.NewDecoder(res.Body).Decode(&resp)) + require.Equal(t, httpmw.SignedOutErrorMessage, resp.Message) + require.Equal(t, "API key belongs to a deleted user.", resp.Detail) + + // Reject-before-write: the rejected request must not bump + // api_keys.last_used / expires_at or users.last_seen_at. + gotAPIKey, err := db.GetAPIKeyByID(ctx, sentAPIKey.ID) require.NoError(t, err) - _, err = tx.ExecContext(ctx, `ALTER TABLE users ENABLE TRIGGER trigger_update_users`) + require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) + gotUser, err := db.GetUserByID(ctx, user.ID) require.NoError(t, err) - require.NoError(t, tx.Commit()) + require.Equal(t, deletedUser.LastSeenAt, gotUser.LastSeenAt) + + // Soft 401: an optional-auth route must continue unauthenticated + // instead of failing the whole request the way a hard error does. + optionalR := httptest.NewRequest("GET", "/", nil) + optionalR.Header.Set(codersdk.SessionTokenHeader, token) + optionalRW := httptest.NewRecorder() + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + Optional: true, + })(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + _, ok := httpmw.APIKeyOptional(r) + assert.False(t, ok, "optional-auth route must stay unauthenticated") + rw.WriteHeader(http.StatusOK) + })).ServeHTTP(optionalRW, optionalR) + optionalRes := optionalRW.Result() + defer optionalRes.Body.Close() + require.Equal(t, http.StatusOK, optionalRes.StatusCode) + }) + + t.Run("DeletedUserExpiredOAuth", func(t *testing.T) { + t.Parallel() + var ( + db, _, sqlDB = dbtestutil.NewDBWithSQLDB(t) + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + user = dbgen.User(t, db, database.User{}) + // The API key itself is valid, but the OAuth link is + // expired: pre-fix, the middleware would call the IdP with + // the deleted user's refresh token and rewrite user_links + // before rejecting the subject. + sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LastUsed: dbtime.Now().AddDate(0, 0, -1), + LoginType: database.LoginTypeOIDC, + }) + sentLink = dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + OAuthExpiry: dbtime.Now().AddDate(0, 0, -1), + }) + ) + softDeleteUserKeepRows(t, sqlDB, user.ID) + + var idpCalls atomic.Int64 r.Header.Set(codersdk.SessionTokenHeader, token) httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ - DB: db, + DB: db, + OAuth2Configs: &httpmw.OAuth2Configs{ + OIDC: &testutil.OAuth2Config{ + TokenSourceFunc: func() (*oauth2.Token, error) { + idpCalls.Add(1) + return &oauth2.Token{ + AccessToken: "wow", + RefreshToken: "moo", + Expiry: dbtime.Now().AddDate(0, 0, 1), + }, nil + }, + }, + }, RedirectToLogin: false, })(successHandler).ServeHTTP(rw, r) res := rw.Result() @@ -290,6 +380,23 @@ func TestAPIKey(t *testing.T) { require.NoError(t, json.NewDecoder(res.Body).Decode(&resp)) require.Equal(t, httpmw.SignedOutErrorMessage, resp.Message) require.Equal(t, "API key belongs to a deleted user.", resp.Detail) + + // Reject-before-write: the deleted user's refresh token must + // never reach the IdP and user_links must stay untouched. + require.EqualValues(t, 0, idpCalls.Load(), "deleted-user key must not reach the IdP") + ctx := testutil.Context(t, testutil.WaitShort) + gotLink, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, sentLink.OAuthAccessToken, gotLink.OAuthAccessToken) + require.Equal(t, sentLink.OAuthRefreshToken, gotLink.OAuthRefreshToken) + require.Equal(t, sentLink.OAuthExpiry, gotLink.OAuthExpiry) + gotAPIKey, err := db.GetAPIKeyByID(ctx, sentAPIKey.ID) + require.NoError(t, err) + require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) t.Run("InvalidSecret", func(t *testing.T) { diff --git a/coderd/httpmw/workspaceagent_test.go b/coderd/httpmw/workspaceagent_test.go index c18e7aab91d..027fbb1d17d 100644 --- a/coderd/httpmw/workspaceagent_test.go +++ b/coderd/httpmw/workspaceagent_test.go @@ -96,6 +96,36 @@ func TestWorkspaceAgent(t *testing.T) { require.Contains(t, response.Message, `User is not active (status = "suspended")`) }) + t.Run("DeletedOwner", func(t *testing.T) { + t.Parallel() + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + authToken := uuid.New() + req, rtr, workspace, _ := setup(t, db, authToken, httpmw.ExtractWorkspaceAgentAndLatestBuild( + httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{ + DB: db, + Optional: false, + }), + ) + + // Soft-delete the owner while keeping their rows so the agent's + // auth token still resolves; a soft-deleted owner must yield a + // terminal 401 for the agent, not a retryable 500. + softDeleteUserKeepRows(t, sqlDB, workspace.OwnerID) + + 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.Equal(t, "Workspace owner has been deleted.", response.Message) + }) + t.Run("Latest", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 9918cd33a88..0bc0ca1c01a 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -401,6 +401,11 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database // Grab the user roles so we can perform the exchange as the user. actor, _, err := httpmw.UserRBACSubject(ctx, db, dbCode.UserID, rbac.ScopeAll) if err != nil { + // A soft-deleted user's authorization code is a revoked grant, not + // a server error: RFC 6749 section 5.2 maps it to invalid_grant. + if errors.Is(err, httpmw.ErrUserDeleted) { + return codersdk.OAuth2TokenResponse{}, errBadCode + } return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err) } @@ -518,6 +523,11 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, rbac.ScopeAll) if err != nil { + // A soft-deleted user's refresh token is a revoked grant, not a + // server error: RFC 6749 section 5.2 maps it to invalid_grant. + if errors.Is(err, httpmw.ErrUserDeleted) { + return codersdk.OAuth2TokenResponse{}, errBadToken + } return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err) } diff --git a/coderd/userauth.go b/coderd/userauth.go index fb80c2230b5..63ad55e0169 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -620,6 +620,8 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co return user, rbac.Subject{}, false } + // ErrUserDeleted is unreachable here: GetUserByEmailOrUsername above + // filters deleted users, so a soft-deleted user never gets this far. subject, userStatus, err := httpmw.UserRBACSubject(ctx, api.Database, user.ID, rbac.ScopeAll) if err != nil { logger.Error(ctx, "unable to fetch authorization user roles", slog.Error(err)) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 361ee8025ec..46ebf2110dc 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1225,6 +1225,12 @@ type PromoteQueuedResult struct { func forcedMCPServerConfigsForOwner(ctx context.Context, store database.Store, organizationID, ownerID uuid.UUID) ([]database.MCPServerConfig, error) { owner, _, err := httpmw.UserRBACSubject(ctx, store, ownerID, rbac.ScopeAll) if err != nil { + // Chats are not purged when their owner is soft-deleted, so this + // path stays reachable. Fail closed with an explicit message + // instead of an opaque authorization error. + if errors.Is(err, httpmw.ErrUserDeleted) { + return nil, xerrors.New("chat owner has been deleted") + } return nil, xerrors.Errorf("load chat owner authorization: %w", err) } forced, err := store.GetForcedMCPServerConfigsByOrganization(dbauthz.As(ctx, owner), organizationID) @@ -1642,6 +1648,12 @@ func callerModelConfigContext( } actor, _, err := httpmw.UserRBACSubject(ctx, store, ownerID, rbac.ScopeAll) if err != nil { + // Chats are not purged when their owner is soft-deleted, so this + // path stays reachable. Fail closed with an explicit message + // instead of an opaque authorization error. + if errors.Is(err, httpmw.ErrUserDeleted) { + return nil, xerrors.New("chat owner has been deleted") + } return nil, xerrors.Errorf("load model config authorization: %w", err) } //nolint:gocritic // Background Chatd work must use the chat owner's model ACLs. diff --git a/coderd/x/chatd/chattool/listtemplates.go b/coderd/x/chatd/chattool/listtemplates.go index a59b00d1fbd..efdc5de46ae 100644 --- a/coderd/x/chatd/chattool/listtemplates.go +++ b/coderd/x/chatd/chattool/listtemplates.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "database/sql" + "errors" "math" "slices" "strings" @@ -437,6 +438,12 @@ func compactTemplateSearch(value string) string { func asOwner(ctx context.Context, db database.Store, ownerID uuid.UUID) (context.Context, error) { actor, _, err := httpmw.UserRBACSubject(ctx, db, ownerID, rbac.ScopeAll) if err != nil { + // Chats are not purged when their owner is soft-deleted, so this + // path stays reachable. Fail closed with an explicit message + // instead of an opaque authorization error. + if errors.Is(err, httpmw.ErrUserDeleted) { + return ctx, xerrors.New("chat owner has been deleted") + } return ctx, xerrors.Errorf("load user authorization: %w", err) } return dbauthz.As(ctx, actor), nil