Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions apps/web/app/api/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe("POST /api/mcp", () => {
expect(response.status).toBe(401);
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
expect(response.headers.get("WWW-Authenticate")).toBe(
'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"'
'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp", scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"'
);
expect(applyIPRateLimit).toHaveBeenCalled();
});
Expand Down Expand Up @@ -445,7 +445,7 @@ describe("POST /api/mcp", () => {
expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled();
expect(applyIPRateLimit).toHaveBeenCalled();
expect(response.headers.get("WWW-Authenticate")).toBe(
'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"'
'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp", scope="surveys:read surveys:write workflows:read workflows:write feedbackRecords:read feedbackRecords:write"'
);
});

Expand Down Expand Up @@ -485,7 +485,9 @@ describe("POST /api/mcp", () => {
expect(message.result.structuredContent.error).toMatchObject({
status: 403,
code: "forbidden",
detail: "OAuth token does not include the required MCP scope",
// Names the scope this specific call needed, not just that some scope was missing — that is
// the only thing the client can act on.
detail: "OAuth token does not include the required MCP scope: surveys:write",
requestId: "req_read_only",
});
});
Expand Down Expand Up @@ -528,7 +530,9 @@ describe("POST /api/mcp", () => {
expect(message.result.structuredContent.error).toMatchObject({
status: 403,
code: "forbidden",
detail: "OAuth token does not include the required MCP scope",
// The refusal names the scope the client must obtain, since a JSON-RPC tool result carries no
// WWW-Authenticate header for it to read.
detail: "OAuth token does not include the required MCP scope: workflows:write",
requestId: "req_wf_read_only",
});
// The scope gate must fire BEFORE any mutation side effect: no audit log is built or queued for a
Expand Down
51 changes: 34 additions & 17 deletions apps/web/app/api/v3/workflows/lib/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types";
import { getOrganizationMemberEmails } from "@/lib/organization/service";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspaceMemberEmails } from "@/lib/workspace/service";
import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils";
import { buildWorkflowApiContext } from "./context";

Expand All @@ -16,7 +16,7 @@ vi.mock("@formbricks/logger", () => ({
}));
vi.mock("@/app/api/v3/lib/auth", () => ({ requireV3WorkspaceAccess: vi.fn() }));
vi.mock("@/lib/utils/helper", () => ({ getOrganizationIdFromWorkspaceId: vi.fn() }));
vi.mock("@/lib/organization/service", () => ({ getOrganizationMemberEmails: vi.fn() }));
vi.mock("@/lib/workspace/service", () => ({ getWorkspaceMemberEmails: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsWorkflowsEnabled: vi.fn() }));

const baseAuditLog = (): TV3AuditLog => ({
Expand Down Expand Up @@ -155,31 +155,48 @@ describe("verifyTriggerSurvey (validates a workflow trigger's referenced survey)
});
});

describe("verifyRecipientsAllowed (recipient allowlist for send_email, ENG-2029)", () => {
test("returns the literal recipients that are not organization members (case-insensitive)", async () => {
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1");
vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["member@corp.example"]));

const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({
describe("verifyRecipientsAllowed (recipient allowlist for send_email, ENG-2029 + ENG-2186)", () => {
const verifyRecipients = (emails: string[]) =>
buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({
workspaceId: "ws_1",
emails: ["Member@corp.example", "attacker@external-evil.example"],
emails,
});

expect(getOrganizationMemberEmails).toHaveBeenCalledWith("org_1");
test("returns the literal recipients that cannot access the workspace (case-insensitive)", async () => {
vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set(["member@corp.example"]));

const result = await verifyRecipients(["Member@corp.example", "attacker@external-evil.example"]);

expect(result).toEqual({ disallowedEmails: ["attacker@external-evil.example"] });
});

test("allows all recipients when each is an organization member", async () => {
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_1");
vi.mocked(getOrganizationMemberEmails).mockResolvedValue(new Set(["a@corp.example", "b@corp.example"]));
test("allows all recipients when each can access the workspace", async () => {
vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set(["a@corp.example", "b@corp.example"]));

const result = await buildWorkflowApiContext(apiKeyAuth, "req_1", "inst").verifyRecipientsAllowed({
workspaceId: "ws_1",
emails: ["a@corp.example", "b@corp.example"],
});
const result = await verifyRecipients(["a@corp.example", "b@corp.example"]);

expect(result).toEqual({ disallowedEmails: [] });
});

test("scopes the allowlist to the workspace, not to its organization (ENG-2186)", async () => {
// The gate must ask who can access *this workspace*: an org member whose team lost access to it
// is rejected here, matching what the authoring picker offers and what the runner will send.
vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set(["still-has-access@corp.example"]));

const result = await verifyRecipients(["revoked-member@corp.example"]);

expect(getWorkspaceMemberEmails).toHaveBeenCalledWith("ws_1");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
expect(result).toEqual({ disallowedEmails: ["revoked-member@corp.example"] });
});

test("rejects every literal recipient when the workspace resolves to nobody (fails closed)", async () => {
vi.mocked(getWorkspaceMemberEmails).mockResolvedValue(new Set());

await expect(verifyRecipients(["member@corp.example"])).resolves.toEqual({
disallowedEmails: ["member@corp.example"],
});
});
});

describe("recordAudit (binds the audit sink to the request's audit log)", () => {
Expand Down
14 changes: 8 additions & 6 deletions apps/web/app/api/v3/workflows/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden } from "@/app/api/v3/lib/response";
import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { getOrganizationMemberEmails } from "@/lib/organization/service";
import { normalizeEmailForComparison } from "@/lib/utils/email";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspaceMemberEmails } from "@/lib/workspace/service";
import { getIsWorkflowsEnabled } from "@/modules/ee/license-check/lib/utils";

/**
Expand Down Expand Up @@ -94,16 +94,18 @@ const buildRecordAudit =

/**
* Recipient allowlist for `send_email` actions. Injected so `@formbricks/workflows` stays
* organization-agnostic: given literal recipient emails, returns the subset that does NOT belong to
* the workspace's organization. Enable/test use it to block a workflow from silently forwarding
* response data to an arbitrary external inbox (ENG-2029). Emails are compared case-insensitively.
* tenancy-agnostic: given literal recipient emails, returns the subset whose owners cannot access
* this workspace. Enable/test use it to block a workflow from silently forwarding response data to
* an arbitrary external inbox (ENG-2029) or to someone whose access to this workspace was revoked
* (ENG-2186). Scoped to the workspace — not merely to its organization — so it matches both the
* authoring picker's options and the runner's send-time backstop. Emails are compared
* case-insensitively.
*/
const verifyRecipientsAllowed: WorkflowApiContext["verifyRecipientsAllowed"] = async ({
workspaceId,
emails,
}) => {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const memberEmails = await getOrganizationMemberEmails(organizationId);
const memberEmails = await getWorkspaceMemberEmails(workspaceId);
const disallowedEmails = emails.filter((email) => !memberEmails.has(normalizeEmailForComparison(email)));
return { disallowedEmails };
};
Expand Down
8 changes: 7 additions & 1 deletion apps/web/i18n.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2484,7 +2484,7 @@ checksums:
workspace/settings/billing/plan_scale_feature_responses: f2be033ebf6c86a664b812b4a918647f
workspace/settings/billing/plan_scale_feature_security: 6671961cf8d8413d1740b13901bcc033
workspace/settings/billing/plan_scale_feature_semantic_analysis: 1441e34cacd26f0aa27af4ebad6e5c54
workspace/settings/billing/plan_scale_feature_workflows: b0c9c8615a9ba7d9cb73e767290a7f72
workspace/settings/billing/plan_scale_feature_workflow_runs: 22007bc0ad530bfd56b8a3788bdef02f
workspace/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef
workspace/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599
workspace/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d
Expand Down Expand Up @@ -2525,6 +2525,7 @@ checksums:
workspace/settings/billing/trial_warning_add_payment_method: 5e6879babc7acb05a258de64fef57262
workspace/settings/billing/trial_warning_remind_me_later: a12f38fb4352c31ca0d43c05303b7257
workspace/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
workspace/settings/billing/unlimited_workflow_runs: 61221f0506d1bf247f732a31959dc146
workspace/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
workspace/settings/billing/unlock_all_plan_features: f494466ed4d974763434fb15c0d63750
workspace/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
Expand Down Expand Up @@ -4031,6 +4032,8 @@ checksums:
workspace/workflows/archive_workflow_confirmation: 83f351c186e67a3c5308af82bf971d65
workspace/workflows/archive_workflow_description: 06e437998a762e504d7d0101b0c53565
workspace/workflows/auto_layout: 553d4f054655684130a8b5bc8f800bf0
workspace/workflows/autosave_blocked: 675161fc3d48278839a611e2795b9725
workspace/workflows/autosave_blocked_tooltip: b5346c95ec8070002565866317f75f06
workspace/workflows/autosave_failed: 5d9b22b31b83828d5eeaeae87d2b6d14
workspace/workflows/autosave_failed_tooltip: f4cb6255fd863b741275a7ff6d2701c6
workspace/workflows/autosave_failed_tooltip_rejected: 5f90aa698c936629a7b959520dc9358e
Expand Down Expand Up @@ -4061,6 +4064,8 @@ checksums:
workspace/workflows/email_to_label: fc84f35b3c44796dfcdd4b096c9b8d3d
workspace/workflows/email_to_placeholder: 1323bfdf1926a863c93bc0b37ae61218
workspace/workflows/email_to_required: d29dd2c5bdebdde1047f4baae12269a4
workspace/workflows/email_to_unavailable_group_label: ce6c023dd8cad961910a0bd96cc40f5c
workspace/workflows/email_to_unavailable_warning: e52afafce65a359b1a5e71257730aa8b
workspace/workflows/enable_blocked_unsaved_changes: 07fc359db9158b09d612f4f0e0e2df5e
workspace/workflows/enable_failed: 997437c65cc018c4d3cf7622e043d48d
workspace/workflows/enable_success: 384d5e80e013c74f8e092f41ed5bae5c
Expand All @@ -4069,6 +4074,7 @@ checksums:
workspace/workflows/if_else_summary: f166c79dcfd8a9595ac364c96caaf7e1
workspace/workflows/inspector_unsupported_node: 478f0de84863ccf95702428b1cc95be0
workspace/workflows/load_failed: 4e79a59e05cfc390294673e4aa111f02
workspace/workflows/name_invalid: c1f750725318d5e385443519730729da
workspace/workflows/name_required: a2866fa94293bc08be10ef65e0939423
workspace/workflows/no_results_description: b358733531cc8290f8b311a8cc3bf05d
workspace/workflows/no_results_title: e3f2c57c4024d33a20f192d45284d709
Expand Down
93 changes: 53 additions & 40 deletions apps/web/lib/organization/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import { Prisma } from "@formbricks/database/prisma";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
import { getWorkspaces } from "@/lib/workspace/service";
import {
cleanupStripeCustomer,
ensureCloudStripeSetupForOrganization,
} from "@/modules/ee/billing/lib/organization-billing";
import {
createOrganization,
deleteOrganization,
getMonthlyOrganizationWorkflowRunCount,
getOrganization,
getOrganizationMemberEmails,
getOrganizationsByUserId,
select as organizationSelect,
subscribeOrganizationMembersToSurveyResponses,
Expand All @@ -35,8 +36,8 @@ vi.mock("@formbricks/database", () => ({
user: {
findUnique: vi.fn(),
},
membership: {
findMany: vi.fn(),
workflowRun: {
aggregate: vi.fn(),
},
},
}));
Expand All @@ -45,6 +46,10 @@ vi.mock("@/lib/user/service", () => ({
updateUser: vi.fn(),
}));

vi.mock("@/lib/workspace/service", () => ({
getWorkspaces: vi.fn(),
}));

vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
ensureCloudStripeSetupForOrganization: vi.fn().mockResolvedValue(undefined),
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -415,50 +420,58 @@ describe("Organization Service", () => {
});
});

describe("getOrganizationMemberEmails (send_email recipient allowlist, ENG-2029)", () => {
test("queries only active members of the organization", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);

await getOrganizationMemberEmails("org_1");

expect(prisma.membership.findMany).toHaveBeenCalledWith({
where: { organizationId: "org_1", user: { isActive: true } },
select: { user: { select: { email: true } } },
});
});

test("returns a lowercased, whitespace-trimmed set for case-insensitive matching", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ user: { email: " Member@Corp.Example " } },
{ user: { email: "second@corp.example" } },
] as never);

const result = await getOrganizationMemberEmails("org_1");

expect(result).toEqual(new Set(["member@corp.example", "second@corp.example"]));
describe("getMonthlyOrganizationWorkflowRunCount", () => {
const mockOrganization = {
id: "org_1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: "cus_1",
limits: { workspaces: 5, monthly: { responses: 5000, workflowRuns: 1000 } },
usageCycleAnchor: null,
stripe: null,
},
isAISmartToolsEnabled: false,
whitelabel: null,
};

test("counts non-dry workflow runs across the organization's workspaces in the billing cycle", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never);
vi.mocked(getWorkspaces).mockResolvedValue([{ id: "ws_1" }, { id: "ws_2" }] as never);
vi.mocked(prisma.workflowRun.aggregate).mockResolvedValue({ _count: { id: 42 } } as never);

const result = await getMonthlyOrganizationWorkflowRunCount("cms634kob000001uzrelh0qeb");

expect(result).toBe(42);
const aggregateArgs = vi.mocked(prisma.workflowRun.aggregate).mock.calls[0][0];
expect(aggregateArgs.where?.AND).toEqual(
expect.arrayContaining([
{ workspaceId: { in: ["ws_1", "ws_2"] } },
{ isDryRun: false },
expect.objectContaining({ createdAt: expect.any(Object) }),
])
);
});

test("drops memberships with a missing user or empty email", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ user: { email: "kept@corp.example" } },
{ user: null },
{ user: { email: null } },
{ user: { email: "" } },
] as never);

const result = await getOrganizationMemberEmails("org_1");
test("throws ResourceNotFoundError when the organization does not exist", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);

expect(result).toEqual(new Set(["kept@corp.example"]));
await expect(getMonthlyOrganizationWorkflowRunCount("cmmissingorg00000000000a")).rejects.toThrow(
ResourceNotFoundError
);
});

test("wraps a known Prisma error in DatabaseError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db down", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError);
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as never);
vi.mocked(getWorkspaces).mockResolvedValue([{ id: "ws_1" }] as never);
vi.mocked(prisma.workflowRun.aggregate).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("db down", { code: "P2002", clientVersion: "1.0.0" })
);

await expect(getOrganizationMemberEmails("org_1")).rejects.toThrow(DatabaseError);
await expect(getMonthlyOrganizationWorkflowRunCount("cms634kob000001uzrelh0qeb")).rejects.toThrow(
DatabaseError
);
});
});
});
Loading
Loading