diff --git a/lib/tasks/__tests__/validateGetTasksQuery.test.ts b/lib/tasks/__tests__/validateGetTasksQuery.test.ts index b9d0dda71..21722150a 100644 --- a/lib/tasks/__tests__/validateGetTasksQuery.test.ts +++ b/lib/tasks/__tests__/validateGetTasksQuery.test.ts @@ -119,6 +119,41 @@ describe("validateGetTasksQuery", () => { }); }); + it("lets an admin fetch any task by id alone, dropping account scope", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "worker_admin_acc", + orgId: null, + authToken: "token", + }); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const result = await validateGetTasksQuery( + createMockRequest("http://localhost:3000/api/tasks?id=task_owned_by_someone_else"), + ); + + expect(checkIsAdmin).toHaveBeenCalledWith("worker_admin_acc"); + expect(validateAccountIdOverride).not.toHaveBeenCalled(); + // No account_id key -> selectScheduledActions filters by id only (cross-account read). + expect(result).toEqual({ id: "task_owned_by_someone_else" }); + }); + + it("keeps a non-admin id lookup scoped to the authenticated account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "regular_acc", + orgId: null, + authToken: "token", + }); + vi.mocked(checkIsAdmin).mockResolvedValue(false); + + const result = await validateGetTasksQuery( + createMockRequest("http://localhost:3000/api/tasks?id=task_1"), + ); + + expect(checkIsAdmin).toHaveBeenCalledWith("regular_acc"); + expect(validateAccountIdOverride).not.toHaveBeenCalled(); + expect(result).toEqual({ id: "task_1", account_id: "regular_acc" }); + }); + it("returns 403 when non-admin override is denied", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "org_owner_acc", diff --git a/lib/tasks/validateGetTasksQuery.ts b/lib/tasks/validateGetTasksQuery.ts index 3e9d56201..f581a438a 100644 --- a/lib/tasks/validateGetTasksQuery.ts +++ b/lib/tasks/validateGetTasksQuery.ts @@ -19,7 +19,9 @@ export const getTasksQuerySchema = z.object({ }); export type GetTasksQuery = z.infer; -export type ValidatedGetTasksQuery = Omit & { account_id: string }; +// account_id is optional: admin callers fetching a single task by `id` are not +// scoped to their own account, so the resolved query may omit account_id entirely. +export type ValidatedGetTasksQuery = Omit & { account_id?: string }; /** * Validates get tasks query parameters from a NextRequest. @@ -55,7 +57,7 @@ export async function validateGetTasksQuery( ); } - let targetAccountId = authResult.accountId; + let targetAccountId: string | undefined = authResult.accountId; if ( validationResult.data.account_id && @@ -76,10 +78,18 @@ export async function validateGetTasksQuery( targetAccountId = overrideResult.accountId; } + } else if (validationResult.data.id && !validationResult.data.account_id) { + // Single-task lookup by id: admins may read any task regardless of owner + // (e.g. the background worker fetching a customer's scheduled task config). + // Non-admins stay scoped to their own account. + const isAdmin = await checkIsAdmin(authResult.accountId); + if (isAdmin) { + targetAccountId = undefined; + } } return { ...validationResult.data, - account_id: targetAccountId, + ...(targetAccountId ? { account_id: targetAccountId } : {}), }; }