From a2755b78277907425ff60e3b896fda626f883cd1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 4 Feb 2026 13:12:32 -0500 Subject: [PATCH 1/3] feat: add account_id param to GET /api/sandboxes for Org API keys - Add buildGetSandboxesParams.ts for consistent auth/access handling - Update validateGetSandboxesRequest to support account_id query param - Add 403 response for unauthorized account_id usage - Add comprehensive tests for all scenarios Co-Authored-By: Claude Opus 4.5 --- .../__tests__/buildGetSandboxesParams.test.ts | 197 ++++++++++++++ .../validateGetSandboxesRequest.test.ts | 243 ++++++++++++++---- lib/sandbox/buildGetSandboxesParams.ts | 67 +++++ lib/sandbox/validateGetSandboxesRequest.ts | 33 ++- 4 files changed, 473 insertions(+), 67 deletions(-) create mode 100644 lib/sandbox/__tests__/buildGetSandboxesParams.test.ts create mode 100644 lib/sandbox/buildGetSandboxesParams.ts diff --git a/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts new file mode 100644 index 00000000..40e26448 --- /dev/null +++ b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; + +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-org-id", +})); + +describe("buildGetSandboxesParams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("personal API key (org_id = null)", () => { + it("returns accountIds with key owner when no target_account_id", async () => { + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: null, + }); + + expect(result).toEqual({ + params: { accountIds: ["account-123"], sandboxId: undefined }, + error: null, + }); + }); + + it("returns error when personal key tries to filter by account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: null, + target_account_id: "other-account", + }); + + expect(result).toEqual({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + }); + + it("includes sandbox_id filter for personal key", async () => { + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: null, + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { accountIds: ["account-123"], sandboxId: "sbx_abc123" }, + error: null, + }); + }); + }); + + describe("organization API key", () => { + it("fetches org member accountIds when no target_account_id", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: "org-123", organization: null }, + { account_id: "member-2", organization_id: "org-123", organization: null }, + { account_id: "member-3", organization_id: "org-123", organization: null }, + ]); + + const result = await buildGetSandboxesParams({ + account_id: "org-123", + org_id: "org-123", + }); + + expect(getAccountOrganizations).toHaveBeenCalledWith({ organizationId: "org-123" }); + expect(result).toEqual({ + params: { accountIds: ["member-1", "member-2", "member-3"], sandboxId: undefined }, + error: null, + }); + }); + + it("allows filtering by account_id if member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "member-account", + }); + + expect(result).toEqual({ + params: { accountIds: ["member-account"], sandboxId: undefined }, + error: null, + }); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "member-account", + }); + }); + + it("returns error when account_id is not member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "non-member-account", + }); + + expect(result).toEqual({ + params: null, + error: "account_id is not a member of this organization", + }); + }); + + it("includes sandbox_id filter for org key", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { account_id: "member-1", organization_id: "org-123", organization: null }, + ]); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { accountIds: ["member-1"], sandboxId: "sbx_abc123" }, + error: null, + }); + }); + + it("returns empty accountIds when org has no members", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([]); + + const result = await buildGetSandboxesParams({ + account_id: "org-123", + org_id: "org-123", + }); + + expect(result).toEqual({ + params: { accountIds: [], sandboxId: undefined }, + error: null, + }); + }); + }); + + describe("Recoup admin key", () => { + const recoupOrgId = "recoup-org-id"; + + it("returns empty params (no filter) to get all records", async () => { + const result = await buildGetSandboxesParams({ + account_id: "admin-account", + org_id: recoupOrgId, + }); + + expect(result).toEqual({ + params: { sandboxId: undefined }, + error: null, + }); + // Should NOT call getAccountOrganizations for admin + expect(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("allows filtering by any account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetSandboxesParams({ + account_id: "admin-account", + org_id: recoupOrgId, + target_account_id: "any-account", + }); + + expect(result).toEqual({ + params: { accountIds: ["any-account"], sandboxId: undefined }, + error: null, + }); + }); + + it("includes sandbox_id filter for admin key", async () => { + const result = await buildGetSandboxesParams({ + account_id: "admin-account", + org_id: recoupOrgId, + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { sandboxId: "sbx_abc123" }, + error: null, + }); + }); + }); +}); diff --git a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts index 98660d7f..d8747eda 100644 --- a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts +++ b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts @@ -4,11 +4,20 @@ import { NextResponse } from "next/server"; import { validateGetSandboxesRequest } from "../validateGetSandboxesRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); +vi.mock("../buildGetSandboxesParams", () => ({ + buildGetSandboxesParams: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + /** * Creates a mock NextRequest for testing. * @@ -31,80 +40,204 @@ describe("validateGetSandboxesRequest", () => { vi.clearAllMocks(); }); - it("returns error when auth fails", async () => { - vi.mocked(validateAuthContext).mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); + describe("authentication", () => { + it("returns error when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(401); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); }); - it("returns params with accountIds for personal key", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", + describe("query parameter validation", () => { + it("accepts valid sandbox_id parameter", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["acc_123"], sandboxId: "sbx_specific" }, + error: null, + }); + + const request = createMockRequest({ sandbox_id: "sbx_specific" }); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["acc_123"], + sandboxId: "sbx_specific", + }); }); - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - accountIds: ["acc_123"], - sandboxId: undefined, + it("accepts valid account_id parameter (UUID format)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-123", + orgId: "org-123", + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: undefined }, + error: null, + }); + + const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); + const result = await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "org-123", + org_id: "org-123", + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: undefined, + }); + expect(result).toEqual({ + accountIds: ["550e8400-e29b-41d4-a716-446655440000"], + sandboxId: undefined, + }); }); - }); - it("returns params with orgId for org key", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: "org_456", - authToken: "token", + it("rejects invalid account_id format (not UUID)", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + + const request = createMockRequest({ account_id: "invalid-not-uuid" }); + const result = await validateGetSandboxesRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.status).toBe("error"); }); - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - orgId: "org_456", - sandboxId: undefined, + it("accepts both sandbox_id and account_id parameters", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-123", + orgId: "org-123", + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: "sbx_abc123" }, + error: null, + }); + + const request = createMockRequest({ + sandbox_id: "sbx_abc123", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "org-123", + org_id: "org-123", + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: "sbx_abc123", + }); }); }); - it("includes sandbox_id in params when provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", + describe("authorization", () => { + it("returns 403 when personal key tries to use account_id", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + + const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); + const result = await validateGetSandboxesRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const json = await response.json(); + expect(json.status).toBe("error"); + expect(json.error).toBe("Personal API keys cannot filter by account_id"); }); - const request = createMockRequest({ sandbox_id: "sbx_specific" }); - const result = await validateGetSandboxesRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - accountIds: ["acc_123"], - sandboxId: "sbx_specific", + it("returns 403 when account_id is not member of org", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-123", + orgId: "org-123", + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: null, + error: "account_id is not a member of this organization", + }); + + const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); + const result = await validateGetSandboxesRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const json = await response.json(); + expect(json.status).toBe("error"); + expect(json.error).toBe("account_id is not a member of this organization"); }); }); - it("handles empty sandbox_id query param", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", + describe("default behavior", () => { + it("returns params with accountIds for personal key", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["acc_123"], sandboxId: undefined }, + error: null, + }); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "acc_123", + org_id: null, + target_account_id: undefined, + sandbox_id: undefined, + }); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["acc_123"], + sandboxId: undefined, + }); }); - const request = createMockRequest(); - const result = await validateGetSandboxesRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as { sandboxId?: string }).sandboxId).toBeUndefined(); + it("returns params with org member accountIds for org key", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org_456", + orgId: "org_456", + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["member-1", "member-2"], sandboxId: undefined }, + error: null, + }); + + const request = createMockRequest(); + const result = await validateGetSandboxesRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountIds: ["member-1", "member-2"], + sandboxId: undefined, + }); + }); }); }); diff --git a/lib/sandbox/buildGetSandboxesParams.ts b/lib/sandbox/buildGetSandboxesParams.ts new file mode 100644 index 00000000..57579530 --- /dev/null +++ b/lib/sandbox/buildGetSandboxesParams.ts @@ -0,0 +1,67 @@ +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import { RECOUP_ORG_ID } from "@/lib/const"; + +export interface BuildGetSandboxesParamsInput { + /** The authenticated account ID */ + account_id: string; + /** The organization ID from the API key (null for personal keys) */ + org_id: string | null; + /** Optional target account ID to filter by */ + target_account_id?: string; + /** Optional sandbox ID to filter by */ + sandbox_id?: string; +} + +export type BuildGetSandboxesParamsResult = + | { params: SelectAccountSandboxesParams; error: null } + | { params: null; error: string }; + +/** + * Builds the parameters for selectAccountSandboxes based on auth context. + * + * For personal keys: Returns accountIds with the key owner's account + * For org keys: Fetches all org member accountIds and returns them + * For Recoup admin key: Returns empty params to indicate ALL records + * + * If target_account_id is provided, validates access and returns that account. + * + * @param input - The auth context and optional filters + * @returns The params for selectAccountSandboxes or an error + */ +export async function buildGetSandboxesParams( + input: BuildGetSandboxesParamsInput, +): Promise { + const { account_id, org_id, target_account_id, sandbox_id } = input; + + // Handle account_id filter if provided + if (target_account_id) { + const hasAccess = await canAccessAccount({ orgId: org_id, targetAccountId: target_account_id }); + if (!hasAccess) { + return { + params: null, + error: org_id + ? "account_id is not a member of this organization" + : "Personal API keys cannot filter by account_id", + }; + } + return { params: { accountIds: [target_account_id], sandboxId: sandbox_id }, error: null }; + } + + // No account_id filter - determine what to return based on key type + if (org_id === RECOUP_ORG_ID) { + // Recoup admin: return undefined accountIds to indicate ALL records + return { params: { sandboxId: sandbox_id }, error: null }; + } + + if (org_id) { + // Org key: fetch all member account IDs for this organization + const orgMembers = await getAccountOrganizations({ organizationId: org_id }); + const memberAccountIds = orgMembers.map(member => member.account_id); + return { params: { accountIds: memberAccountIds, sandboxId: sandbox_id }, error: null }; + } + + // Personal key: Only return the key owner's account + return { params: { accountIds: [account_id], sandboxId: sandbox_id }, error: null }; +} diff --git a/lib/sandbox/validateGetSandboxesRequest.ts b/lib/sandbox/validateGetSandboxesRequest.ts index aa2d73bb..0c27ed1e 100644 --- a/lib/sandbox/validateGetSandboxesRequest.ts +++ b/lib/sandbox/validateGetSandboxesRequest.ts @@ -3,10 +3,12 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { buildGetSandboxesParams } from "./buildGetSandboxesParams"; import { z } from "zod"; const getSandboxesQuerySchema = z.object({ sandbox_id: z.string().optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); /** @@ -15,6 +17,7 @@ const getSandboxesQuerySchema = z.object({ * * Query parameters: * - sandbox_id: Filter to a specific sandbox (must belong to account/org) + * - account_id: Filter to a specific account (org API keys only) * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or SelectAccountSandboxesParams @@ -26,6 +29,7 @@ export async function validateGetSandboxesRequest( const { searchParams } = new URL(request.url); const queryParams = { sandbox_id: searchParams.get("sandbox_id") ?? undefined, + account_id: searchParams.get("account_id") ?? undefined, }; const queryResult = getSandboxesQuerySchema.safeParse(queryParams); @@ -40,7 +44,7 @@ export async function validateGetSandboxesRequest( ); } - const { sandbox_id: sandboxId } = queryResult.data; + const { sandbox_id: sandboxId, account_id: targetAccountId } = queryResult.data; // Use validateAuthContext for authentication const authResult = await validateAuthContext(request); @@ -50,18 +54,23 @@ export async function validateGetSandboxesRequest( const { accountId, orgId } = authResult; - // Build params based on auth context - const params: SelectAccountSandboxesParams = { - sandboxId, - }; + // Build params using buildGetSandboxesParams for consistent auth/access handling + const buildResult = await buildGetSandboxesParams({ + account_id: accountId, + org_id: orgId, + target_account_id: targetAccountId, + sandbox_id: sandboxId, + }); - if (orgId) { - // Org key - filter by org membership - params.orgId = orgId; - } else { - // Personal key - filter by account - params.accountIds = [accountId]; + if (buildResult.error) { + return NextResponse.json( + { + status: "error", + error: buildResult.error, + }, + { status: 403, headers: getCorsHeaders() }, + ); } - return params; + return buildResult.params; } From 32523bf937b1ed0343d70f8eda59a1811a3a5f97 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 4 Feb 2026 13:18:33 -0500 Subject: [PATCH 2/3] refactor: move canAccessAccount call to validateGetSandboxesRequest - Move access control check from buildGetSandboxesParams to validateGetSandboxesRequest - buildGetSandboxesParams now assumes access has been validated by caller - Update tests to reflect the new responsibility separation Co-Authored-By: Claude Opus 4.5 --- .../__tests__/buildGetSandboxesParams.test.ts | 63 +++------- .../validateGetSandboxesRequest.test.ts | 118 +++++++++++------- lib/sandbox/buildGetSandboxesParams.ts | 16 +-- lib/sandbox/validateGetSandboxesRequest.ts | 19 ++- 4 files changed, 110 insertions(+), 106 deletions(-) diff --git a/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts index 40e26448..c17d3628 100644 --- a/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts +++ b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts @@ -1,13 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; -vi.mock("@/lib/organizations/canAccessAccount", () => ({ - canAccessAccount: vi.fn(), -})); - vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ getAccountOrganizations: vi.fn(), })); @@ -34,21 +29,6 @@ describe("buildGetSandboxesParams", () => { }); }); - it("returns error when personal key tries to filter by account_id", async () => { - vi.mocked(canAccessAccount).mockResolvedValue(false); - - const result = await buildGetSandboxesParams({ - account_id: "account-123", - org_id: null, - target_account_id: "other-account", - }); - - expect(result).toEqual({ - params: null, - error: "Personal API keys cannot filter by account_id", - }); - }); - it("includes sandbox_id filter for personal key", async () => { const result = await buildGetSandboxesParams({ account_id: "account-123", @@ -83,9 +63,7 @@ describe("buildGetSandboxesParams", () => { }); }); - it("allows filtering by account_id if member of org", async () => { - vi.mocked(canAccessAccount).mockResolvedValue(true); - + it("returns target_account_id when provided (access validated by caller)", async () => { const result = await buildGetSandboxesParams({ account_id: "account-123", org_id: "org-123", @@ -96,25 +74,8 @@ describe("buildGetSandboxesParams", () => { params: { accountIds: ["member-account"], sandboxId: undefined }, error: null, }); - expect(canAccessAccount).toHaveBeenCalledWith({ - orgId: "org-123", - targetAccountId: "member-account", - }); - }); - - it("returns error when account_id is not member of org", async () => { - vi.mocked(canAccessAccount).mockResolvedValue(false); - - const result = await buildGetSandboxesParams({ - account_id: "account-123", - org_id: "org-123", - target_account_id: "non-member-account", - }); - - expect(result).toEqual({ - params: null, - error: "account_id is not a member of this organization", - }); + // Should not fetch org members when target_account_id is provided + expect(getAccountOrganizations).not.toHaveBeenCalled(); }); it("includes sandbox_id filter for org key", async () => { @@ -147,6 +108,20 @@ describe("buildGetSandboxesParams", () => { error: null, }); }); + + it("includes both target_account_id and sandbox_id when provided", async () => { + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "member-account", + sandbox_id: "sbx_abc123", + }); + + expect(result).toEqual({ + params: { accountIds: ["member-account"], sandboxId: "sbx_abc123" }, + error: null, + }); + }); }); describe("Recoup admin key", () => { @@ -166,9 +141,7 @@ describe("buildGetSandboxesParams", () => { expect(getAccountOrganizations).not.toHaveBeenCalled(); }); - it("allows filtering by any account_id", async () => { - vi.mocked(canAccessAccount).mockResolvedValue(true); - + it("returns target_account_id when provided (access validated by caller)", async () => { const result = await buildGetSandboxesParams({ account_id: "admin-account", org_id: recoupOrgId, diff --git a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts index d8747eda..d81168e9 100644 --- a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts +++ b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts @@ -5,6 +5,7 @@ import { NextResponse } from "next/server"; import { validateGetSandboxesRequest } from "../validateGetSandboxesRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), @@ -14,6 +15,10 @@ vi.mock("../buildGetSandboxesParams", () => ({ buildGetSandboxesParams: vi.fn(), })); +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); @@ -76,32 +81,6 @@ describe("validateGetSandboxesRequest", () => { }); }); - it("accepts valid account_id parameter (UUID format)", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "org-123", - orgId: "org-123", - authToken: "token", - }); - vi.mocked(buildGetSandboxesParams).mockResolvedValue({ - params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: undefined }, - error: null, - }); - - const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); - const result = await validateGetSandboxesRequest(request); - - expect(buildGetSandboxesParams).toHaveBeenCalledWith({ - account_id: "org-123", - org_id: "org-123", - target_account_id: "550e8400-e29b-41d4-a716-446655440000", - sandbox_id: undefined, - }); - expect(result).toEqual({ - accountIds: ["550e8400-e29b-41d4-a716-446655440000"], - sandboxId: undefined, - }); - }); - it("rejects invalid account_id format (not UUID)", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", @@ -118,48 +97,45 @@ describe("validateGetSandboxesRequest", () => { const json = await response.json(); expect(json.status).toBe("error"); }); + }); - it("accepts both sandbox_id and account_id parameters", async () => { + describe("authorization with canAccessAccount", () => { + it("calls canAccessAccount when account_id is provided", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "org-123", orgId: "org-123", authToken: "token", }); + vi.mocked(canAccessAccount).mockResolvedValue(true); vi.mocked(buildGetSandboxesParams).mockResolvedValue({ - params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: "sbx_abc123" }, + params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: undefined }, error: null, }); - const request = createMockRequest({ - sandbox_id: "sbx_abc123", - account_id: "550e8400-e29b-41d4-a716-446655440000", - }); + const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); await validateGetSandboxesRequest(request); - expect(buildGetSandboxesParams).toHaveBeenCalledWith({ - account_id: "org-123", - org_id: "org-123", - target_account_id: "550e8400-e29b-41d4-a716-446655440000", - sandbox_id: "sbx_abc123", + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "550e8400-e29b-41d4-a716-446655440000", }); }); - }); - describe("authorization", () => { it("returns 403 when personal key tries to use account_id", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", }); - vi.mocked(buildGetSandboxesParams).mockResolvedValue({ - params: null, - error: "Personal API keys cannot filter by account_id", - }); + vi.mocked(canAccessAccount).mockResolvedValue(false); const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); const result = await validateGetSandboxesRequest(request); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: null, + targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + }); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; expect(response.status).toBe(403); @@ -174,14 +150,15 @@ describe("validateGetSandboxesRequest", () => { orgId: "org-123", authToken: "token", }); - vi.mocked(buildGetSandboxesParams).mockResolvedValue({ - params: null, - error: "account_id is not a member of this organization", - }); + vi.mocked(canAccessAccount).mockResolvedValue(false); const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); const result = await validateGetSandboxesRequest(request); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + }); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; expect(response.status).toBe(403); @@ -189,6 +166,53 @@ describe("validateGetSandboxesRequest", () => { expect(json.status).toBe("error"); expect(json.error).toBe("account_id is not a member of this organization"); }); + + it("does not call canAccessAccount when no account_id provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["acc_123"], sandboxId: undefined }, + error: null, + }); + + const request = createMockRequest(); + await validateGetSandboxesRequest(request); + + expect(canAccessAccount).not.toHaveBeenCalled(); + }); + + it("passes target_account_id to buildGetSandboxesParams when access is granted", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "org-123", + orgId: "org-123", + authToken: "token", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: "sbx_abc123" }, + error: null, + }); + + const request = createMockRequest({ + sandbox_id: "sbx_abc123", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + const result = await validateGetSandboxesRequest(request); + + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "org-123", + org_id: "org-123", + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: "sbx_abc123", + }); + expect(result).toEqual({ + accountIds: ["550e8400-e29b-41d4-a716-446655440000"], + sandboxId: "sbx_abc123", + }); + }); }); describe("default behavior", () => { diff --git a/lib/sandbox/buildGetSandboxesParams.ts b/lib/sandbox/buildGetSandboxesParams.ts index 57579530..0be16cec 100644 --- a/lib/sandbox/buildGetSandboxesParams.ts +++ b/lib/sandbox/buildGetSandboxesParams.ts @@ -1,4 +1,3 @@ -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; import { RECOUP_ORG_ID } from "@/lib/const"; @@ -8,7 +7,7 @@ export interface BuildGetSandboxesParamsInput { account_id: string; /** The organization ID from the API key (null for personal keys) */ org_id: string | null; - /** Optional target account ID to filter by */ + /** Optional target account ID to filter by (access must be validated before calling) */ target_account_id?: string; /** Optional sandbox ID to filter by */ sandbox_id?: string; @@ -25,7 +24,7 @@ export type BuildGetSandboxesParamsResult = * For org keys: Fetches all org member accountIds and returns them * For Recoup admin key: Returns empty params to indicate ALL records * - * If target_account_id is provided, validates access and returns that account. + * If target_account_id is provided, returns that account (access must be validated by caller). * * @param input - The auth context and optional filters * @returns The params for selectAccountSandboxes or an error @@ -35,17 +34,8 @@ export async function buildGetSandboxesParams( ): Promise { const { account_id, org_id, target_account_id, sandbox_id } = input; - // Handle account_id filter if provided + // Handle account_id filter if provided (access already validated by caller) if (target_account_id) { - const hasAccess = await canAccessAccount({ orgId: org_id, targetAccountId: target_account_id }); - if (!hasAccess) { - return { - params: null, - error: org_id - ? "account_id is not a member of this organization" - : "Personal API keys cannot filter by account_id", - }; - } return { params: { accountIds: [target_account_id], sandboxId: sandbox_id }, error: null }; } diff --git a/lib/sandbox/validateGetSandboxesRequest.ts b/lib/sandbox/validateGetSandboxesRequest.ts index 0c27ed1e..d5b7b5e2 100644 --- a/lib/sandbox/validateGetSandboxesRequest.ts +++ b/lib/sandbox/validateGetSandboxesRequest.ts @@ -4,6 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; import { buildGetSandboxesParams } from "./buildGetSandboxesParams"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { z } from "zod"; const getSandboxesQuerySchema = z.object({ @@ -54,7 +55,23 @@ export async function validateGetSandboxesRequest( const { accountId, orgId } = authResult; - // Build params using buildGetSandboxesParams for consistent auth/access handling + // Check access when account_id filter is provided + if (targetAccountId) { + const hasAccess = await canAccessAccount({ orgId, targetAccountId }); + if (!hasAccess) { + return NextResponse.json( + { + status: "error", + error: orgId + ? "account_id is not a member of this organization" + : "Personal API keys cannot filter by account_id", + }, + { status: 403, headers: getCorsHeaders() }, + ); + } + } + + // Build params using buildGetSandboxesParams const buildResult = await buildGetSandboxesParams({ account_id: accountId, org_id: orgId, From 2b8fc32cd6461cc6ef85c11b4a79f57b41ad1f3d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 10:38:16 -0500 Subject: [PATCH 3/3] fix: move canAccessAccount check back inside buildGetSandboxesParams The refactor in 32523bf moved the access check out of the builder and into validateGetSandboxesRequest, breaking the established pattern used by buildGetChatsParams and buildGetPulsesParams. This left the builder as a security-unaware function that blindly accepted any target_account_id. Align with the correct pattern: canAccessAccount lives inside the builder so any caller gets defense-in-depth access checks. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/buildGetSandboxesParams.test.ts | 53 +++++++++++-- .../validateGetSandboxesRequest.test.ts | 78 ++++++------------- lib/sandbox/buildGetSandboxesParams.ts | 16 +++- lib/sandbox/validateGetSandboxesRequest.ts | 29 ++----- 4 files changed, 89 insertions(+), 87 deletions(-) diff --git a/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts index c17d3628..2ccd8c3d 100644 --- a/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts +++ b/lib/sandbox/__tests__/buildGetSandboxesParams.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ getAccountOrganizations: vi.fn(), })); @@ -29,6 +34,21 @@ describe("buildGetSandboxesParams", () => { }); }); + it("returns error when personal key tries to filter by account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: null, + target_account_id: "other-account", + }); + + expect(result).toEqual({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + }); + it("includes sandbox_id filter for personal key", async () => { const result = await buildGetSandboxesParams({ account_id: "account-123", @@ -63,7 +83,9 @@ describe("buildGetSandboxesParams", () => { }); }); - it("returns target_account_id when provided (access validated by caller)", async () => { + it("allows filtering by account_id if member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + const result = await buildGetSandboxesParams({ account_id: "account-123", org_id: "org-123", @@ -74,8 +96,25 @@ describe("buildGetSandboxesParams", () => { params: { accountIds: ["member-account"], sandboxId: undefined }, error: null, }); - // Should not fetch org members when target_account_id is provided - expect(getAccountOrganizations).not.toHaveBeenCalled(); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "member-account", + }); + }); + + it("returns error when account_id is not member of org", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetSandboxesParams({ + account_id: "account-123", + org_id: "org-123", + target_account_id: "non-member-account", + }); + + expect(result).toEqual({ + params: null, + error: "account_id is not a member of this organization", + }); }); it("includes sandbox_id filter for org key", async () => { @@ -109,7 +148,9 @@ describe("buildGetSandboxesParams", () => { }); }); - it("includes both target_account_id and sandbox_id when provided", async () => { + it("includes both target_account_id and sandbox_id when access granted", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + const result = await buildGetSandboxesParams({ account_id: "account-123", org_id: "org-123", @@ -141,7 +182,9 @@ describe("buildGetSandboxesParams", () => { expect(getAccountOrganizations).not.toHaveBeenCalled(); }); - it("returns target_account_id when provided (access validated by caller)", async () => { + it("allows filtering by any account_id", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + const result = await buildGetSandboxesParams({ account_id: "admin-account", org_id: recoupOrgId, diff --git a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts index d81168e9..3069ab19 100644 --- a/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts +++ b/lib/sandbox/__tests__/validateGetSandboxesRequest.test.ts @@ -5,7 +5,6 @@ import { NextResponse } from "next/server"; import { validateGetSandboxesRequest } from "../validateGetSandboxesRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { buildGetSandboxesParams } from "../buildGetSandboxesParams"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), @@ -15,10 +14,6 @@ vi.mock("../buildGetSandboxesParams", () => ({ buildGetSandboxesParams: vi.fn(), })); -vi.mock("@/lib/organizations/canAccessAccount", () => ({ - canAccessAccount: vi.fn(), -})); - vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); @@ -99,42 +94,26 @@ describe("validateGetSandboxesRequest", () => { }); }); - describe("authorization with canAccessAccount", () => { - it("calls canAccessAccount when account_id is provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "org-123", - orgId: "org-123", - authToken: "token", - }); - vi.mocked(canAccessAccount).mockResolvedValue(true); - vi.mocked(buildGetSandboxesParams).mockResolvedValue({ - params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: undefined }, - error: null, - }); - - const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); - await validateGetSandboxesRequest(request); - - expect(canAccessAccount).toHaveBeenCalledWith({ - orgId: "org-123", - targetAccountId: "550e8400-e29b-41d4-a716-446655440000", - }); - }); - - it("returns 403 when personal key tries to use account_id", async () => { + describe("authorization via buildGetSandboxesParams", () => { + it("returns 403 when buildGetSandboxesParams returns personal key error", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", }); - vi.mocked(canAccessAccount).mockResolvedValue(false); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); const result = await validateGetSandboxesRequest(request); - expect(canAccessAccount).toHaveBeenCalledWith({ - orgId: null, - targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "acc_123", + org_id: null, + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: undefined, }); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; @@ -144,20 +123,25 @@ describe("validateGetSandboxesRequest", () => { expect(json.error).toBe("Personal API keys cannot filter by account_id"); }); - it("returns 403 when account_id is not member of org", async () => { + it("returns 403 when buildGetSandboxesParams returns non-member error", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "org-123", orgId: "org-123", authToken: "token", }); - vi.mocked(canAccessAccount).mockResolvedValue(false); + vi.mocked(buildGetSandboxesParams).mockResolvedValue({ + params: null, + error: "account_id is not a member of this organization", + }); const request = createMockRequest({ account_id: "550e8400-e29b-41d4-a716-446655440000" }); const result = await validateGetSandboxesRequest(request); - expect(canAccessAccount).toHaveBeenCalledWith({ - orgId: "org-123", - targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + expect(buildGetSandboxesParams).toHaveBeenCalledWith({ + account_id: "org-123", + org_id: "org-123", + target_account_id: "550e8400-e29b-41d4-a716-446655440000", + sandbox_id: undefined, }); expect(result).toBeInstanceOf(NextResponse); const response = result as NextResponse; @@ -167,30 +151,12 @@ describe("validateGetSandboxesRequest", () => { expect(json.error).toBe("account_id is not a member of this organization"); }); - it("does not call canAccessAccount when no account_id provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - }); - vi.mocked(buildGetSandboxesParams).mockResolvedValue({ - params: { accountIds: ["acc_123"], sandboxId: undefined }, - error: null, - }); - - const request = createMockRequest(); - await validateGetSandboxesRequest(request); - - expect(canAccessAccount).not.toHaveBeenCalled(); - }); - - it("passes target_account_id to buildGetSandboxesParams when access is granted", async () => { + it("passes target_account_id and sandbox_id to buildGetSandboxesParams", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "org-123", orgId: "org-123", authToken: "token", }); - vi.mocked(canAccessAccount).mockResolvedValue(true); vi.mocked(buildGetSandboxesParams).mockResolvedValue({ params: { accountIds: ["550e8400-e29b-41d4-a716-446655440000"], sandboxId: "sbx_abc123" }, error: null, diff --git a/lib/sandbox/buildGetSandboxesParams.ts b/lib/sandbox/buildGetSandboxesParams.ts index 0be16cec..a446597c 100644 --- a/lib/sandbox/buildGetSandboxesParams.ts +++ b/lib/sandbox/buildGetSandboxesParams.ts @@ -1,4 +1,5 @@ import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; import { RECOUP_ORG_ID } from "@/lib/const"; @@ -7,7 +8,7 @@ export interface BuildGetSandboxesParamsInput { account_id: string; /** The organization ID from the API key (null for personal keys) */ org_id: string | null; - /** Optional target account ID to filter by (access must be validated before calling) */ + /** Optional target account ID to filter by */ target_account_id?: string; /** Optional sandbox ID to filter by */ sandbox_id?: string; @@ -24,7 +25,7 @@ export type BuildGetSandboxesParamsResult = * For org keys: Fetches all org member accountIds and returns them * For Recoup admin key: Returns empty params to indicate ALL records * - * If target_account_id is provided, returns that account (access must be validated by caller). + * If target_account_id is provided, validates access and returns that account. * * @param input - The auth context and optional filters * @returns The params for selectAccountSandboxes or an error @@ -34,8 +35,17 @@ export async function buildGetSandboxesParams( ): Promise { const { account_id, org_id, target_account_id, sandbox_id } = input; - // Handle account_id filter if provided (access already validated by caller) + // Handle account_id filter if provided if (target_account_id) { + const hasAccess = await canAccessAccount({ orgId: org_id, targetAccountId: target_account_id }); + if (!hasAccess) { + return { + params: null, + error: org_id + ? "account_id is not a member of this organization" + : "Personal API keys cannot filter by account_id", + }; + } return { params: { accountIds: [target_account_id], sandboxId: sandbox_id }, error: null }; } diff --git a/lib/sandbox/validateGetSandboxesRequest.ts b/lib/sandbox/validateGetSandboxesRequest.ts index d5b7b5e2..7252e33d 100644 --- a/lib/sandbox/validateGetSandboxesRequest.ts +++ b/lib/sandbox/validateGetSandboxesRequest.ts @@ -4,7 +4,6 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import type { SelectAccountSandboxesParams } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; import { buildGetSandboxesParams } from "./buildGetSandboxesParams"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { z } from "zod"; const getSandboxesQuerySchema = z.object({ @@ -18,7 +17,7 @@ const getSandboxesQuerySchema = z.object({ * * Query parameters: * - sandbox_id: Filter to a specific sandbox (must belong to account/org) - * - account_id: Filter to a specific account (org API keys only) + * - account_id: Filter to a specific account (validated against org membership) * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or SelectAccountSandboxesParams @@ -55,39 +54,23 @@ export async function validateGetSandboxesRequest( const { accountId, orgId } = authResult; - // Check access when account_id filter is provided - if (targetAccountId) { - const hasAccess = await canAccessAccount({ orgId, targetAccountId }); - if (!hasAccess) { - return NextResponse.json( - { - status: "error", - error: orgId - ? "account_id is not a member of this organization" - : "Personal API keys cannot filter by account_id", - }, - { status: 403, headers: getCorsHeaders() }, - ); - } - } - - // Build params using buildGetSandboxesParams - const buildResult = await buildGetSandboxesParams({ + // Use shared function to build params + const { params, error } = await buildGetSandboxesParams({ account_id: accountId, org_id: orgId, target_account_id: targetAccountId, sandbox_id: sandboxId, }); - if (buildResult.error) { + if (error) { return NextResponse.json( { status: "error", - error: buildResult.error, + error, }, { status: 403, headers: getCorsHeaders() }, ); } - return buildResult.params; + return params; }