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
278 changes: 278 additions & 0 deletions .github/workflows/helm-chart-validation.yml

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions .github/workflows/release-helm-chart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,13 @@ jobs:
formbricks:
webappUrl: https://qa.example.com

llm:
enabled: true

taxonomy:
enabled: true
llm:
provider: openai-compatible
model: operator-selected-model
baseUrl: https://llm.example.com/v1
contextWindowTokens: "65536"
YAML

rendered="$(helm template qa charts/formbricks \
Expand All @@ -213,8 +215,12 @@ jobs:
"value: \"http://formbricks-taxonomy:8000\"" \
"name: HUB_INTERNAL_API_URL" \
"value: \"http://formbricks-hub:8080\"" \
"name: TAXONOMY_LLM_PROVIDER" \
"value: \"openai-compatible\"" \
"name: TAXONOMY_LLM_MODEL" \
"value: \"operator-selected-model\"" \
"name: TAXONOMY_LLM_BASE_URL" \
"value: \"http://qa-router-service:8000/v1\"" \
"value: \"https://llm.example.com/v1\"" \
"key: TAXONOMY_SERVICE_TOKEN" \
"key: HUB_INTERNAL_API_TOKEN"; do
if ! grep -F "$expected" <<< "$rendered" >/dev/null; then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ZContactAttributeKeyUpdateSchema,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { checkContactsEnabledApiV2 } from "@/modules/ee/license-check/lib/contacts-api-guard";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";

export const GET = async (
Expand All @@ -28,6 +29,11 @@ export const GET = async (
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;

const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId);
if (contactsNotEnabledError) {
return handleApiError(request, contactsNotEnabledError);
}

const res = await getContactAttributeKey(params.contactAttributeKeyId);

if (!res.ok) {
Expand Down Expand Up @@ -63,6 +69,11 @@ export const PUT = async (
auditLog.targetId = params.contactAttributeKeyId;
}

const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId);
if (contactsNotEnabledError) {
return handleApiError(request, contactsNotEnabledError, auditLog);
}

const res = await getContactAttributeKey(params.contactAttributeKeyId);

if (!res.ok) {
Expand Down Expand Up @@ -135,6 +146,11 @@ export const DELETE = async (
auditLog.targetId = params.contactAttributeKeyId;
}

const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId);
if (contactsNotEnabledError) {
return handleApiError(request, contactsNotEnabledError, auditLog);
}

const res = await getContactAttributeKey(params.contactAttributeKeyId);

if (!res.ok) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { resolveBodyIdsV2 } from "@/modules/api/v2/management/lib/workspace-resolver";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { checkContactsEnabledApiV2 } from "@/modules/ee/license-check/lib/contacts-api-guard";

export const GET = async (request: NextRequest) =>
authenticatedApiClient({
Expand All @@ -22,6 +23,11 @@ export const GET = async (request: NextRequest) =>
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;

const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId);
if (contactsNotEnabledError) {
return handleApiError(request, contactsNotEnabledError);
}

const workspaceIds = [
...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)),
];
Expand All @@ -47,9 +53,14 @@ export const POST = async (request: NextRequest) =>
if (!resolved.ok) throw resolved.error;
return { ...body, ...resolved.data };
},
handler: async ({ parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;

const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId);
if (contactsNotEnabledError) {
return handleApiError(request, contactsNotEnabledError, auditLog);
}

const createContactAttributeKeyResult = await createContactAttributeKey(body);

if (!createContactAttributeKeyResult.ok) {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/modules/ee/contacts/[contactId]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getWorkspaceIdFromSurveyId,
} from "@/lib/utils/helper";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement";
import { CONTACT_SURVEY_WORKSPACE_MISMATCH_ERROR_CODE } from "@/modules/ee/contacts/lib/personal-link-errors";

const ZGeneratePersonalSurveyLinkAction = z.object({
Expand Down Expand Up @@ -42,6 +43,8 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
],
});

await ensureContactsEnabled(organizationId);

// Cross-tenant guard: the survey must belong to the same workspace as the
// contact the caller was authorized against. Authorization above is derived
// from `contactId` only, so without this a caller could pass a `surveyId`
Expand Down
15 changes: 9 additions & 6 deletions apps/web/modules/ee/contacts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
Expand All @@ -14,7 +14,7 @@ import {
getWorkspaceIdFromContactId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement";
import { createContactsFromCSV, deleteContact, getContact, getContacts } from "./lib/contacts";
import { updateContactAttributes } from "./lib/update-contact-attributes";
import {
Expand Down Expand Up @@ -51,10 +51,7 @@ export const getContactsAction = authenticatedActionClient
],
});

const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this organization");
}
await ensureContactsEnabled(organizationId);

return getContacts(workspaceId, parsedInput.offset, parsedInput.searchValue);
});
Expand Down Expand Up @@ -84,6 +81,8 @@ export const deleteContactAction = authenticatedActionClient.inputSchema(ZContac
],
});

await ensureContactsEnabled(organizationId);

ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.contactId = parsedInput.contactId;

Expand Down Expand Up @@ -123,6 +122,8 @@ export const createContactsFromCSVAction = authenticatedActionClient
],
});

await ensureContactsEnabled(organizationId);

ctx.auditLoggingCtx.organizationId = organizationId;
const existingContactCount = await prisma.contact.count({
where: { workspaceId },
Expand Down Expand Up @@ -186,6 +187,8 @@ export const updateContactAttributesAction = authenticatedActionClient
],
});

await ensureContactsEnabled(organizationId);

ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.contactId = parsedInput.contactId;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { CONTACTS_API_V1_NOT_ENABLED_MESSAGE } from "@/modules/ee/contacts/lib/contacts-entitlement";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import {
deleteContactAttributeKey,
Expand All @@ -14,15 +16,22 @@ import { ZContactAttributeKeyUpdateInput } from "./types/contact-attribute-keys"

async function fetchAndAuthorizeContactAttributeKey(
attributeKeyId: string,
workspacePermissions: NonNullable<TApiKeyAuthentication>["workspacePermissions"],
authentication: NonNullable<TApiKeyAuthentication>,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
// Entitlement first, matching the plural route: without the contacts feature the caller may
// not interact with attribute keys at all, regardless of workspace permissions.
const isContactsEnabled = await getIsContactsEnabled(authentication.organizationId);
if (!isContactsEnabled) {
return { error: responses.forbiddenResponse(CONTACTS_API_V1_NOT_ENABLED_MESSAGE) };
}

const attributeKey = await getContactAttributeKey(attributeKeyId);
if (!attributeKey) {
return { error: responses.notFoundResponse("Attribute Key", attributeKeyId) };
}

if (!hasPermission(workspacePermissions, attributeKey.workspaceId, requiredPermission)) {
if (!hasPermission(authentication.workspacePermissions, attributeKey.workspaceId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}

Expand All @@ -42,7 +51,7 @@ export const GET = withV1ApiWrapper({

const result = await fetchAndAuthorizeContactAttributeKey(
params.contactAttributeKeyId,
authentication.workspacePermissions,
authentication,
"GET"
);
if (result.error) {
Expand All @@ -55,14 +64,6 @@ export const GET = withV1ApiWrapper({
response: responses.successResponse(result.attributeKey),
};
} catch (error) {
if (
error instanceof Error &&
error.message === "Contacts are only enabled for Enterprise Edition, please upgrade."
) {
return {
response: responses.forbiddenResponse(error.message),
};
}
return handleErrorResponse(error);
}
},
Expand All @@ -85,7 +86,7 @@ export const DELETE = withV1ApiWrapper({
try {
const result = await fetchAndAuthorizeContactAttributeKey(
params.contactAttributeKeyId,
authentication.workspacePermissions,
authentication,
"DELETE"
);

Expand Down Expand Up @@ -132,7 +133,7 @@ export const PUT = withV1ApiWrapper({
try {
const result = await fetchAndAuthorizeContactAttributeKey(
params.contactAttributeKeyId,
authentication.workspacePermissions,
authentication,
"PUT"
);
if (result.error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { CONTACTS_API_V1_NOT_ENABLED_MESSAGE } from "@/modules/ee/contacts/lib/contacts-entitlement";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { ZContactAttributeKeyCreateInput } from "./[contactAttributeKeyId]/types/contact-attribute-keys";
Expand All @@ -20,9 +21,7 @@ export const GET = withV1ApiWrapper({
const isContactsEnabled = await getIsContactsEnabled(authentication.organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"Contacts are only enabled for Enterprise Edition, please upgrade."
),
response: responses.forbiddenResponse(CONTACTS_API_V1_NOT_ENABLED_MESSAGE),
};
}

Expand Down Expand Up @@ -56,9 +55,7 @@ export const POST = withV1ApiWrapper({
const isContactsEnabled = await getIsContactsEnabled(authentication.organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"Contacts are only enabled for Enterprise Edition, please upgrade."
),
response: responses.forbiddenResponse(CONTACTS_API_V1_NOT_ENABLED_MESSAGE),
};
}

Expand Down
7 changes: 7 additions & 0 deletions apps/web/modules/ee/contacts/attributes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getContactAttributeKeyById,
updateContactAttributeKey,
} from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement";

const ZCreateContactAttributeKeyAction = z.object({
workspaceId: ZId,
Expand Down Expand Up @@ -60,6 +61,8 @@ export const createContactAttributeKeyAction = authenticatedActionClient
],
});

await ensureContactsEnabled(organizationId);

ctx.auditLoggingCtx.organizationId = organizationId;

const contactAttributeKey = await createContactAttributeKey({
Expand Down Expand Up @@ -122,6 +125,8 @@ export const updateContactAttributeKeyAction = authenticatedActionClient
],
});

await ensureContactsEnabled(organizationId);

ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = existingKey;

Expand Down Expand Up @@ -169,6 +174,8 @@ export const deleteContactAttributeKeyAction = authenticatedActionClient
],
});

await ensureContactsEnabled(organizationId);

ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = existingKey;

Expand Down
32 changes: 32 additions & 0 deletions apps/web/modules/ee/contacts/lib/contacts-entitlement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError } from "@formbricks/types/errors";

const mocks = vi.hoisted(() => ({
getIsContactsEnabled: vi.fn(),
}));

vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));

const { CONTACTS_NOT_ENABLED_MESSAGE, ensureContactsEnabled } = await import("./contacts-entitlement");

describe("ensureContactsEnabled", () => {
beforeEach(() => {
vi.clearAllMocks();
});

test("throws OperationNotAllowedError when the entitlement is missing", async () => {
mocks.getIsContactsEnabled.mockResolvedValue(false);

await expect(ensureContactsEnabled("org1")).rejects.toThrow(OperationNotAllowedError);
await expect(ensureContactsEnabled("org1")).rejects.toThrow(CONTACTS_NOT_ENABLED_MESSAGE);
expect(mocks.getIsContactsEnabled).toHaveBeenCalledWith("org1");
});

test("resolves when the entitlement is present", async () => {
mocks.getIsContactsEnabled.mockResolvedValue(true);

await expect(ensureContactsEnabled("org1")).resolves.toBeUndefined();
});
});
27 changes: 27 additions & 0 deletions apps/web/modules/ee/contacts/lib/contacts-entitlement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import "server-only";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";

export const CONTACTS_NOT_ENABLED_MESSAGE = "Contacts are not enabled for this organization";

/**
* The exact string the v1 management routes have always returned for a missing contacts
* entitlement — kept verbatim for API consumers that match on it.
*/
export const CONTACTS_API_V1_NOT_ENABLED_MESSAGE =
"Contacts are only enabled for Enterprise Edition, please upgrade.";

/**
* Module-boundary guard for the contacts (EE) entitlement, for server actions.
*
* Every server action in `modules/ee/contacts` that reads or writes contact data must call this
* right after authorization. The entitlement check used to be inlined per call site and rotted
* out of several write paths over time (it survived only in some siblings), so new call sites
* must go through this helper instead of re-inlining `getIsContactsEnabled`.
*/
export const ensureContactsEnabled = async (organizationId: string): Promise<void> => {
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError(CONTACTS_NOT_ENABLED_MESSAGE);
}
};
Loading
Loading