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
87 changes: 87 additions & 0 deletions apps/web/modules/workspaces/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TMembership, TOrganizationRole } from "@formbricks/types/memberships";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getSession } from "@/modules/auth/lib/session";
import { getWorkspacePermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getWorkspaceAuth } from "./utils";

const mocks = vi.hoisted(() => ({ isFormbricksCloud: false }));

// Real getAccessFlags is used on purpose so the tests exercise the actual role -> isBilling mapping
// that the redirect branch keys off of. Everything else getWorkspaceAuth touches is stubbed.
vi.mock("react", () => ({ cache: (fn: (...args: unknown[]) => unknown) => fn }));
vi.mock("@/lib/constants", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/constants")>()),
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/workspace/service", () => ({ getWorkspace: vi.fn() }));
vi.mock("@/lib/organization/service", () => ({ getOrganization: vi.fn() }));
vi.mock("@/lib/membership/service", () => ({ getMembershipByUserIdOrganizationId: vi.fn() }));
vi.mock("@/lib/membership/navigation", () => ({ getBillingFallbackPath: vi.fn() }));
vi.mock("@/lingodotdev/server", () => ({ getTranslate: vi.fn(() => Promise.resolve((k: string) => k)) }));
vi.mock("@/modules/auth/lib/session", () => ({ getSession: vi.fn() }));
vi.mock("@/modules/ee/teams/lib/roles", () => ({ getWorkspacePermissionByUserId: vi.fn() }));
vi.mock("@/modules/ee/teams/utils/teams", () => ({
getTeamPermissionFlags: vi.fn(() => ({
hasReadAccess: false,
hasReadWriteAccess: false,
hasManageAccess: false,
})),
}));

const workspaceId = "workspace-1";
const organizationId = "organization-1";
const billingFallbackPath = `/organizations/${organizationId}/settings/enterprise`;

const primeAuth = (role: TOrganizationRole) => {
vi.mocked(getWorkspace).mockResolvedValue({ id: workspaceId, organizationId } as Awaited<
ReturnType<typeof getWorkspace>
>);
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1" },
expires: new Date(0).toISOString(),
} as Awaited<ReturnType<typeof getSession>>);
vi.mocked(getOrganization).mockResolvedValue({ id: organizationId } as Awaited<
ReturnType<typeof getOrganization>
>);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
role,
organizationId,
userId: "user-1",
accepted: true,
} as TMembership);
vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null);
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
};

describe("getWorkspaceAuth billing gate (ENG-1763)", () => {
beforeEach(() => {
vi.clearAllMocks();
});

test("redirects a billing-role member to the billing fallback path", async () => {
primeAuth("billing");

await getWorkspaceAuth(workspaceId);

expect(getBillingFallbackPath).toHaveBeenCalledWith(organizationId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});

test.each<TOrganizationRole>(["owner", "manager", "member"])(
"does not redirect a %s member",
async (role) => {
primeAuth(role);

const result = await getWorkspaceAuth(workspaceId);

expect(redirect).not.toHaveBeenCalled();
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(result.isBilling).toBe(false);
}
);
});
11 changes: 11 additions & 0 deletions apps/web/modules/workspaces/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { redirect } from "next/navigation";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/prisma";
Expand All @@ -10,6 +11,7 @@ import {
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getMonthlyOrganizationResponseCount, getOrganization } from "@/lib/organization/service";
Expand Down Expand Up @@ -56,6 +58,15 @@ export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise<

const { isMember, isOwner, isManager, isBilling } = getAccessFlags(currentUserMembership.role);

// Billing-role members are scoped to billing/enterprise screens only. They must never reach
// workspace product data (contacts PII, survey summaries/responses, dashboards). This is the
// single choke point every product page flows through, so gating here closes all of them at
// once and keeps this helper aligned with hasUserWorkspaceAccessForAction, which already denies
// billing. Individual pages that also guard billing inline remain correct (defense in depth).
if (isBilling) {
redirect(getBillingFallbackPath(organization.id, IS_FORMBRICKS_CLOUD));
}

const workspacePermission = await getWorkspacePermissionByUserId(session.user.id, workspace.id);

const { hasReadAccess, hasReadWriteAccess, hasManageAccess } = getTeamPermissionFlags(workspacePermission);
Expand Down
76 changes: 76 additions & 0 deletions apps/web/playwright/billing-role-access.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect } from "@playwright/test";
import { prisma } from "@formbricks/database";
import type { UsersFixture } from "./fixtures/users";
import { test } from "./lib/fixtures";

// ENG-1763 regression: a billing-role member must not reach workspace product data (contacts PII,
// survey summaries/responses, dashboards) by direct navigation. getWorkspaceAuth is the single choke
// point every product page flows through, so it bounces billing users to their org billing/enterprise
// home instead. The owner test guards against a naive "redirect everyone" fix regressing product
// access for legitimate roles.

// Creates an organization (owned by `owner`) with a workspace holding product data, plus a second
// user who is a billing-role member of that same organization.
const setupOrgWithBillingMember = async (users: UsersFixture) => {
const owner = await users.create();
if (!owner.organizationId || !owner.workspaceId) {
throw new Error("Owner org/workspace not seeded for test");
}

const billingUser = await users.create({ withoutWorkspace: true });
await prisma.membership.create({
data: {
userId: billingUser.id,
organizationId: owner.organizationId,
role: "billing",
accepted: true,
},
});

// users.create seeds a survey in the workspace; it backs the survey summary/responses URLs below.
const survey = await prisma.survey.findFirstOrThrow({
where: { workspaceId: owner.workspaceId },
select: { id: true },
});

const workspaceBase = `/workspaces/${owner.workspaceId}`;
return {
owner,
billingUser,
workspaceId: owner.workspaceId,
contactsUrl: `${workspaceBase}/contacts`,
// Every workspace product surface flows through getWorkspaceAuth, so all of them must redirect.
productUrls: [
`${workspaceBase}/contacts`,
`${workspaceBase}/dashboards`,
`${workspaceBase}/surveys/${survey.id}/summary`,
`${workspaceBase}/surveys/${survey.id}/responses`,
],
};
};

test.describe("Billing-role workspace access (ENG-1763)", () => {
const billingHomeUrl = /\/organizations\/[^/]+\/settings\/(billing|enterprise)/;

test("redirects a billing member off product pages to the billing home", async ({ page, users }) => {
const { billingUser, productUrls } = await setupOrgWithBillingMember(users);

await billingUser.login();

for (const url of productUrls) {
await page.goto(url, { waitUntil: "domcontentloaded" });
await expect(page, `billing member should be redirected off ${url}`).toHaveURL(billingHomeUrl);
}
});

test("keeps product-page access for a non-billing owner", async ({ page, users }) => {
const { owner, workspaceId, contactsUrl } = await setupOrgWithBillingMember(users);

await owner.login();
await page.goto(contactsUrl, { waitUntil: "domcontentloaded" });

// Owner stays on the product route (the page may render an upgrade prompt when the enterprise
// feature is unlicensed, but crucially it is not bounced to the billing home).
await expect(page).toHaveURL(new RegExp(`/workspaces/${workspaceId}/contacts`));
});
});
125 changes: 125 additions & 0 deletions docs/self-hosting/advanced/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,131 @@ description: "Formbricks Self-hosted version migration"
icon: "arrow-right"
---

## v5.2

Formbricks v5.2 migrates authentication from NextAuth to **Better Auth**. For most self-hosted
instances this is a drop-in upgrade, but **if you use SSO (Azure AD / Entra ID, generic OIDC, or SAML)
you must re-register the OAuth callback URL at your identity provider before the first v5.2 restart**,
or SSO sign-in will fail with a redirect-URI mismatch.

<Warning>
All users are signed out once when you upgrade to v5.2. Better Auth takes over session handling and
there is no dual-read from the old NextAuth sessions, so the cutover is a one-time forced re-login.
Passwords are preserved — existing credential hashes are migrated automatically, so no password reset
is needed.
</Warning>

### SSO Callback URL Change (action required for SSO users)

Better Auth serves the OAuth2/OIDC callback at a new path:

- old (NextAuth): `/api/auth/callback/{provider}`
- new (Better Auth): `/api/auth/oauth2/callback/{providerId}`

Update the redirect / callback URIs at your identity provider to the new path. Replace `https://your-domain.com`
with your instance URL:

| Provider | Where to update | New callback URL |
| --- | --- | --- |
| Azure AD / Entra ID | Azure portal -> App registration -> Authentication -> Redirect URIs | `https://your-domain.com/api/auth/oauth2/callback/azuread` |
| Generic OIDC | At your OIDC provider | `https://your-domain.com/api/auth/oauth2/callback/openid` |
| SAML (BoxyHQ / Jackson) | The Jackson connection's `redirect_uri` | `https://your-domain.com/api/auth/oauth2/callback/saml` |

<Note>
**Google and GitHub sign-in do not change.** They use Better Auth's built-in social providers, whose
callback path (`/api/auth/callback/{provider}`) is unchanged — nothing to do there.

For SAML, only the Jackson connection's `redirect_uri` changes. The IdP-side ACS endpoint
(`/api/auth/saml/callback`) is unchanged, so you do **not** need to reconfigure your IdP's SAML app.

Leave the old `/api/auth/callback/*` redirect URIs registered through the transition. Keeping them in
place is harmless and makes a rollback to v5.1 clean.
</Note>

### Authentication Environment Variables

Better Auth introduces two optional variables. **Neither is required** for a standard upgrade — both fall
back to your existing NextAuth configuration:

- `BETTER_AUTH_SECRET` — cookie/session signing secret. Falls back to `NEXTAUTH_SECRET` when unset. If you
set it explicitly it must be at least 32 characters. Keeping the `NEXTAUTH_SECRET` fallback is
recommended so the value the forward-auth proxy already verifies with stays consistent.
- `BETTER_AUTH_URL` — the auth base URL. Falls back to `NEXTAUTH_URL` (and then `WEBAPP_URL`) when unset.

<Info>
If your instance already sets `NEXTAUTH_SECRET` and `NEXTAUTH_URL` (all v5.1 instances do), you can
upgrade without adding any new auth variables.
</Info>

**Removed:** `DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION` no longer exists. Remove it from your environment
if you set it; it now has no effect.

### Optional New Configuration

None of the following is required to run v5.2. Add them only if you want the associated feature.

#### Hub Enrichment (sentiment, emotion, translation)

Hub `>= 0.8.0` can classify sentiment and emotion and translate feedback. These are **off** unless you set
a provider and model, and they run in both the Hub API and the hub-worker, so set the variables on **both**
services. Bump your Hub image to `>= 0.8.0` (via `HUB_IMAGE_REF`) to pick up these features.

- `SENTIMENT_PROVIDER`, `SENTIMENT_MODEL`, `SENTIMENT_PROVIDER_API_KEY`
- `EMOTIONS_PROVIDER`, `EMOTIONS_MODEL`, `EMOTIONS_PROVIDER_API_KEY`
- `TRANSLATION_PROVIDER`, `TRANSLATION_MODEL`, `TRANSLATION_PROVIDER_API_KEY`, `TRANSLATION_DEFAULT_LANGUAGE`

Supported providers: `openai`, `google` (AI Studio, needs `*_PROVIDER_API_KEY`), and `google-gemini`
(Vertex, needs `*_GOOGLE_CLOUD_PROJECT` / `*_LOCATION` plus Application Default Credentials).

<Warning>
A provider set **without** its required credentials crash-loops both Hub containers on startup — it does
not degrade gracefully. Set the credentials, or leave the provider unset.
</Warning>

#### AI Taxonomy (beta)

An optional standalone taxonomy service, enabled in Docker Compose with `COMPOSE_PROFILES=taxonomy`. It
shares an internal token with Hub:

- `TAXONOMY_SERVICE_URL` (the internal URL to the taxonomy service)
- `HUB_INTERNAL_API_TOKEN`, `TAXONOMY_SERVICE_TOKEN` (strong random secrets)
- `TAXONOMY_LLM_PROVIDER` / `TAXONOMY_LLM_MODEL` / `TAXONOMY_LLM_BASE_URL` / `TAXONOMY_LLM_API_KEY`
(`openai-compatible` by default; `bedrock` and `vertex-gemini` also supported)
- `TAXONOMY_IMAGE_REF` to pin a released image (e.g. `:v0.1.0`)

Use `COMPOSE_PROFILES=qwen,taxonomy` to back it with the bundled Qwen/vLLM service.

#### Bundled Qwen / vLLM Runtime

Docker Compose can now run a bundled OpenAI-compatible LLM for self-hosted AI features with
`COMPOSE_PROFILES=qwen`. Tunable via `QWEN_VLLM_IMAGE`, `QWEN_MODEL_ID`, `QWEN_SERVED_MODEL_NAME`,
`QWEN_MAX_MODEL_LEN`, `QWEN_MAX_NUM_SEQS`, `QWEN_GPU_MEMORY_UTILIZATION`, `QWEN_VLLM_HOST`, and
`QWEN_VLLM_PORT`.

#### OpenTelemetry Log Export

- `OTEL_LOGS_ENABLED=1` — export Pino application logs via OTLP (off by default; also needs
`OTEL_EXPORTER_OTLP_ENDPOINT`).

### Upgrade Steps

v5.2 requires no manual database migration step beyond the standard automatic Prisma migrations on startup
(the credential-account backfill runs automatically). The Helm chart is unchanged from v5.1.

1. **If you use SSO**, add the new `/api/auth/oauth2/callback/{providerId}` redirect URIs at your IdP (see
the table above), keeping the old ones in place.
2. Back up your database.
3. Pull the v5.2 images and restart (`docker compose pull && docker compose down && docker compose up -d`,
or `helm upgrade` with the new tag).
4. After startup, sign in again (the one-time forced re-login) and verify each configured SSO provider
completes sign-in.

### Rollback

Because the old `/api/auth/callback/*` redirect URIs are left in place, rolling back to v5.1 is clean:
revert your image tags / Helm values, and the previous NextAuth callback path works again. Restore the
database backup only if you need to undo schema changes.

## v5

Formbricks v5 changes the self-hosted runtime contract. If you are upgrading an existing Formbricks 4.x
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ overrides:
# ENG-1513 / GHSA-fjxv-7rqg-78g4: form-data <4.0.4 uses an unsafe random function for the
# multipart boundary. @redocly/respect-core pins 4.0.0; patched in 4.0.4+.
form-data@4: 4.0.6
tar: 7.5.16
# ENG-1947 / GHSA-23hp-3jrh-7fpw (CVE-2026-59873): node-tar <=7.5.18 has no hard
# upper bound on total decompressed bytes / entry count, so a small gzip bomb can
# exhaust disk and CPU (DoS). Patched in 7.5.19.
tar: 7.5.19
# ENG-1527-batch / GHSA-mh29-5h37-fv8m + GHSA-h67p-54hq-rp68: js-yaml <4.1.2 quadratic-complexity
# DoS via merge keys / repeated aliases (dev-only, via @redocly/cli). No 4.1.2 published; 4.2.0 is the
# lowest patched and stays <5. Scoped to the 4.x line so any 3.x consumer is untouched.
Expand Down
Loading