diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts index b22e8257..10aae90d 100644 --- a/app/api/organizations/route.ts +++ b/app/api/organizations/route.ts @@ -19,11 +19,16 @@ export async function OPTIONS() { * GET /api/organizations * * Retrieves all organizations an account belongs to. + * Requires authentication via x-api-key or Authorization bearer token. + * + * For personal keys: returns the key owner's organizations. + * For org keys: returns organizations for all accounts in the org. + * For Recoup admin: returns all organizations. * * Query parameters: - * - accountId (required): The account's ID (UUID) + * - account_id (optional): Filter to a specific account (org keys only) * - * @param request - The request object containing query parameters + * @param request - The request object * @returns A NextResponse with organizations data */ export async function GET(request: NextRequest) { @@ -45,4 +50,3 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { return createOrganizationHandler(request); } - diff --git a/lib/organizations/__tests__/buildGetOrganizationsParams.test.ts b/lib/organizations/__tests__/buildGetOrganizationsParams.test.ts new file mode 100644 index 00000000..34f615f3 --- /dev/null +++ b/lib/organizations/__tests__/buildGetOrganizationsParams.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildGetOrganizationsParams } from "../buildGetOrganizationsParams"; + +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-org-id", +})); + +describe("buildGetOrganizationsParams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns accountId for personal key", async () => { + const result = await buildGetOrganizationsParams({ + accountId: "personal-account-123", + orgId: null, + }); + + expect(result).toEqual({ + params: { accountId: "personal-account-123" }, + error: null, + }); + }); + + it("returns organizationId for org key", async () => { + const result = await buildGetOrganizationsParams({ + accountId: "org-123", + orgId: "org-123", + }); + + expect(result).toEqual({ + params: { organizationId: "org-123" }, + error: null, + }); + }); + + it("returns empty params for Recoup admin key", async () => { + const result = await buildGetOrganizationsParams({ + accountId: "recoup-org-id", + orgId: "recoup-org-id", + }); + + expect(result).toEqual({ + params: {}, + error: null, + }); + }); + + it("returns targetAccountId when access is granted", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetOrganizationsParams({ + accountId: "org-123", + orgId: "org-123", + targetAccountId: "target-456", + }); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "org-123", + targetAccountId: "target-456", + }); + expect(result).toEqual({ + params: { accountId: "target-456" }, + error: null, + }); + }); + + it("returns error when personal key tries to filter by targetAccountId", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetOrganizationsParams({ + accountId: "personal-123", + orgId: null, + targetAccountId: "other-account", + }); + + expect(result).toEqual({ + params: null, + error: "Personal API keys cannot filter by account_id", + }); + }); + + it("returns error when org key lacks access to targetAccountId", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await buildGetOrganizationsParams({ + accountId: "org-123", + orgId: "org-123", + targetAccountId: "not-in-org", + }); + + expect(result).toEqual({ + params: null, + error: "account_id is not a member of this organization", + }); + }); + + it("returns targetAccountId for Recoup admin with filter", async () => { + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await buildGetOrganizationsParams({ + accountId: "recoup-org-id", + orgId: "recoup-org-id", + targetAccountId: "any-account", + }); + + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "recoup-org-id", + targetAccountId: "any-account", + }); + expect(result).toEqual({ + params: { accountId: "any-account" }, + error: null, + }); + }); +}); diff --git a/lib/organizations/buildGetOrganizationsParams.ts b/lib/organizations/buildGetOrganizationsParams.ts new file mode 100644 index 00000000..d5bbf244 --- /dev/null +++ b/lib/organizations/buildGetOrganizationsParams.ts @@ -0,0 +1,62 @@ +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import type { GetAccountOrganizationsParams } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import { RECOUP_ORG_ID } from "@/lib/const"; + +export interface BuildGetOrganizationsParamsInput { + /** The authenticated account ID */ + accountId: string; + /** The organization ID from the API key (null for personal keys) */ + orgId: string | null; + /** Optional target account ID to filter by */ + targetAccountId?: string; +} + +export type BuildGetOrganizationsParamsResult = + | { params: GetAccountOrganizationsParams; error: null } + | { params: null; error: string }; + +/** + * Builds the parameters for getAccountOrganizations based on auth context. + * + * For personal keys: Returns accountId with the key owner's account + * For org keys: Returns organizationId for filtering by org membership + * For Recoup admin key: Returns empty params to indicate ALL records + * + * If targetAccountId is provided, validates access and returns that account. + * + * @param input - The auth context and optional filters + * @returns The params for getAccountOrganizations or an error + */ +export async function buildGetOrganizationsParams( + input: BuildGetOrganizationsParamsInput, +): Promise { + const { accountId, orgId, targetAccountId } = input; + + // Handle account_id filter if provided + if (targetAccountId) { + const hasAccess = await canAccessAccount({ orgId, targetAccountId }); + if (!hasAccess) { + return { + params: null, + error: orgId + ? "account_id is not a member of this organization" + : "Personal API keys cannot filter by account_id", + }; + } + return { params: { accountId: targetAccountId }, error: null }; + } + + // No account_id filter - determine what to return based on key type + if (orgId === RECOUP_ORG_ID) { + // Recoup admin: return empty params to indicate ALL records + return { params: {}, error: null }; + } + + if (orgId) { + // Org key: return organizationId for filtering by org membership + return { params: { organizationId: orgId }, error: null }; + } + + // Personal key: Only return the key owner's organizations + return { params: { accountId }, error: null }; +} diff --git a/lib/organizations/getOrganizationsHandler.ts b/lib/organizations/getOrganizationsHandler.ts index 93a67cbe..f0061c3a 100644 --- a/lib/organizations/getOrganizationsHandler.ts +++ b/lib/organizations/getOrganizationsHandler.ts @@ -1,30 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateOrganizationsQuery } from "@/lib/organizations/validateOrganizationsQuery"; +import { validateGetOrganizationsRequest } from "@/lib/organizations/validateGetOrganizationsRequest"; import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; import { formatAccountOrganizations } from "@/lib/organizations/formatAccountOrganizations"; /** * Handler for retrieving organizations for an account. * - * Query parameters: - * - accountId (required): The account ID to get organizations for + * Authenticates via x-api-key or Authorization bearer token. + * For personal keys: returns the key owner's organizations. + * For org keys: returns organizations for all accounts in the org. + * For Recoup admin: returns all organizations. * - * @param request - The request object containing query parameters + * Optional query parameter: + * - account_id: Filter to a specific account (org keys only) + * + * @param request - The request object * @returns A NextResponse with organizations data */ export async function getOrganizationsHandler(request: NextRequest): Promise { try { - const { searchParams } = new URL(request.url); - - const validatedQuery = validateOrganizationsQuery(searchParams); - if (validatedQuery instanceof NextResponse) { - return validatedQuery; + const validated = await validateGetOrganizationsRequest(request); + if (validated instanceof NextResponse) { + return validated; } - const rawOrgs = await getAccountOrganizations({ - accountId: validatedQuery.accountId, - }); + const rawOrgs = await getAccountOrganizations(validated); const organizations = formatAccountOrganizations(rawOrgs); return NextResponse.json( diff --git a/lib/organizations/validateGetOrganizationsRequest.ts b/lib/organizations/validateGetOrganizationsRequest.ts new file mode 100644 index 00000000..87a6858c --- /dev/null +++ b/lib/organizations/validateGetOrganizationsRequest.ts @@ -0,0 +1,76 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { GetAccountOrganizationsParams } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import { buildGetOrganizationsParams } from "./buildGetOrganizationsParams"; +import { z } from "zod"; + +const getOrganizationsQuerySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + +/** + * Validates GET /api/organizations request. + * Handles authentication via x-api-key or Authorization bearer token. + * + * For personal keys: Returns accountId with the key owner's account + * For org keys: Returns organizationId for filtering by org membership + * For Recoup admin key: Returns empty params to indicate ALL organization records + * + * Query parameters: + * - 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 GetAccountOrganizationsParams + */ +export async function validateGetOrganizationsRequest( + request: NextRequest, +): Promise { + // Parse query parameters first + const { searchParams } = new URL(request.url); + const queryParams = { + account_id: searchParams.get("account_id") ?? undefined, + }; + + const queryResult = getOrganizationsQuerySchema.safeParse(queryParams); + if (!queryResult.success) { + const firstError = queryResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const { account_id: targetAccountId } = queryResult.data; + + // Use validateAuthContext for authentication + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId, orgId } = authResult; + + // Use shared function to build params + const { params, error } = await buildGetOrganizationsParams({ + accountId, + orgId, + targetAccountId, + }); + + if (error) { + return NextResponse.json( + { + status: "error", + error, + }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return params; +} diff --git a/lib/organizations/validateOrganizationsQuery.ts b/lib/organizations/validateOrganizationsQuery.ts deleted file mode 100644 index e7f93f85..00000000 --- a/lib/organizations/validateOrganizationsQuery.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const organizationsQuerySchema = z.object({ - accountId: z.string({ message: "accountId is required" }).uuid("accountId must be a valid UUID"), -}); - -export type OrganizationsQuery = z.infer; - -/** - * Validates query parameters for GET /api/organizations. - * - * @param searchParams - The URL search parameters - * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. - */ -export function validateOrganizationsQuery( - searchParams: URLSearchParams, -): NextResponse | OrganizationsQuery { - const params = Object.fromEntries(searchParams.entries()); - const result = organizationsQuerySchema.safeParse(params); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} -