feat: audit logs (#5866)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
victorvhs017
2025-06-06 02:31:39 +07:00
committed by GitHub
parent ece3d508a2
commit a9946737df
170 changed files with 8474 additions and 4344 deletions

View File

@@ -3,4 +3,5 @@ description: Whenever the user asks to write or update a test file for .tsx or .
globs:
alwaysApply: false
---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.

View File

@@ -190,7 +190,7 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
# REDIS_URL=redis://localhost:6379
REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -216,3 +216,8 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

View File

@@ -45,6 +45,16 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
valkey:
image: valkey/valkey:8.1.1
ports:
- 6379:6379
options: >-
--entrypoint "valkey-server"
--health-cmd="valkey-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -86,6 +86,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next/navigation", () => ({

View File

@@ -1,15 +1,33 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
// Mock our useSignOut hook
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: () => ({
signOut: mockSignOut,
}),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => (
@@ -70,6 +88,12 @@ describe("LandingSidebar component", () => {
const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem);
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "o1",
redirect: true,
callbackUrl: "/auth/login",
});
});
});

View File

@@ -3,6 +3,7 @@
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
@@ -20,7 +21,6 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -44,6 +44,7 @@ export const LandingSidebar = ({
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter();
@@ -123,7 +124,13 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -89,6 +89,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/environment/service");

View File

@@ -98,6 +98,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({

View File

@@ -35,6 +35,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next-auth", () => ({

View File

@@ -34,6 +34,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock dependencies

View File

@@ -26,6 +26,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
}));
describe("Contact Page Re-export", () => {

View File

@@ -4,7 +4,9 @@ import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getOrganizationProjectsLimit,
getRoleManagementPermission,
@@ -20,62 +22,69 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient
.schema(ZCreateProjectAction)
.action(async ({ parsedInput, ctx }) => {
const { user } = ctx;
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
return project;
});
)
);

View File

@@ -3,8 +3,10 @@
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { getSurveysByActionClassId } from "@/lib/survey/service";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
@@ -14,63 +16,80 @@ const ZDeleteActionClassAction = z.object({
actionClassId: ZId,
});
export const deleteActionClassAction = authenticatedActionClient
.schema(ZDeleteActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
await deleteActionClass(parsedInput.actionClassId);
});
export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action(
withAuditLogging(
"deleted",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = await getActionClass(parsedInput.actionClassId);
return await deleteActionClass(parsedInput.actionClassId);
}
)
);
const ZUpdateActionClassAction = z.object({
actionClassId: ZId,
updatedAction: ZActionClassInput,
});
export const updateActionClassAction = authenticatedActionClient
.schema(ZUpdateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action(
withAuditLogging(
"updated",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = actionClass;
const result = await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
ctx.auditLoggingCtx.newObject = result;
return result;
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
return await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
});
)
);
const ZGetActiveInactiveSurveysAction = z.object({
actionClassId: ZId,

View File

@@ -1,6 +1,6 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -10,6 +10,17 @@ import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Mock dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
@@ -18,6 +29,9 @@ vi.mock("next/navigation", () => ({
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
}));
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(),
}));
@@ -203,7 +217,9 @@ describe("MainNavigation", () => {
});
test("renders user dropdown and handles logout", async () => {
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
@@ -224,7 +240,13 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org1",
redirect: false,
callbackUrl: "/auth/login",
});
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});

View File

@@ -6,6 +6,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -42,7 +43,6 @@ import {
UserIcon,
UsersIcon,
} from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -90,6 +90,7 @@ export const MainNavigation = ({
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
@@ -389,8 +390,14 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -2,13 +2,15 @@
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration";
@@ -20,48 +22,79 @@ const ZCreateOrUpdateIntegrationAction = z.object({
export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
.action(
withAuditLogging(
"createdUpdated",
"integration",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createOrUpdateIntegration(
parsedInput.environmentId,
parsedInput.integrationData
);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient
.schema(ZDeleteIntegrationAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
return await deleteIntegration(parsedInput.integrationId);
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
const result = await deleteIntegration(parsedInput.integrationId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);

View File

@@ -49,6 +49,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/integration/service");

View File

@@ -2,7 +2,7 @@
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";

View File

@@ -32,6 +32,8 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock child components

View File

@@ -2,7 +2,7 @@
import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
describe("AppConnectionPage Re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("GeneralSettingsPage re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("LanguagesPage re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("ProjectLookSettingsPage re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("TagsPage re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
describe("ProjectTeams re-export", () => {

View File

@@ -41,6 +41,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);

View File

@@ -1,7 +1,9 @@
"use server";
import { updateUser } from "@/lib/user/service";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user";
@@ -11,8 +13,25 @@ const ZUpdateNotificationSettingsAction = z.object({
export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction)
.action(async ({ ctx, parsedInput }) => {
await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
});
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);

View File

@@ -7,10 +7,12 @@ import {
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { updateUser } from "@/lib/user/service";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -27,93 +29,136 @@ const limiter = rateLimit({
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
}
async function handleEmailUpdate({
ctx,
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
const isEmailUnique = await getIsEmailUnique(inputEmail);
if (!isEmailUnique) return payload;
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
return payload;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(async ({ parsedInput, ctx }) => {
const inputEmail = parsedInput.email?.trim().toLowerCase();
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
let payload: TUserUpdateInput = {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
// Only process email update if a new email is provided and it's different from current email
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
// Check if the new email is unique, no user exists with the new email
const isEmailUnique = await getIsEmailUnique(inputEmail);
// If the new email is unique, proceed with the email update
if (isEmailUnique) {
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
}
// Only proceed with updateUser if we have actual changes to make
if (Object.keys(payload).length > 0) {
await updateUser(ctx.user.id, payload);
}
return true;
});
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient
.schema(ZUpdateAvatarAction)
.action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient
.schema(ZRemoveAvatarAction)
.action(async ({ parsedInput, ctx }) => {
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
return await updateUser(ctx.user.id, { imageUrl: null });
});
)
);

View File

@@ -3,6 +3,7 @@
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -16,8 +17,6 @@ import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
@@ -39,7 +38,6 @@ export const EditProfileDetailsForm = ({
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({
defaultValues: {
@@ -53,6 +51,7 @@ export const EditProfileDetailsForm = ({
const { isSubmitting, isDirty } = form.formState;
const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleConfirmPassword = async (password: string) => {
const values = form.getValues();
@@ -86,8 +85,12 @@ export const EditProfileDetailsForm = ({
toast.success(t("auth.verification-requested.new_email_verification_success"));
} else {
toast.success(t("environments.settings.profile.email_change_initiated"));
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
await signOutWithAudit({
reason: "email_change",
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
});
return;
}
} else {

View File

@@ -1,8 +1,10 @@
"use server";
import { deleteOrganization, updateOrganization } from "@/lib/organization/service";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -16,43 +18,65 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
});
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient
.schema(ZDeleteOrganizationAction)
.action(async ({ parsedInput, ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
return await deleteOrganization(parsedInput.organizationId);
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);

View File

@@ -30,6 +30,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("TeamsPage re-export", () => {

View File

@@ -2,7 +2,7 @@
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { revalidatePath } from "next/cache";
import { z } from "zod";

View File

@@ -45,6 +45,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");

View File

@@ -3,8 +3,10 @@
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
@@ -63,37 +65,55 @@ const ZGenerateResultShareUrlAction = z.object({
export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
return resultShareKey;
});
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
const ZGetResultShareUrlAction = z.object({
surveyId: ZId,
@@ -132,30 +152,50 @@ const ZDeleteResultShareUrlAction = z.object({
export const deleteResultShareUrlAction = authenticatedActionClient
.schema(ZDeleteResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
return await updateSurvey({ ...survey, resultShareKey: null });
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,

View File

@@ -7,6 +7,20 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
withAuditLogging: vi.fn((...args: any[]) => {
// Check if the last argument is a function and return it directly
if (typeof args[args.length - 1] === "function") {
return args[args.length - 1];
}
// Otherwise, return a new function that takes a function as an argument and returns it
return (fn: any) => fn;
}),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -30,7 +44,9 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-url",
}));
// Create a spy for refreshSingleUseId so we can override it in tests

View File

@@ -5,8 +5,10 @@ import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/respon
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -14,7 +16,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
const ZGetResponsesDownloadUrlAction = z.object({
surveyId: ZId,
@@ -102,39 +104,54 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const { followUps } = parsedInput;
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
return await updateSurvey(parsedInput);
});
)
);

View File

@@ -39,6 +39,8 @@ vi.mock("@/lib/constants", () => ({
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({

View File

@@ -7,6 +7,8 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
@@ -179,10 +181,33 @@ export const POST = async (request: Request) => {
// Update survey status if necessary
if (survey.autoComplete && responseCount >= survey.autoComplete) {
await updateSurvey({
...survey,
status: "completed",
});
let logStatus: TAuditStatus = "success";
try {
await updateSurvey({
...survey,
status: "completed",
});
} catch (error) {
logStatus = "failure";
logger.error(
{ error, url: request.url, surveyId },
`Failed to update survey ${surveyId} status to completed`
);
} finally {
await queueAuditEvent({
status: logStatus,
action: "updated",
targetType: "survey",
userId: UNKNOWN_DATA,
userType: "system",
targetId: survey.id,
organizationId: organization.id,
newObject: {
status: "completed",
},
});
}
}
// Await webhook and email promises with allSettled to prevent early rejection

View File

@@ -1,8 +1,140 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import NextAuth from "next-auth";
import { logger } from "@formbricks/logger";
export const fetchCache = "force-no-store";
const handler = NextAuth(authOptions);
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
const authOptions = {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.session) {
result = await baseAuthOptions.callbacks.session(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("Session callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
if (error) throw error;
return result;
},
async signIn({ user, account, profile, email, credentials }) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
result = await baseAuthOptions.callbacks.signIn({
user,
account,
profile,
email,
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
logger.withContext({ eventId, err }).error("User sign-in failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};
return NextAuth(authOptions)(req, ctx);
};
export { handler as GET, handler as POST };

View File

@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
@@ -44,63 +45,104 @@ export const GET = async (
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}
let actionClassUpdate;
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
try {
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
let actionClassUpdate;
try {
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const updatedActionClass = await updateActionClass(
inputValidation.data.environmentId,
params.actionClassId,
inputValidation.data
);
if (updatedActionClass) {
auditLog.newObject = updatedActionClass;
return {
response: responses.successResponse(updatedActionClass),
};
}
return {
response: responses.internalServerErrorResponse("Some error occurred while updating action"),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
const updatedActionClass = await updateActionClass(
inputValidation.data.environmentId,
params.actionClassId,
inputValidation.data
);
if (updatedActionClass) {
return responses.successResponse(updatedActionClass);
}
return responses.internalServerErrorResponse("Some error ocured while updating action");
} catch (error) {
return handleErrorResponse(error);
}
};
},
"updated",
"actionClass"
);
export const DELETE = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.actionClassId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
const deletedActionClass = await deleteActionClass(params.actionClassId);
return {
response: responses.successResponse(deletedActionClass),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
const deletedActionClass = await deleteActionClass(params.actionClassId);
return responses.successResponse(deletedActionClass);
} catch (error) {
return handleErrorResponse(error);
}
};
},
"deleted",
"actionClass"
);

View File

@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
@@ -28,41 +29,62 @@ export const GET = async (request: Request) => {
}
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let actionClassInput;
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
actionClassInput = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
let actionClassInput;
try {
actionClassInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;
return {
response: responses.successResponse(actionClass),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
return responses.successResponse(actionClass);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
"created",
"actionClass"
);

View File

@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -48,58 +49,101 @@ export const GET = async (
}
};
export const DELETE = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) return result.error;
const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) return result.error;
let responseUpdate;
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
responseUpdate = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.response;
const deletedResponse = await deleteResponse(params.responseId);
return {
response: responses.successResponse(deletedResponse),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
return {
response: handleErrorResponse(error),
};
}
},
"deleted",
"response"
);
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.response;
let responseUpdate;
try {
responseUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const updated = await updateResponse(params.responseId, inputValidation.data);
auditLog.newObject = updated;
return {
response: responses.successResponse(updated),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
return responses.successResponse(await updateResponse(params.responseId, inputValidation.data));
} catch (error) {
return handleErrorResponse(error);
}
};
},
"updated",
"response"
);

View File

@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -91,46 +92,78 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
return { survey };
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const inputResult = await validateInput(request);
if (inputResult.error) return inputResult.error;
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) return surveyResult.error;
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
const response = await createResponse(responseInput);
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputResult = await validateInput(request);
if (inputResult.error) {
return {
response: inputResult.error,
};
}
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) {
return {
response: surveyResult.error,
};
}
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
try {
const response = await createResponse(responseInput);
auditLog.targetId = response.id;
auditLog.newObject = response;
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
response: responses.internalServerErrorResponse(error.message),
};
}
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
"created",
"response"
);

View File

@@ -3,6 +3,7 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -42,64 +43,121 @@ export const GET = async (
}
};
export const DELETE = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) return result.error;
const deletedSurvey = await deleteSurvey(params.surveyId);
return responses.successResponse(deletedSurvey);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) return result.error;
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyUpdate;
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
surveyUpdate = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.survey;
const deletedSurvey = await deleteSurvey(params.surveyId);
return {
response: responses.successResponse(deletedSurvey),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
return {
response: handleErrorResponse(error),
};
}
},
"deleted",
"survey"
);
const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey,
...surveyUpdate,
});
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.survey;
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
return {
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
let surveyUpdate;
try {
surveyUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey,
...surveyUpdate,
});
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) {
return {
response: featureCheckResult,
};
}
try {
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
auditLog.newObject = updatedSurvey;
return {
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) return featureCheckResult;
return responses.successResponse(await updateSurvey({ ...inputValidation.data, id: params.surveyId }));
} catch (error) {
return handleErrorResponse(error);
}
};
},
"updated",
"survey"
);

View File

@@ -2,6 +2,7 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -33,50 +34,78 @@ export const GET = async (request: Request) => {
}
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let surveyInput;
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
surveyInput = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
let surveyInput;
try {
surveyInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const { environmentId } = inputValidation.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return {
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
const surveyData = { ...inputValidation.data, environmentId };
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) {
return {
response: featureCheckResult,
};
}
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
auditLog.targetId = survey.id;
auditLog.newObject = survey;
return {
response: responses.successResponse(survey),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId } = inputValidation.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
const surveyData = { ...inputValidation.data, environmentId };
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) return featureCheckResult;
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
return responses.successResponse(survey);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
"created",
"survey"
);

View File

@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers";
import { logger } from "@formbricks/logger";
@@ -28,33 +29,54 @@ export const GET = async (request: Request, props: { params: Promise<{ webhookId
return responses.successResponse(webhook);
};
export const DELETE = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ webhookId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.webhookId;
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return {
response: responses.notAuthenticatedResponse(),
};
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
// check if webhook exists
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
return {
response: responses.unauthorizedResponse(),
};
}
// check if webhook exists
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
return responses.unauthorizedResponse();
}
auditLog.oldObject = webhook;
// delete webhook from database
try {
const webhook = await deleteWebhook(params.webhookId);
return responses.successResponse(webhook);
} catch (e) {
logger.error({ error: e, url: request.url }, "Error deleting webhook");
return responses.notFoundResponse("Webhook", params.webhookId);
}
};
// delete webhook from database
try {
const deletedWebhook = await deleteWebhook(params.webhookId);
return {
response: responses.successResponse(deletedWebhook),
};
} catch (e) {
auditLog.status = "failure";
logger.error({ error: e, url: request.url }, "Error deleting webhook");
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
},
"deleted",
"webhook"
);

View File

@@ -3,6 +3,7 @@ import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
@@ -25,43 +26,65 @@ export const GET = async (request: Request) => {
}
};
export const POST = async (request: Request) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
const webhookInput = await request.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const environmentId = inputValidation.data.environmentId;
if (!environmentId) {
return responses.badRequestResponse("Environment ID is required");
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// add webhook to database
try {
const webhook = await createWebhook(inputValidation.data);
return responses.successResponse(webhook);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
if (error instanceof DatabaseError) {
return responses.internalServerErrorResponse(error.message);
auditLog.organizationId = authentication.organizationId;
auditLog.userId = authentication.apiKeyId;
const webhookInput = await request.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
throw error;
}
};
const environmentId = inputValidation.data.environmentId;
if (!environmentId) {
return {
response: responses.badRequestResponse("Environment ID is required"),
};
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
try {
const webhook = await createWebhook(inputValidation.data);
auditLog.targetId = webhook.id;
auditLog.newObject = webhook;
return {
response: responses.successResponse(webhook),
};
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.internalServerErrorResponse(error.message),
};
}
throw error;
}
},
"created",
"webhook"
);

View File

@@ -0,0 +1,277 @@
import * as Sentry from "@sentry/nextjs";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { responses } from "./response";
import { ApiAuditLog } from "./with-api-logging";
// Mocks
// This top-level mock is crucial for the SUT (withApiLogging.ts)
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
error: mockContextualLoggerError,
warn: mockContextualLoggerWarn,
info: mockContextualLoggerInfo,
}));
return {
logger: {
withContext: mockWithContextInstance,
// These are for direct calls like logger.error(), logger.warn()
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
};
});
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
return {
method,
url,
headers: {
get: (key: string) => headers.get(key),
},
} as unknown as Request;
}
// Minimal valid ApiAuditLog
const baseAudit: ApiAuditLog = {
action: "created",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
status: "failure",
userType: "api",
};
describe("withApiLogging", () => {
beforeEach(() => {
vi.resetModules(); // Reset SUT and other potentially cached modules
// vi.doMock for constants if a specific test needs to override it
// The top-level mocks for audit-logs, sentry, logger should be re-applied implicitly
// or are already in place due to vi.mock hoisting.
// Restore the mock for constants to its default for most tests
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
vi.clearAllMocks(); // Clear call counts etc. for all vi.fn()
});
test("logs and audits on error response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.internalServerErrorResponse("fail"),
};
});
const req = createMockRequest({ headers: new Map([["x-request-id", "abc-123"]]) });
const { withApiLogging } = await import("./with-api-logging"); // SUT dynamically imported
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "abc-123",
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
);
});
test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.badRequestResponse("bad req"),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
});
test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
throw new Error("fail!");
});
const req = createMockRequest({ headers: new Map([["x-request-id", "err-1"]]) });
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({
code: "internal_server_error",
message: "An unexpected error occurred.",
details: {},
});
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "err-1",
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
);
});
test("does not log/audit on success response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.successResponse({ ok: true }),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).not.toHaveBeenCalled();
expect(mockContextualLoggerError).not.toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
apiUrl: req.url,
action: "created",
status: "success",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
// For this specific test, we override the AUDIT_LOG_ENABLED constant
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { withApiLogging } = await import("./with-api-logging");
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
audit: { ...baseAudit },
});
const req = createMockRequest();
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,103 @@
import { responses } from "@/app/lib/api/response";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
export type ApiAuditLog = Parameters<typeof queueAuditEvent>[0];
/**
* withApiLogging wraps an V1 API handler to provide unified error/audit/system logging.
* - Handler must return { response }.
* - If not a successResponse, calls audit log, system log, and Sentry as needed.
* - System and Sentry logs are always called for non-success responses.
*/
export const withApiLogging = <TResult extends { response: Response }>(
handler: (req: Request, props?: any, auditLog?: ApiAuditLog) => Promise<TResult>,
action: TAuditAction,
targetType: TAuditTarget
) => {
return async function (req: Request, props: any): Promise<Response> {
const auditLog = buildAuditLogBaseObject(action, targetType, req.url);
let result: { response: Response };
let error: any = undefined;
try {
result = await handler(req, props, auditLog);
} catch (err) {
error = err;
result = {
response: responses.internalServerErrorResponse("An unexpected error occurred."),
};
}
const res = result.response;
// Try to parse the response as JSON to check if it's a success or error
let isSuccess = false;
let parsed: any = undefined;
try {
parsed = await res.clone().json();
isSuccess = parsed && typeof parsed === "object" && "data" in parsed;
} catch {
isSuccess = false;
}
const correlationId = req.headers.get("x-request-id") ?? "";
if (!isSuccess) {
if (auditLog) {
auditLog.eventId = correlationId;
}
// System log
const logContext: any = {
correlationId,
method: req.method,
path: req.url,
status: res.status,
};
if (error) {
logContext.error = error;
}
logger.withContext(logContext).error("API Error Details");
// Sentry log
if (SENTRY_DSN && IS_PRODUCTION && res.status === 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, {
extra: {
error,
correlationId,
},
});
}
} else {
auditLog.status = "success";
}
if (AUDIT_LOG_ENABLED && auditLog) {
queueAuditEvent(auditLog);
}
return res;
};
};
export const buildAuditLogBaseObject = (
action: TAuditAction,
targetType: TAuditTarget,
apiUrl: string
): ApiAuditLog => {
return {
action,
targetType,
userId: UNKNOWN_DATA,
targetId: UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
};
};

View File

@@ -4,6 +4,8 @@ import { gethasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { OperationNotAllowedError } from "@formbricks/types/errors";
@@ -12,24 +14,31 @@ const ZCreateOrganizationAction = z.object({
organizationName: z.string(),
});
export const createOrganizationAction = authenticatedActionClient
.schema(ZCreateOrganizationAction)
.action(async ({ ctx, parsedInput }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
export const createOrganizationAction = authenticatedActionClient.schema(ZCreateOrganizationAction).action(
withAuditLogging(
"created",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
return newOrganization;
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
return newOrganization;
});
)
);

View File

@@ -3,9 +3,13 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZStorageRetrievalParams } from "@formbricks/types/storage";
import { getFile } from "./lib/get-file";
@@ -57,46 +61,161 @@ export const GET = async (
};
export const DELETE = async (
_: NextRequest,
props: { params: Promise<{ fileName: string }> }
request: NextRequest,
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> }
): Promise<Response> => {
const params = await props.params;
const getOrgId = async (environmentId: string): Promise<string> => {
try {
return await getOrganizationIdFromEnvironmentId(environmentId);
} catch (error) {
logger.error("Failed to get organization ID for environment", { error });
return UNKNOWN_DATA;
}
};
const logFileDeletion = async ({
accessType,
userId,
status = "failure",
failureReason,
oldObject,
}: {
accessType?: string;
userId?: string;
status?: TAuditStatus;
failureReason?: string;
oldObject?: Record<string, unknown>;
}) => {
try {
const organizationId = await getOrgId(environmentId);
await queueAuditEvent({
action: "deleted",
targetType: "file",
userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too
userType: "user",
targetId: `${environmentId}:${accessType}`, // Generic target identifier
organizationId,
status,
newObject: {
environmentId,
accessType,
...(failureReason && { failureReason }),
},
oldObject,
apiUrl: request.url,
});
} catch (auditError) {
logger.error("Failed to log file deletion audit event:", auditError);
}
};
// Validation
if (!params.fileName) {
await logFileDeletion({
failureReason: "fileName parameter missing",
});
return responses.badRequestResponse("Fields are missing or incorrectly formatted", {
fileName: "fileName is required",
});
}
const [environmentId, accessType, file] = params.fileName.split("/");
const { environmentId, accessType, fileName } = params;
// Security check: If fileName contains the same properties from the route, ensure they match
// This is to prevent a user from deleting a file from a different environment
const [fileEnvironmentId, fileAccessType, file] = fileName.split("/");
if (fileEnvironmentId !== environmentId) {
await logFileDeletion({
failureReason: "Environment ID mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Environment ID mismatch", {
message: "The environment ID in the fileName does not match the route environment ID",
});
}
if (fileAccessType !== accessType) {
await logFileDeletion({
failureReason: "Access type mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Access type mismatch", {
message: "The access type in the fileName does not match the route access type",
});
}
const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType });
if (!paramValidation.success) {
await logFileDeletion({
failureReason: "Parameter validation failed",
accessType,
});
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(paramValidation.error),
true
);
}
// check if user is authenticated
const {
environmentId: validEnvId,
accessType: validAccessType,
fileName: validFileName,
} = paramValidation.data;
// Authentication
const session = await getServerSession(authOptions);
if (!session?.user) {
await logFileDeletion({
failureReason: "User not authenticated",
accessType: validAccessType,
});
return responses.notAuthenticatedResponse();
}
// check if the user has access to the environment
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
// Authorization
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId);
if (!isUserAuthorized) {
await logFileDeletion({
failureReason: "User not authorized to access environment",
accessType: validAccessType,
userId: session.user.id,
});
return responses.unauthorizedResponse();
}
return await handleDeleteFile(
paramValidation.data.environmentId,
paramValidation.data.accessType,
paramValidation.data.fileName
);
try {
const deleteResult = await handleDeleteFile(validEnvId, validAccessType, validFileName);
const isSuccess = deleteResult.status === 200;
let failureReason = "File deletion failed";
if (!isSuccess) {
try {
const responseBody = await deleteResult.json();
failureReason = responseBody.message || failureReason; // NOSONAR // We want to check for empty messages too
} catch (error) {
logger.error("Failed to parse file delete error response body", { error });
}
}
await logFileDeletion({
status: isSuccess ? "success" : "failure",
failureReason: isSuccess ? undefined : failureReason,
accessType: validAccessType,
userId: session.user.id,
});
return deleteResult;
} catch (error) {
await logFileDeletion({
failureReason: error instanceof Error ? error.message : "Unexpected error during file deletion",
accessType: validAccessType,
userId: session.user.id,
});
throw error;
}
};

View File

@@ -7,7 +7,6 @@ export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
@@ -282,4 +281,11 @@ export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
export const AUDIT_LOG_ENABLED =
env.AUDIT_LOG_ENABLED === "1" &&
env.REDIS_URL &&
env.REDIS_URL !== "" &&
env.ENCRYPTION_KEY &&
env.ENCRYPTION_KEY !== ""; // The audit log requires Redis to be configured
export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;

View File

@@ -105,7 +105,12 @@ export const env = createEnv({
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(),
AUDIT_LOG_ENABLED: z.enum(["1", "0"]).optional(),
AUDIT_LOG_GET_USER_IP: z.enum(["1", "0"]).optional(),
SESSION_MAX_AGE: z
.string()
.transform((val) => parseInt(val))
.optional(),
},
/*
@@ -201,6 +206,8 @@ export const env = createEnv({
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
},
});

View File

@@ -1,100 +0,0 @@
import { getMembershipRole } from "@/lib/membership/hooks/actions";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { returnValidationErrors } from "next-safe-action";
import { ZodIssue, z } from "zod";
import { AuthorizationError } from "@formbricks/types/errors";
import { type TOrganizationRole } from "@formbricks/types/memberships";
export const formatErrors = (issues: ZodIssue[]): Record<string, { _errors: string[] }> => {
return {
...issues.reduce((acc, issue) => {
acc[issue.path.join(".")] = {
_errors: [issue.message],
};
return acc;
}, {}),
};
};
export type TAccess<T extends z.ZodRawShape> =
| {
type: "organization";
schema?: z.ZodObject<T>;
data?: z.ZodObject<T>["_output"];
roles: TOrganizationRole[];
}
| {
type: "projectTeam";
minPermission?: TTeamPermission;
projectId: string;
}
| {
type: "team";
minPermission?: TTeamRole;
teamId: string;
};
const teamPermissionWeight = {
read: 1,
readWrite: 2,
manage: 3,
};
const teamRoleWeight = {
contributor: 1,
admin: 2,
};
export const checkAuthorizationUpdated = async <T extends z.ZodRawShape>({
userId,
organizationId,
access,
}: {
userId: string;
organizationId: string;
access: TAccess<T>[];
}) => {
const role = await getMembershipRole(userId, organizationId);
for (const accessItem of access) {
if (accessItem.type === "organization") {
if (accessItem.schema) {
const resultSchema = accessItem.schema.strict();
const parsedResult = resultSchema.safeParse(accessItem.data);
if (!parsedResult.success) {
// @ts-expect-error -- TODO: match dynamic next-safe-action types
return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues));
}
}
if (accessItem.roles.includes(role)) {
return true;
}
} else {
if (accessItem.type === "projectTeam") {
const projectPermission = await getProjectPermissionByUserId(userId, accessItem.projectId);
if (
!projectPermission ||
(accessItem.minPermission !== undefined &&
teamPermissionWeight[projectPermission] < teamPermissionWeight[accessItem.minPermission])
) {
continue;
}
} else {
const teamRole = await getTeamRoleByTeamIdUserId(accessItem.teamId, userId);
if (
!teamRole ||
(accessItem.minPermission !== undefined &&
teamRoleWeight[teamRole] < teamRoleWeight[accessItem.minPermission])
) {
continue;
}
}
return true;
}
}
throw new AuthorizationError("Not authorized");
};

View File

@@ -0,0 +1,120 @@
import { getMembershipRole } from "@/lib/membership/hooks/actions";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { returnValidationErrors } from "next-safe-action";
import { ZodIssue, z } from "zod";
import { AuthorizationError } from "@formbricks/types/errors";
import { type TOrganizationRole } from "@formbricks/types/memberships";
export const formatErrors = (issues: ZodIssue[]): Record<string, { _errors: string[] }> => {
return {
...issues.reduce((acc, issue) => {
acc[issue.path.join(".")] = {
_errors: [issue.message],
};
return acc;
}, {}),
};
};
export type TAccess<T extends z.ZodRawShape> =
| {
type: "organization";
schema?: z.ZodObject<T>;
data?: z.ZodObject<T>["_output"];
roles: TOrganizationRole[];
}
| {
type: "projectTeam";
minPermission?: TTeamPermission;
projectId: string;
}
| {
type: "team";
minPermission?: TTeamRole;
teamId: string;
};
const teamPermissionWeight = {
read: 1,
readWrite: 2,
manage: 3,
};
const teamRoleWeight = {
contributor: 1,
admin: 2,
};
const checkOrganizationAccess = <T extends z.ZodRawShape>(
accessItem: TAccess<T>,
role: TOrganizationRole
) => {
if (accessItem.type !== "organization") return false;
if (accessItem.schema) {
const resultSchema = accessItem.schema.strict();
const parsedResult = resultSchema.safeParse(accessItem.data);
if (!parsedResult.success) {
// @ts-expect-error -- match dynamic next-safe-action types
return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues));
}
}
return accessItem.roles.includes(role);
};
const checkProjectTeamAccess = async (accessItem: any, userId: string) => {
if (accessItem.type !== "projectTeam") return false;
const projectPermission = await getProjectPermissionByUserId(userId, accessItem.projectId);
if (!projectPermission) return false;
if (
accessItem.minPermission !== undefined &&
teamPermissionWeight[projectPermission] < teamPermissionWeight[accessItem.minPermission]
) {
return false;
}
return true;
};
const checkTeamAccess = async (accessItem: any, userId: string) => {
if (accessItem.type !== "team") return false;
const teamRole = await getTeamRoleByTeamIdUserId(accessItem.teamId, userId);
if (!teamRole) return false;
if (
accessItem.minPermission !== undefined &&
teamRoleWeight[teamRole] < teamRoleWeight[accessItem.minPermission]
) {
return false;
}
return true;
};
export const checkAuthorizationUpdated = async <T extends z.ZodRawShape>({
userId,
organizationId,
access,
}: {
userId: string;
organizationId: string;
access: TAccess<T>[];
}) => {
const role = await getMembershipRole(userId, organizationId);
for (const accessItem of access) {
if (accessItem.type === "organization") {
const orgResult = checkOrganizationAccess(accessItem, role);
if (orgResult === true) return true;
if (orgResult) return orgResult; // validation error
}
if (accessItem.type === "projectTeam" && (await checkProjectTeamAccess(accessItem, userId))) {
return true;
}
if (accessItem.type === "team" && (await checkTeamAccess(accessItem, userId))) {
return true;
}
}
throw new AuthorizationError("Not authorized");
};

View File

@@ -1,8 +1,12 @@
import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
import {
AuthenticationError,
@@ -13,10 +17,16 @@ import {
TooManyRequestsError,
UnknownError,
} from "@formbricks/types/errors";
import { ActionClientCtx } from "./types/context";
export const actionClient = createSafeActionClient({
handleServerError(e) {
Sentry.captureException(e);
handleServerError(e, utils) {
const eventId = (utils.ctx as Record<string, any>)?.auditLoggingCtx?.eventId ?? undefined; // keep explicit fallback
Sentry.captureException(e, {
extra: {
eventId,
},
});
if (
e instanceof ResourceNotFoundError ||
@@ -31,12 +41,28 @@ export const actionClient = createSafeActionClient({
}
// eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors
logger.error(e, "SERVER ERROR");
logger.withContext({ eventId }).error(e, "SERVER ERROR");
return DEFAULT_SERVER_ERROR_MESSAGE;
},
}).use(async ({ next }) => {
// Create a unique event id
const eventId = uuidv4();
const ctx: ActionClientCtx = { auditLoggingCtx: { eventId, ipAddress: UNKNOWN_DATA } };
if (AUDIT_LOG_ENABLED && AUDIT_LOG_GET_USER_IP) {
try {
const ipAddress = await getClientIpFromHeaders();
ctx.auditLoggingCtx.ipAddress = ipAddress;
} catch (err) {
// Non-fatal we keep UNKNOWN_DATA
logger.warn({ err }, "Failed to resolve client IP for audit logging");
}
}
return next({ ctx });
});
export const authenticatedActionClient = actionClient.use(async ({ next }) => {
export const authenticatedActionClient = actionClient.use(async ({ ctx, next }) => {
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new AuthenticationError("Not authenticated");
@@ -49,5 +75,5 @@ export const authenticatedActionClient = actionClient.use(async ({ next }) => {
throw new AuthorizationError("User not found");
}
return next({ ctx: { user } });
return next({ ctx: { ...ctx, user } });
});

View File

@@ -0,0 +1,34 @@
import { TUser } from "@formbricks/types/user";
export type AuditLoggingCtx = {
organizationId?: string;
ipAddress: string;
segmentId?: string;
oldObject?: Record<string, unknown> | null;
newObject?: Record<string, unknown> | null;
eventId?: string;
surveyId?: string;
tagId?: string;
webhookId?: string;
userId?: string;
projectId?: string;
languageId?: string;
inviteId?: string;
membershipId?: string;
actionClassId?: string;
contactId?: string;
apiKeyId?: string;
responseId?: string;
responseNoteId?: string;
teamId?: string;
integrationId?: string;
};
export type ActionClientCtx = {
auditLoggingCtx: AuditLoggingCtx;
user?: TUser;
};
export type AuthenticatedActionClientCtx = ActionClientCtx & {
user: TUser;
};

View File

@@ -0,0 +1,82 @@
import * as nextHeaders from "next/headers";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getClientIpFromHeaders } from "./client-ip";
// Mock next/headers
declare module "next/headers" {
export function headers(): any;
}
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
const mockHeaders = (headerMap: Record<string, string | undefined>) => {
vi.mocked(nextHeaders.headers).mockReturnValue({
get: (key: string) => headerMap[key.toLowerCase()] ?? undefined,
});
};
describe("getClientIpFromHeaders", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns cf-connecting-ip if present", async () => {
mockHeaders({ "cf-connecting-ip": "1.2.3.4" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("1.2.3.4");
});
test("returns first x-forwarded-for if cf-connecting-ip is missing", async () => {
mockHeaders({ "x-forwarded-for": "5.6.7.8, 9.10.11.12" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("5.6.7.8");
});
test("returns x-real-ip if cf-connecting-ip and x-forwarded-for are missing", async () => {
mockHeaders({ "x-real-ip": "13.14.15.16" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("13.14.15.16");
});
test("returns ::1 if no headers are present", async () => {
mockHeaders({});
const ip = await getClientIpFromHeaders();
expect(ip).toBe("::1");
});
test("trims whitespace in x-forwarded-for", async () => {
mockHeaders({ "x-forwarded-for": " 21.22.23.24 , 25.26.27.28" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("21.22.23.24");
});
test("getClientIpFromHeaders should return the value of the cf-connecting-ip header when it is present", async () => {
const testIp = "123.123.123.123";
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockImplementation((headerName: string) => {
if (headerName === "cf-connecting-ip") {
return testIp;
}
return null;
}),
} as any);
const result = await getClientIpFromHeaders();
expect(result).toBe(testIp);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("getClientIpFromHeaders should handle errors when headers() throws an exception", async () => {
vi.mocked(nextHeaders.headers).mockImplementation(() => {
throw new Error("Failed to get headers");
});
const result = await getClientIpFromHeaders();
expect(result).toBe("::1");
});
});

View File

@@ -0,0 +1,22 @@
import { headers } from "next/headers";
import { logger } from "@formbricks/logger";
export async function getClientIpFromHeaders(): Promise<string> {
let headersList: Headers;
try {
headersList = await headers();
} catch (e) {
logger.error(e, "Failed to get headers in getClientIpFromHeaders");
return "::1";
}
// Try common proxy headers first
const cfConnectingIp = headersList.get("cf-connecting-ip");
if (cfConnectingIp) return cfConnectingIp;
const xForwardedFor = headersList.get("x-forwarded-for");
if (xForwardedFor) return xForwardedFor.split(",")[0].trim();
// Fallback (may be undefined or localhost in dev)
return headersList.get("x-real-ip") || "::1"; // NOSONAR - We want to fallback when the result is ""
}

View File

@@ -597,7 +597,6 @@
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_error": "Beim Aktualisieren der Kontakte ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"first_name": "Vorname",
"last_name": "Nachname",

View File

@@ -597,7 +597,6 @@
"contact_deleted_successfully": "Contact deleted successfully",
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_error": "Something went wrong while refreshing contacts, please try again",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"first_name": "First Name",
"last_name": "Last Name",

View File

@@ -597,7 +597,6 @@
"contact_deleted_successfully": "Contact supprimé avec succès",
"contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_error": "Une erreur s'est produite lors de la mise à jour des contacts. Veuillez réessayer.",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"first_name": "Prénom",
"last_name": "Nom de famille",

View File

@@ -597,7 +597,6 @@
"contact_deleted_successfully": "Contato excluído com sucesso",
"contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_error": "Ocorreu um erro ao atualizar os contatos. Por favor, tente novamente.",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"first_name": "Primeiro Nome",
"last_name": "Sobrenome",

View File

@@ -597,7 +597,6 @@
"contact_deleted_successfully": "Contacto eliminado com sucesso",
"contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_error": "Algo correu mal ao atualizar os contactos, por favor, tente novamente",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"first_name": "Primeiro Nome",
"last_name": "Apelido",

View File

@@ -597,7 +597,6 @@
"contact_deleted_successfully": "聯絡人已成功刪除",
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_error": "重新整理聯絡人時發生錯誤,請再試一次",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"first_name": "名字",
"last_name": "姓氏",

View File

@@ -18,10 +18,10 @@ import {
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { logApiError } from "@/modules/api/v2/lib/utils";
import { logApiErrorEdge } from "@/modules/api/v2/lib/utils-edge";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ipAddress } from "@vercel/functions";
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
@@ -106,7 +106,6 @@ export const middleware = async (originalRequest: NextRequest) => {
request.headers.set("x-start-time", Date.now().toString());
// Create a new NextResponse object to forward the new request with headers
const nextResponseWithCustomHeader = NextResponse.next({
request: {
headers: request.headers,
@@ -117,25 +116,23 @@ export const middleware = async (originalRequest: NextRequest) => {
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
const ip = await getClientIpFromHeaders();
if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) {
return nextResponseWithCustomHeader;
}
let ip =
request.headers.get("cf-connecting-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
ipAddress(request);
if (ip) {
try {
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
// NOSONAR - This is a catch all for rate limiting errors
const apiError: ApiErrorResponseV2 = {
type: "too_many_requests",
details: [{ field: "", issue: "Too many requests. Please try again later." }],
};
logApiError(request, apiError);
logApiErrorEdge(request, apiError);
return NextResponse.json(apiError, { status: 429 });
}
}

View File

@@ -1,74 +0,0 @@
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { deleteUserAction } from "./actions";
// Mock all dependencies
vi.mock("@/lib/user/service", () => ({
deleteUser: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
}));
// add a mock to authenticatedActionClient.action
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
action: (fn: any) => {
return fn;
},
},
}));
describe("deleteUserAction", () => {
test("deletes user successfully when multi-org is enabled", async () => {
const ctx = { user: { id: "test-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(true);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "test-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("test-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("test-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("deletes user successfully when multi-org is disabled but user is not sole owner of any org", async () => {
const ctx = { user: { id: "another-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "another-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "another-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("another-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("another-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("throws OperationNotAllowedError when user is sole owner in at least one org and multi-org is disabled", async () => {
const ctx = { user: { id: "sole-owner-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([
{ id: "org-1" } as TOrganization,
]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
await expect(() => deleteUserAction({ ctx } as any)).rejects.toThrow(OperationNotAllowedError);
expect(deleteUser).not.toHaveBeenCalled();
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("sole-owner-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,18 +1,29 @@
"use server";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteUser } from "@/lib/user/service";
import { deleteUser, getUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { OperationNotAllowedError } from "@formbricks/types/errors";
export const deleteUserAction = authenticatedActionClient.action(async ({ ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
return await deleteUser(ctx.user.id);
});
export const deleteUserAction = authenticatedActionClient.action(
withAuditLogging(
"deleted",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
const result = await deleteUser(ctx.user.id);
return result;
}
)
);

View File

@@ -1,18 +1,28 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import * as nextAuth from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import * as actions from "./actions";
import { DeleteAccountModal } from "./index";
vi.mock("next-auth/react", async () => {
const actual = await vi.importActual("next-auth/react");
return {
...actual,
signOut: vi.fn(),
};
});
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Mock our useSignOut hook
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: () => ({
signOut: mockSignOut,
}),
}));
vi.mock("./actions", () => ({
deleteUserAction: vi.fn(),
@@ -29,6 +39,7 @@ describe("DeleteAccountModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal with correct props", () => {
@@ -66,7 +77,12 @@ describe("DeleteAccountModal", () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
// Mock window.location.replace
Object.defineProperty(window, "location", {
writable: true,
value: { replace: vi.fn() },
});
render(
<DeleteAccountModal
@@ -86,7 +102,11 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion",
redirect: false, // Updated to match new implementation
});
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
@@ -95,7 +115,6 @@ describe("DeleteAccountModal", () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
Object.defineProperty(window, "location", {
writable: true,
@@ -120,8 +139,13 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ redirect: true });
expect(window.location.replace).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion",
redirect: false, // Updated to match new implementation
});
expect(window.location.replace).toHaveBeenCalledWith(
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
);
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,9 +1,9 @@
"use client";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { T, useTranslate } from "@tolgee/react";
import { signOut } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
@@ -28,6 +28,7 @@ export const DeleteAccountModal = ({
const { t } = useTranslate();
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -36,12 +37,18 @@ export const DeleteAccountModal = ({
try {
setDeleting(true);
await deleteUserAction();
// redirect to account deletion survey in Formbricks Cloud
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect
});
// Manual redirect after signOut completes
if (isFormbricksCloud) {
await signOut({ redirect: true });
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
} else {
await signOut({ callbackUrl: "/auth/login" });
window.location.replace("/auth/login");
}
} catch (error) {
toast.error("Something went wrong");

View File

@@ -1,202 +0,0 @@
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createResponseNote, resolveResponseNote, updateResponseNote } from "@/lib/responseNote/service";
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromResponseId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromResponseId,
getOrganizationIdFromResponseNoteId,
getProjectIdFromEnvironmentId,
getProjectIdFromResponseId,
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { describe, expect, test, vi } from "vitest";
import {
createResponseNoteAction,
createTagAction,
createTagToResponseAction,
deleteResponseAction,
deleteTagOnResponseAction,
getResponseAction,
resolveResponseNoteAction,
updateResponseNoteAction,
} from "./actions";
// Dummy inputs and context
const dummyCtx = { user: { id: "user1" } };
const dummyTagInput = { environmentId: "env1", tagName: "tag1" };
const dummyTagToResponseInput = { responseId: "resp1", tagId: "tag1" };
const dummyResponseIdInput = { responseId: "resp1" };
const dummyResponseNoteInput = { responseNoteId: "note1", text: "Updated note" };
const dummyCreateNoteInput = { responseId: "resp1", text: "New note" };
const dummyGetResponseInput = { responseId: "resp1" };
// Mocks for external dependencies
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getProjectIdFromEnvironmentId: vi.fn().mockResolvedValue("proj-env"),
getOrganizationIdFromResponseId: vi.fn().mockResolvedValue("org-resp"),
getOrganizationIdFromResponseNoteId: vi.fn().mockResolvedValue("org-resp-note"),
getProjectIdFromResponseId: vi.fn().mockResolvedValue("proj-resp"),
getProjectIdFromResponseNoteId: vi.fn().mockResolvedValue("proj-resp-note"),
getEnvironmentIdFromResponseId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getTag: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
deleteResponse: vi.fn().mockResolvedValue("deletedResponse"),
getResponse: vi.fn().mockResolvedValue({ data: "responseData" }),
}));
vi.mock("@/lib/responseNote/service", () => ({
createResponseNote: vi.fn().mockResolvedValue("createdNote"),
updateResponseNote: vi.fn().mockResolvedValue("updatedNote"),
resolveResponseNote: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/tag/service", () => ({
createTag: vi.fn().mockResolvedValue("createdTag"),
}));
vi.mock("@/lib/tagOnResponse/service", () => ({
addTagToRespone: vi.fn().mockResolvedValue("tagAdded"),
deleteTagOnResponse: vi.fn().mockResolvedValue("tagDeleted"),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => async (input: any) => {
const { user, ...rest } = input;
return fn({
parsedInput: rest,
ctx: { user },
});
},
}),
},
}));
describe("createTagAction", () => {
test("successfully creates a tag", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org1");
await createTagAction({ ...dummyTagInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(getProjectIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(createTag).toHaveBeenCalledWith(dummyTagInput.environmentId, dummyTagInput.tagName);
});
});
describe("createTagToResponseAction", () => {
test("adds tag to response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getEnvironmentIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(addTagToRespone).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteTagOnResponseAction", () => {
test("deletes tag on response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(deleteTagOnResponse).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteResponseAction", () => {
test("deletes response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await deleteResponseAction({ ...dummyResponseIdInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(deleteResponse).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
});
});
describe("updateResponseNoteAction", () => {
test("updates response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await updateResponseNoteAction({ ...dummyResponseNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(updateResponseNote).toHaveBeenCalledWith(
dummyResponseNoteInput.responseNoteId,
dummyResponseNoteInput.text
);
});
});
describe("resolveResponseNoteAction", () => {
test("resolves response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await resolveResponseNoteAction({ responseNoteId: "note1", ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(resolveResponseNote).toHaveBeenCalledWith("note1");
});
});
describe("createResponseNoteAction", () => {
test("creates a response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await createResponseNoteAction({ ...dummyCreateNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(createResponseNote).toHaveBeenCalledWith(
dummyCreateNoteInput.responseId,
dummyCtx.user.id,
dummyCreateNoteInput.text
);
});
});
describe("getResponseAction", () => {
test("retrieves response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await getResponseAction({ ...dummyGetResponseInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getResponse).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
});
});

View File

@@ -5,7 +5,8 @@ import { createResponseNote, resolveResponseNote, updateResponseNote } from "@/l
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
getEnvironmentIdFromResponseId,
getOrganizationIdFromEnvironmentId,
@@ -16,6 +17,7 @@ import {
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -24,209 +26,266 @@ const ZCreateTagAction = z.object({
tagName: z.string(),
});
export const createTagAction = authenticatedActionClient
.schema(ZCreateTagAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction).action(
withAuditLogging(
"created",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
return await createTag(parsedInput.environmentId, parsedInput.tagName);
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
ctx.auditLoggingCtx.tagId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZCreateTagToResponseAction = z.object({
responseId: ZId,
tagId: ZId,
});
export const createTagToResponseAction = authenticatedActionClient
.schema(ZCreateTagToResponseAction)
.action(async ({ parsedInput, ctx }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
export const createTagToResponseAction = authenticatedActionClient.schema(ZCreateTagToResponseAction).action(
withAuditLogging(
"addedToResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
const organizationId = await getOrganizationIdFromEnvironmentId(responseEnvironmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.newObject = result;
return result;
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
return await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
});
)
);
const ZDeleteTagOnResponseAction = z.object({
responseId: ZId,
tagId: ZId,
});
export const deleteTagOnResponseAction = authenticatedActionClient
.schema(ZDeleteTagOnResponseAction)
.action(async ({ parsedInput, ctx }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
export const deleteTagOnResponseAction = authenticatedActionClient.schema(ZDeleteTagOnResponseAction).action(
withAuditLogging(
"removedFromResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
return await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
});
)
);
const ZDeleteResponseAction = z.object({
responseId: ZId,
});
export const deleteResponseAction = authenticatedActionClient
.schema(ZDeleteResponseAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
return await deleteResponse(parsedInput.responseId);
});
export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResponseAction).action(
withAuditLogging(
"deleted",
"response",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.responseId = parsedInput.responseId;
const result = await deleteResponse(parsedInput.responseId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
const ZUpdateResponseNoteAction = z.object({
responseNoteId: ZId,
text: z.string(),
});
export const updateResponseNoteAction = authenticatedActionClient
.schema(ZUpdateResponseNoteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
return await updateResponseNote(parsedInput.responseNoteId, parsedInput.text);
});
export const updateResponseNoteAction = authenticatedActionClient.schema(ZUpdateResponseNoteAction).action(
withAuditLogging(
"updated",
"responseNote",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.responseNoteId = parsedInput.responseNoteId;
const result = await updateResponseNote(parsedInput.responseNoteId, parsedInput.text);
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZResolveResponseNoteAction = z.object({
responseNoteId: ZId,
});
export const resolveResponseNoteAction = authenticatedActionClient
.schema(ZResolveResponseNoteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
await resolveResponseNote(parsedInput.responseNoteId);
});
export const resolveResponseNoteAction = authenticatedActionClient.schema(ZResolveResponseNoteAction).action(
withAuditLogging(
"updated",
"responseNote",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.responseNoteId = parsedInput.responseNoteId;
const result = await resolveResponseNote(parsedInput.responseNoteId);
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZCreateResponseNoteAction = z.object({
responseId: ZId,
text: z.string(),
});
export const createResponseNoteAction = authenticatedActionClient
.schema(ZCreateResponseNoteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
return await createResponseNote(parsedInput.responseId, ctx.user.id, parsedInput.text);
});
export const createResponseNoteAction = authenticatedActionClient.schema(ZCreateResponseNoteAction).action(
withAuditLogging(
"created",
"responseNote",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createResponseNote(parsedInput.responseId, ctx.user.id, parsedInput.text);
ctx.auditLoggingCtx.newObject = result;
ctx.auditLoggingCtx.responseNoteId = result.id;
return result;
}
)
);
const ZGetResponseAction = z.object({
responseId: ZId,

View File

@@ -1,3 +1,4 @@
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { ZodRawShape, z } from "zod";
@@ -8,10 +9,12 @@ export type HandlerFn<TInput = Record<string, unknown>> = ({
authentication,
parsedInput,
request,
auditLog,
}: {
authentication: TAuthenticationApiKey;
parsedInput: TInput;
request: Request;
auditLog?: ApiAuditLog;
}) => Promise<Response>;
export type ExtendedSchemas = {
@@ -41,18 +44,25 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
externalParams,
rateLimit = true,
handler,
auditLog,
}: {
request: Request;
schemas?: S;
externalParams?: Promise<Record<string, any>>;
rateLimit?: boolean;
handler: HandlerFn<ParsedSchemas<S>>;
auditLog?: ApiAuditLog;
}): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication.ok) {
return handleApiError(request, authentication.error);
}
if (auditLog) {
auditLog.userId = authentication.data.apiKeyId;
auditLog.organizationId = authentication.data.organizationId;
}
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
@@ -106,5 +116,6 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
authentication: authentication.data,
parsedInput,
request,
auditLog,
});
};

View File

@@ -1,5 +1,7 @@
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
@@ -8,24 +10,35 @@ export const authenticatedApiClient = async <S extends ExtendedSchemas>({
externalParams,
rateLimit = true,
handler,
action,
targetType,
}: {
request: Request;
schemas?: S;
externalParams?: Promise<Record<string, any>>;
rateLimit?: boolean;
handler: HandlerFn<ParsedSchemas<S>>;
action?: TAuditAction;
targetType?: TAuditTarget;
}): Promise<Response> => {
try {
const auditLog =
action && targetType ? buildAuditLogBaseObject(action, targetType, request.url) : undefined;
const response = await apiWrapper({
request,
schemas,
externalParams,
rateLimit,
handler,
auditLog,
});
if (response.ok) {
logApiRequest(request, response.status);
if (auditLog) {
auditLog.status = "success";
}
logApiRequest(request, response.status, auditLog);
}
return response;

View File

@@ -18,6 +18,9 @@ vi.mock("@sentry/nextjs", () => ({
vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mocked-sentry-dsn",
IS_PRODUCTION: true,
AUDIT_LOG_ENABLED: true,
ENCRYPTION_KEY: "mocked-encryption-key",
REDIS_URL: "mock-url",
}));
describe("utils", () => {

View File

@@ -0,0 +1,30 @@
// Function is this file can be used in edge runtime functions, like api routes.
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
});
}
logger
.withContext({
correlationId,
error,
})
.error("API Error Details");
};

View File

@@ -1,14 +1,19 @@
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
// if we don't add this we get build errors with prisma due to type-nesting
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
logApiError(request, err);
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: ApiAuditLog
): Response => {
logApiError(request, err, auditLog);
switch (err.type) {
case "bad_request":
@@ -50,7 +55,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
});
};
export const logApiRequest = (request: Request, responseStatus: number): void => {
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
@@ -73,29 +78,22 @@ export const logApiRequest = (request: Request, responseStatus: number): void =>
queryParams: safeQueryParams,
})
.info("API Request Details");
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
logApiErrorEdge(request, error);
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
logAuditLog(request, auditLog);
};
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
});
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
if (AUDIT_LOG_ENABLED && auditLog) {
const correlationId = request.headers.get("x-request-id") ?? "";
queueAuditEvent({
...auditLog,
eventId: correlationId,
}).catch((err) => logger.error({ err, correlationId }, "Failed to queue audit event from logApiError"));
}
logger
.withContext({
correlationId,
error,
})
.error("API Error Details");
};

View File

@@ -56,36 +56,55 @@ export const PUT = async (
body: ZContactAttributeKeyUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params, body } = parsedInput;
if (auditLog) {
auditLog.targetId = params.contactAttributeKeyId;
}
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error as ApiErrorResponseV2);
return handleApiError(request, res.error as ApiErrorResponseV2, auditLog);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
},
auditLog
);
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
},
auditLog
);
}
const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body);
if (!updatedContactAttributeKey.ok) {
return handleApiError(request, updatedContactAttributeKey.error as ApiErrorResponseV2);
return handleApiError(request, updatedContactAttributeKey.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = res.data;
auditLog.newObject = updatedContactAttributeKey.data;
}
return responses.successResponse(updatedContactAttributeKey);
},
action: "updated",
targetType: "contactAttributeKey",
});
export const DELETE = async (
@@ -98,35 +117,53 @@ export const DELETE = async (
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params.contactAttributeKeyId;
}
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error as ApiErrorResponseV2);
return handleApiError(request, res.error as ApiErrorResponseV2, auditLog);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
},
auditLog
);
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contact attribute key" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contactAttributeKey" }],
},
auditLog
);
}
const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId);
if (!deletedContactAttributeKey.ok) {
return handleApiError(request, deletedContactAttributeKey.error as ApiErrorResponseV2);
return handleApiError(request, deletedContactAttributeKey.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = res.data;
}
return responses.successResponse(deletedContactAttributeKey);
},
action: "deleted",
targetType: "contactAttributeKey",
});

View File

@@ -51,24 +51,35 @@ export const POST = async (request: NextRequest) =>
schemas: {
body: ZContactAttributeKeyInput,
},
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
],
});
return handleApiError(
request,
{
type: "forbidden",
details: [
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
],
},
auditLog
);
}
const createContactAttributeKeyResult = await createContactAttributeKey(body);
if (!createContactAttributeKeyResult.ok) {
return handleApiError(request, createContactAttributeKeyResult.error as ApiErrorResponseV2);
return handleApiError(request, createContactAttributeKeyResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createContactAttributeKeyResult.data.id;
auditLog.newObject = createContactAttributeKeyResult.data;
}
return responses.createdResponse(createContactAttributeKeyResult);
},
action: "created",
targetType: "contactAttributeKey",
});

View File

@@ -59,35 +59,53 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
params: z.object({ responseId: ZResponseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params.responseId;
}
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
},
auditLog
);
}
const environmentIdResult = await getEnvironmentId(params.responseId, true);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
return handleApiError(request, environmentIdResult.error, auditLog);
}
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
});
return handleApiError(
request,
{
type: "unauthorized",
},
auditLog
);
}
const response = await deleteResponse(params.responseId);
if (!response.ok) {
return handleApiError(request, response.error as ApiErrorResponseV2);
return handleApiError(request, response.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = response.data;
}
return responses.successResponse(response);
},
action: "deleted",
targetType: "response",
});
export const PUT = (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -98,44 +116,56 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
params: z.object({ responseId: ZResponseIdSchema }),
body: ZResponseUpdateSchema,
},
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body, params } = parsedInput;
if (!body || !params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
},
auditLog
);
}
const environmentIdResult = await getEnvironmentId(params.responseId, true);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
return handleApiError(request, environmentIdResult.error, auditLog);
}
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
});
return handleApiError(
request,
{
type: "unauthorized",
},
auditLog
);
}
const existingResponse = await getResponse(params.responseId);
if (!existingResponse.ok) {
return handleApiError(request, existingResponse.error as ApiErrorResponseV2);
return handleApiError(request, existingResponse.error as ApiErrorResponseV2, auditLog);
}
const questionsResponse = await getSurveyQuestions(existingResponse.data.surveyId);
if (!questionsResponse.ok) {
return handleApiError(request, questionsResponse.error as ApiErrorResponseV2);
return handleApiError(request, questionsResponse.error as ApiErrorResponseV2, auditLog);
}
if (!validateFileUploads(body.data, questionsResponse.data.questions)) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
},
auditLog
);
}
// Validate response data for "other" options exceeding character limit
@@ -163,9 +193,16 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
const response = await updateResponse(params.responseId, body);
if (!response.ok) {
return handleApiError(request, response.error as ApiErrorResponseV2);
return handleApiError(request, response.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = existingResponse.data;
auditLog.newObject = response.data;
}
return responses.successResponse(response);
},
action: "updated",
targetType: "response",
});

View File

@@ -51,28 +51,36 @@ export const POST = async (request: Request) =>
schemas: {
body: ZResponseInput,
},
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
if (!body) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
},
auditLog
);
}
const environmentIdResult = await getEnvironmentId(body.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
return handleApiError(request, environmentIdResult.error, auditLog);
}
const environmentId = environmentIdResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return handleApiError(request, {
type: "unauthorized",
});
return handleApiError(
request,
{
type: "unauthorized",
},
auditLog
);
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
@@ -82,14 +90,18 @@ export const POST = async (request: Request) =>
const surveyQuestions = await getSurveyQuestions(body.surveyId);
if (!surveyQuestions.ok) {
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2);
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
},
auditLog
);
}
// Validate response data for "other" options exceeding character limit
@@ -116,9 +128,16 @@ export const POST = async (request: Request) =>
const createResponseResult = await createResponse(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error);
return handleApiError(request, createResponseResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createResponseResult.data.id;
auditLog.newObject = createResponseResult.data;
}
return responses.createdResponse({ data: createResponseResult.data });
},
action: "created",
targetType: "response",
});

View File

@@ -58,55 +58,77 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
body: ZWebhookUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params, body } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.webhookId;
}
if (!body || !params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
},
auditLog
);
}
// get surveys environment
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!surveysEnvironmentId.ok) {
return handleApiError(request, surveysEnvironmentId.error);
return handleApiError(request, surveysEnvironmentId.error, auditLog);
}
// get webhook environment
const webhook = await getWebhook(params.webhookId);
if (!webhook.ok) {
return handleApiError(request, webhook.error as ApiErrorResponseV2);
return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog);
}
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
},
auditLog
);
}
// check if webhook environment matches the surveys environment
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
return handleApiError(request, {
type: "bad_request",
details: [
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
],
});
return handleApiError(
request,
{
type: "bad_request",
details: [
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
],
},
auditLog
);
}
const updatedWebhook = await updateWebhook(params.webhookId, body);
if (!updatedWebhook.ok) {
return handleApiError(request, updatedWebhook.error as ApiErrorResponseV2);
return handleApiError(request, updatedWebhook.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = webhook.data;
auditLog.newObject = updatedWebhook.data;
}
return responses.successResponse(updatedWebhook);
},
action: "updated",
targetType: "webhook",
});
export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
@@ -116,35 +138,52 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
params: z.object({ webhookId: ZWebhookIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.webhookId;
}
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
},
auditLog
);
}
const webhook = await getWebhook(params.webhookId);
if (!webhook.ok) {
return handleApiError(request, webhook.error as ApiErrorResponseV2);
return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog);
}
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
},
auditLog
);
}
const deletedWebhook = await deleteWebhook(params.webhookId);
if (!deletedWebhook.ok) {
return handleApiError(request, deletedWebhook.error as ApiErrorResponseV2);
return handleApiError(request, deletedWebhook.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = webhook.data;
}
return responses.successResponse(deletedWebhook);
},
action: "deleted",
targetType: "webhook",
});

View File

@@ -43,35 +43,50 @@ export const POST = async (request: NextRequest) =>
schemas: {
body: ZWebhookInput,
},
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
if (!body) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
},
auditLog
);
}
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
return handleApiError(request, environmentIdResult.error, auditLog);
}
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
return handleApiError(request, {
type: "forbidden",
details: [{ field: "environmentId", issue: "does not have permission to create webhook" }],
});
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "environmentId", issue: "does not have permission to create webhook" }],
},
auditLog
);
}
const createWebhookResult = await createWebhook(body);
if (!createWebhookResult.ok) {
return handleApiError(request, createWebhookResult.error);
return handleApiError(request, createWebhookResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createWebhookResult.data.id;
auditLog.newObject = createWebhookResult.data;
}
return responses.createdResponse(createWebhookResult);
},
action: "created",
targetType: "webhook",
});

View File

@@ -4,7 +4,9 @@ import { handleApiError } from "@/modules/api/v2/lib/utils";
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import { checkAuthenticationAndAccess } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import {
createProjectTeam,
@@ -53,20 +55,28 @@ export async function POST(request: Request, props: { params: Promise<{ organiza
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { body, params }, authentication }) => {
handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => {
const { teamId, projectId } = body!;
if (auditLog) {
auditLog.targetId = `${projectId}-${teamId}`;
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
return handleApiError(request, hasAccess.error, auditLog);
}
// check if project team already exists
@@ -80,22 +90,32 @@ export async function POST(request: Request, props: { params: Promise<{ organiza
});
if (!existingProjectTeam.ok) {
return handleApiError(request, existingProjectTeam.error);
return handleApiError(request, existingProjectTeam.error, auditLog);
}
if (existingProjectTeam.data.data.length > 0) {
return handleApiError(request, {
type: "conflict",
details: [{ field: "projectTeam", issue: "Project team already exists" }],
});
return handleApiError(
request,
{
type: "conflict",
details: [{ field: "projectTeam", issue: "Project team already exists" }],
},
auditLog
);
}
const result = await createProjectTeam(body!);
if (!result.ok) {
return handleApiError(request, result.error);
return handleApiError(request, result.error, auditLog);
}
if (auditLog) {
auditLog.newObject = result.data;
}
return responses.successResponse({ data: result.data });
},
action: "created",
targetType: "projectTeam",
});
}
@@ -107,29 +127,65 @@ export async function PUT(request: Request, props: { params: Promise<{ organizat
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { body, params }, authentication }) => {
handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => {
const { teamId, projectId } = body!;
if (auditLog) {
auditLog.targetId = `${projectId}-${teamId}`;
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
return handleApiError(request, hasAccess.error, auditLog);
}
// Fetch old object for audit log
let oldProjectTeamData: any = UNKNOWN_DATA;
try {
const oldProjectTeamResult = await getProjectTeams(authentication.organizationId, {
teamId,
projectId,
limit: 1,
skip: 0,
sortBy: "createdAt",
order: "desc",
});
if (oldProjectTeamResult.ok && oldProjectTeamResult.data.data.length > 0) {
oldProjectTeamData = oldProjectTeamResult.data.data[0];
} else {
logger.error(`Failed to fetch old project team data for audit log`);
}
} catch (error) {
logger.error(error, `Failed to fetch old project team data for audit log`);
}
const result = await updateProjectTeam(teamId, projectId, body!);
if (!result.ok) {
return handleApiError(request, result.error);
return handleApiError(request, result.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldProjectTeamData;
auditLog.newObject = result.data;
}
return responses.successResponse({ data: result.data });
},
action: "updated",
targetType: "projectTeam",
});
}
@@ -141,28 +197,63 @@ export async function DELETE(request: Request, props: { params: Promise<{ organi
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { query, params }, authentication }) => {
handler: async ({ parsedInput: { query, params }, authentication, auditLog }) => {
const { teamId, projectId } = query!;
if (auditLog) {
auditLog.targetId = `${projectId}-${teamId}`;
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
return handleApiError(request, hasAccess.error, auditLog);
}
// Fetch old object for audit log
let oldProjectTeamData: any = UNKNOWN_DATA;
try {
const oldProjectTeamResult = await getProjectTeams(authentication.organizationId, {
teamId,
projectId,
limit: 1,
skip: 0,
sortBy: "createdAt",
order: "desc",
});
if (oldProjectTeamResult.ok && oldProjectTeamResult.data.data.length > 0) {
oldProjectTeamData = oldProjectTeamResult.data.data[0];
} else {
logger.error(`Failed to fetch old project team data for audit log`);
}
} catch (error) {
logger.error(error, `Failed to fetch old project team data for audit log`);
}
const result = await deleteProjectTeam(teamId, projectId);
if (!result.ok) {
return handleApiError(request, result.error);
return handleApiError(request, result.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldProjectTeamData;
}
return responses.successResponse({ data: result.data });
},
action: "deleted",
targetType: "projectTeam",
});
}

View File

@@ -13,7 +13,9 @@ import {
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (
@@ -53,22 +55,46 @@ export const DELETE = async (
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
handler: async ({ authentication, parsedInput: { params }, auditLog }) => {
if (auditLog) {
auditLog.targetId = params.teamId;
}
const team = await deleteTeam(params!.organizationId, params!.teamId);
if (!hasOrganizationIdAndAccess(params.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
let oldTeamData: any = UNKNOWN_DATA;
try {
const oldTeamResult = await getTeam(params.organizationId, params.teamId);
if (oldTeamResult.ok) {
oldTeamData = oldTeamResult.data;
}
} catch (error) {
logger.error(`Failed to fetch old team data for audit log: ${JSON.stringify(error)}`);
}
const team = await deleteTeam(params.organizationId, params.teamId);
if (!team.ok) {
return handleApiError(request, team.error as ApiErrorResponseV2);
return handleApiError(request, team.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldTeamData;
}
return responses.successResponse(team);
},
action: "deleted",
targetType: "team",
});
export const PUT = (
@@ -82,20 +108,45 @@ export const PUT = (
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
body: ZTeamUpdateSchema,
},
handler: async ({ authentication, parsedInput: { body, params } }) => {
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
if (auditLog) {
auditLog.targetId = params.teamId;
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
let oldTeamData: any = UNKNOWN_DATA;
try {
const oldTeamResult = await getTeam(params.organizationId, params.teamId);
if (oldTeamResult.ok) {
oldTeamData = oldTeamResult.data;
}
} catch (error) {
logger.error(`Failed to fetch old team data for audit log: ${JSON.stringify(error)}`);
}
const team = await updateTeam(params!.organizationId, params!.teamId, body!);
if (!team.ok) {
return handleApiError(request, team.error as ApiErrorResponseV2);
return handleApiError(request, team.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldTeamData;
auditLog.newObject = team.data;
}
return responses.successResponse(team);
},
action: "updated",
targetType: "team",
});

View File

@@ -46,19 +46,30 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params } }) => {
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
const createTeamResult = await createTeam(body!, authentication.organizationId);
if (!createTeamResult.ok) {
return handleApiError(request, createTeamResult.error);
return handleApiError(request, createTeamResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createTeamResult.data.id;
auditLog.newObject = createTeamResult.data;
}
return responses.createdResponse({ data: createTeamResult.data });
},
action: "created",
targetType: "team",
});

View File

@@ -14,8 +14,10 @@ import {
ZUserInput,
ZUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) =>
@@ -59,28 +61,45 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params } }) => {
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
if (IS_FORMBRICKS_CLOUD) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [
{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" },
],
},
auditLog
);
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
const createUserResult = await createUser(body!, authentication.organizationId);
if (!createUserResult.ok) {
return handleApiError(request, createUserResult.error);
return handleApiError(request, createUserResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createUserResult.data.id;
auditLog.newObject = createUserResult.data;
}
return responses.createdResponse({ data: createUserResult.data });
},
action: "created",
targetType: "user",
});
export const PATCH = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
@@ -91,33 +110,75 @@ export const PATCH = async (request: Request, props: { params: Promise<{ organiz
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params } }) => {
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
if (IS_FORMBRICKS_CLOUD) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [
{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" },
],
},
auditLog
);
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
if (!body?.email) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "email", issue: "Email is required" }],
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "email", issue: "Email is required" }],
},
auditLog
);
}
let oldUserData: any = UNKNOWN_DATA;
try {
const oldUserResult = await getUsers(authentication.organizationId, {
email: body.email,
limit: 1,
skip: 0,
sortBy: "createdAt",
order: "desc",
});
if (oldUserResult.ok) {
oldUserData = oldUserResult.data.data[0];
}
} catch (error) {
logger.error(`Failed to fetch old user data for audit log: ${JSON.stringify(error)}`);
}
if (auditLog) {
auditLog.targetId = oldUserData !== UNKNOWN_DATA ? oldUserData?.id : UNKNOWN_DATA;
}
const updateUserResult = await updateUser(body, authentication.organizationId);
if (!updateUserResult.ok) {
return handleApiError(request, updateUserResult.error);
return handleApiError(request, updateUserResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = auditLog.targetId === UNKNOWN_DATA ? updateUserResult.data.id : auditLog.targetId;
auditLog.oldObject = oldUserData;
auditLog.newObject = updateUserResult.data;
}
return responses.successResponse({ data: updateUserResult.data });
},
action: "updated",
targetType: "user",
});

View File

@@ -0,0 +1,149 @@
import { logSignOut } from "@/modules/auth/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { logSignOutAction } from "./sign-out";
// Mock the dependencies
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({
logSignOut: vi.fn(),
}));
// Clear the existing mock from vitestSetup.ts
vi.unmock("@/modules/auth/actions/sign-out");
describe("logSignOutAction", () => {
const mockUserId = "user123";
const mockUserEmail = "test@example.com";
const mockContext = {
reason: "user_initiated" as const,
redirectUrl: "https://example.com",
organizationId: "org123",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("calls logSignOut with correct parameters", async () => {
await logSignOutAction(mockUserId, mockUserEmail, mockContext);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, mockContext);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with minimal parameters", async () => {
const minimalContext = {};
await logSignOutAction(mockUserId, mockUserEmail, minimalContext);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, minimalContext);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with context containing only reason", async () => {
const contextWithReason = { reason: "session_timeout" as const };
await logSignOutAction(mockUserId, mockUserEmail, contextWithReason);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithReason);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with context containing only redirectUrl", async () => {
const contextWithRedirectUrl = { redirectUrl: "https://redirect.com" };
await logSignOutAction(mockUserId, mockUserEmail, contextWithRedirectUrl);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithRedirectUrl);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with context containing only organizationId", async () => {
const contextWithOrgId = { organizationId: "org456" };
await logSignOutAction(mockUserId, mockUserEmail, contextWithOrgId);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithOrgId);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("handles all possible reason values", async () => {
const reasons = [
"user_initiated",
"account_deletion",
"email_change",
"session_timeout",
"forced_logout",
] as const;
for (const reason of reasons) {
const context = { reason };
await logSignOutAction(mockUserId, mockUserEmail, context);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, context);
}
expect(logSignOut).toHaveBeenCalledTimes(reasons.length);
});
test("logs error and re-throws when logSignOut throws an Error", async () => {
const mockError = new Error("Failed to log sign out");
vi.mocked(logSignOut).mockImplementation(() => {
throw mockError;
});
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
test("logs error and re-throws when logSignOut throws a non-Error", async () => {
const mockError = "String error";
vi.mocked(logSignOut).mockImplementation(() => {
throw mockError;
});
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
test("logs error with empty context when logSignOut throws", async () => {
const mockError = new Error("Failed to log sign out");
const emptyContext = {};
vi.mocked(logSignOut).mockImplementation(() => {
throw mockError;
});
await expect(() => logSignOutAction(mockUserId, mockUserEmail, emptyContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: emptyContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
test("does not log error when logSignOut succeeds", async () => {
await logSignOutAction(mockUserId, mockUserEmail, mockContext);
expect(logger.error).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,32 @@
"use server";
import { logSignOut } from "@/modules/auth/lib/utils";
import { logger } from "@formbricks/logger";
/**
* Logs a sign out event
* @param userId - The ID of the user who signed out
* @param userEmail - The email of the user who signed out
* @param context - The context of the sign out event
*/
export const logSignOutAction = async (
userId: string,
userEmail: string,
context: {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
redirectUrl?: string;
organizationId?: string;
}
) => {
try {
logSignOut(userId, userEmail, context);
} catch (error) {
logger.error("Failed to log sign out event", {
userId,
context,
error: error instanceof Error ? error.message : String(error),
});
// Re-throw to ensure callers are aware of the failure
throw error;
}
};

View File

@@ -3,8 +3,9 @@
import { hashPassword } from "@/lib/auth";
import { verifyToken } from "@/lib/jwt";
import { actionClient } from "@/lib/utils/action-client";
import { updateUser } from "@/modules/auth/lib/user";
import { getUser } from "@/modules/auth/lib/user";
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getUser, updateUser } from "@/modules/auth/lib/user";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendPasswordResetNotifyEmail } from "@/modules/email";
import { z } from "zod";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -15,16 +16,25 @@ const ZResetPasswordAction = z.object({
password: ZUserPassword,
});
export const resetPasswordAction = actionClient
.schema(ZResetPasswordAction)
.action(async ({ parsedInput }) => {
const hashedPassword = await hashPassword(parsedInput.password);
const { id } = await verifyToken(parsedInput.token);
const user = await getUser(id);
if (!user) {
throw new ResourceNotFoundError("user", id);
export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
const hashedPassword = await hashPassword(parsedInput.password);
const { id } = await verifyToken(parsedInput.token);
const oldObject = await getUser(id);
if (!oldObject) {
throw new ResourceNotFoundError("user", id);
}
const updatedUser = await updateUser(id, { password: hashedPassword });
ctx.auditLoggingCtx.userId = id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = updatedUser;
await sendPasswordResetNotifyEmail(updatedUser);
return { success: true };
}
const updatedUser = await updateUser(id, { password: hashedPassword });
await sendPasswordResetNotifyEmail(updatedUser);
return { success: true };
});
)
);

View File

@@ -0,0 +1,251 @@
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
import "@testing-library/jest-dom/vitest";
import { cleanup, renderHook } from "@testing-library/react";
import { signOut } from "next-auth/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
// Import the actual hook (unmock it for testing)
vi.unmock("@/modules/auth/hooks/use-sign-out");
const { useSignOut } = await import("./use-sign-out");
// Mock dependencies
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next-auth/react", () => ({
signOut: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("useSignOut", () => {
const mockSessionUser = {
id: "user-123",
email: "test@example.com",
};
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
});
test("should return signOut function", () => {
const { result } = renderHook(() => useSignOut());
expect(result.current.signOut).toBeDefined();
expect(typeof result.current.signOut).toBe("function");
});
test("should sign out without audit logging when no session user", async () => {
const { result } = renderHook(() => useSignOut());
await result.current.signOut();
expect(logSignOutAction).not.toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({
redirect: undefined,
callbackUrl: undefined,
});
});
test("should sign out with audit logging when session user exists", async () => {
const { result } = renderHook(() => useSignOut(mockSessionUser));
await result.current.signOut({
reason: "user_initiated",
redirectUrl: "/dashboard",
organizationId: "org-123",
});
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
reason: "user_initiated",
redirectUrl: "/dashboard",
organizationId: "org-123",
});
expect(signOut).toHaveBeenCalledWith({
redirect: undefined,
callbackUrl: undefined,
});
});
test("should handle null session user", async () => {
const { result } = renderHook(() => useSignOut(null));
await result.current.signOut();
expect(logSignOutAction).not.toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({
redirect: undefined,
callbackUrl: undefined,
});
});
test("should use default reason when not provided", async () => {
const { result } = renderHook(() => useSignOut(mockSessionUser));
await result.current.signOut();
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
reason: "user_initiated",
redirectUrl: undefined,
organizationId: undefined,
});
});
test("should use callbackUrl as redirectUrl when redirectUrl not provided", async () => {
const { result } = renderHook(() => useSignOut(mockSessionUser));
await result.current.signOut({
callbackUrl: "/auth/login",
organizationId: "org-456",
});
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org-456",
});
expect(signOut).toHaveBeenCalledWith({
redirect: undefined,
callbackUrl: "/auth/login",
});
});
test("should pass through NextAuth signOut options", async () => {
const { result } = renderHook(() => useSignOut(mockSessionUser));
await result.current.signOut({
redirect: false,
callbackUrl: "/custom-redirect",
});
expect(signOut).toHaveBeenCalledWith({
redirect: false,
callbackUrl: "/custom-redirect",
});
});
test("should handle different sign out reasons", async () => {
const { result } = renderHook(() => useSignOut(mockSessionUser));
const reasons = ["account_deletion", "email_change", "session_timeout", "forced_logout"] as const;
for (const reason of reasons) {
vi.clearAllMocks();
await result.current.signOut({ reason });
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
reason,
redirectUrl: undefined,
organizationId: undefined,
});
}
});
test("should handle session user without email", async () => {
const userWithoutEmail = { id: "user-456" };
const { result } = renderHook(() => useSignOut(userWithoutEmail));
await result.current.signOut();
expect(logSignOutAction).toHaveBeenCalledWith("user-456", "", {
reason: "user_initiated",
redirectUrl: undefined,
organizationId: undefined,
});
});
test("should not block sign out when audit logging fails", async () => {
vi.mocked(logSignOutAction).mockRejectedValueOnce(new Error("Audit logging failed"));
const { result } = renderHook(() => useSignOut(mockSessionUser));
await result.current.signOut();
expect(logger.error).toHaveBeenCalledWith("Failed to log signOut event:", expect.any(Error));
expect(signOut).toHaveBeenCalledWith({
redirect: undefined,
callbackUrl: undefined,
});
});
test("should return NextAuth signOut result", async () => {
const mockSignOutResult = { url: "https://example.com/signed-out" };
vi.mocked(signOut).mockResolvedValueOnce(mockSignOutResult);
const { result } = renderHook(() => useSignOut(mockSessionUser));
const signOutResult = await result.current.signOut();
expect(signOutResult).toBe(mockSignOutResult);
});
test("should handle audit logging error and still return NextAuth result", async () => {
const mockSignOutResult = { url: "https://example.com/signed-out" };
vi.mocked(logSignOutAction).mockRejectedValueOnce(new Error("Network error"));
vi.mocked(signOut).mockResolvedValueOnce(mockSignOutResult);
const { result } = renderHook(() => useSignOut(mockSessionUser));
const signOutResult = await result.current.signOut();
expect(logger.error).toHaveBeenCalled();
expect(signOutResult).toBe(mockSignOutResult);
});
test("should handle complex sign out scenario", async () => {
const { result } = renderHook(() => useSignOut(mockSessionUser));
await result.current.signOut({
reason: "email_change",
redirectUrl: "/profile/email-changed",
organizationId: "org-complex-123",
redirect: true,
callbackUrl: "/dashboard",
});
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
reason: "email_change",
redirectUrl: "/profile/email-changed", // redirectUrl takes precedence over callbackUrl
organizationId: "org-complex-123",
});
expect(signOut).toHaveBeenCalledWith({
redirect: true,
callbackUrl: "/dashboard",
});
});
test("should wait for audit logging before calling NextAuth signOut", async () => {
let auditLogResolved = false;
vi.mocked(logSignOutAction).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
auditLogResolved = true;
});
const { result } = renderHook(() => useSignOut(mockSessionUser));
const signOutPromise = result.current.signOut();
// NextAuth signOut should not be called immediately
expect(signOut).not.toHaveBeenCalled();
await signOutPromise;
expect(auditLogResolved).toBe(true);
expect(signOut).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
import { signOut } from "next-auth/react";
import { logger } from "@formbricks/logger";
interface UseSignOutOptions {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
redirectUrl?: string;
organizationId?: string;
redirect?: boolean;
callbackUrl?: string;
}
interface SessionUser {
id: string;
email?: string;
}
/**
* Custom hook to handle sign out with audit logging
* @param sessionUser - The current user session data (optional)
* @returns {Object} - An object containing the signOutWithAudit function
*/
export const useSignOut = (sessionUser?: SessionUser | null) => {
const signOutWithAudit = async (options?: UseSignOutOptions) => {
// Log audit event before signing out (server action)
if (sessionUser?.id) {
try {
await logSignOutAction(sessionUser.id, sessionUser.email ?? "", {
reason: options?.reason || "user_initiated", // NOSONAR // We want to check for empty strings
redirectUrl: options?.redirectUrl || options?.callbackUrl, // NOSONAR // We want to check for empty strings
organizationId: options?.organizationId,
});
} catch (error) {
// Don't block signOut if audit logging fails
logger.error("Failed to log signOut event:", error);
}
}
// Call NextAuth signOut
return await signOut({
redirect: options?.redirect,
callbackUrl: options?.callbackUrl,
});
};
return { signOut: signOutWithAudit };
};

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "smtp.example.com",
SMTP_PORT: "587",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next-auth", () => ({

View File

@@ -8,8 +8,31 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
}));
// Mock next/headers
vi.mock("next/headers", () => ({
headers: () => ({
get: () => null,
has: () => false,
keys: () => [],
values: () => [],
entries: () => [],
forEach: () => {},
}),
cookies: () => ({
get: (name: string) => {
if (name === "next-auth.callback-url") {
@@ -73,7 +96,7 @@ describe("authOptions", () => {
id: mockUser.id,
email: mockUser.email,
password: null,
});
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -87,7 +110,7 @@ describe("authOptions", () => {
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
});
} as any);
const credentials = { email: mockUser.email, password: "wrongPassword" };
@@ -106,7 +129,7 @@ describe("authOptions", () => {
twoFactorEnabled: false,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser as any);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -128,7 +151,7 @@ describe("authOptions", () => {
twoFactorEnabled: true,
backupCodes: null,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { email: mockUser.email, password: mockPassword, backupCode: "123456" };
@@ -157,7 +180,7 @@ describe("authOptions", () => {
});
test("should throw error if email is already verified", async () => {
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { token: createToken(mockUser.id, mockUser.email) };
@@ -167,7 +190,7 @@ describe("authOptions", () => {
});
test("should update user and verify email when token is valid", async () => {
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null });
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
@@ -175,7 +198,7 @@ describe("authOptions", () => {
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
});
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
@@ -193,7 +216,7 @@ describe("authOptions", () => {
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
});
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
@@ -259,7 +282,7 @@ describe("authOptions", () => {
twoFactorEnabled: true,
twoFactorSecret: "encrypted_secret",
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -276,7 +299,7 @@ describe("authOptions", () => {
twoFactorEnabled: true,
twoFactorSecret: null,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = {
email: mockUser.email,

View File

@@ -7,7 +7,16 @@ import {
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { verifyPassword } from "@/modules/auth/lib/utils";
import {
logAuthAttempt,
logAuthEvent,
logAuthSuccess,
logEmailVerificationAttempt,
logTwoFactorAttempt,
shouldLogAuthFailure,
verifyPassword,
} from "@/modules/auth/lib/utils";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import type { Account, NextAuthOptions } from "next-auth";
@@ -43,9 +52,16 @@ export const authOptions: NextAuthOptions = {
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials, _req) {
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
if (!credentials) {
if (await shouldLogAuthFailure("no_credentials")) {
logAuthAttempt("no_credentials_provided", "credentials", "credentials_validation");
}
throw new Error("Invalid credentials");
}
let user;
try {
user = await prisma.user.findUnique({
@@ -55,37 +71,70 @@ export const authOptions: NextAuthOptions = {
});
} catch (e) {
logger.error(e, "Error in CredentialsProvider authorize");
logAuthAttempt("database_error", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
throw Error("Internal server error. Please try again later");
}
if (!user) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
}
throw new Error("Invalid credentials");
}
if (!user.password) {
logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email);
throw new Error("User has no password stored");
}
if (user.isActive === false) {
logAuthAttempt("account_inactive", "credentials", "account_status", user.id, user.email);
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
const isValid = await verifyPassword(credentials.password, user.password);
if (!isValid) {
if (await shouldLogAuthFailure(user.email)) {
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);
}
throw new Error("Invalid credentials");
}
logAuthSuccess("passwordVerified", "credentials", "password_validation", user.id, user.email, {
requires2FA: user.twoFactorEnabled,
});
if (user.twoFactorEnabled && credentials.backupCode) {
if (!ENCRYPTION_KEY) {
logger.error("Missing encryption key; cannot proceed with backup code login.");
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "encryption_key_missing");
throw new Error("Internal Server Error");
}
if (!user.backupCodes) throw new Error("No backup codes found");
if (!user.backupCodes) {
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "no_backup_codes");
throw new Error("No backup codes found");
}
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
let backupCodes;
try {
backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
} catch (e) {
logger.error(e, "Error in CredentialsProvider authorize");
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "invalid_backup_codes");
throw new Error("Invalid backup codes");
}
// check if user-supplied code matches one
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
if (index === -1) throw new Error("Invalid backup code");
if (index === -1) {
if (await shouldLogAuthFailure(user.email)) {
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "invalid_backup_code");
}
throw new Error("Invalid backup code");
}
// delete verified backup code and re-encrypt remaining
backupCodes[index] = null;
@@ -97,30 +146,58 @@ export const authOptions: NextAuthOptions = {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), ENCRYPTION_KEY),
},
});
logTwoFactorAttempt(true, "backup_code", user.id, user.email, undefined, {
backupCodeConsumed: true,
});
} else if (user.twoFactorEnabled) {
if (!credentials.totpCode) {
logAuthEvent("twoFactorRequired", "success", user.id, user.email, {
provider: "credentials",
authMethod: "password_validation",
requiresTOTP: true,
});
throw new Error("second factor required");
}
if (!user.twoFactorSecret) {
logTwoFactorAttempt(false, "totp", user.id, user.email, "no_2fa_secret");
throw new Error("Internal Server Error");
}
if (!ENCRYPTION_KEY) {
logTwoFactorAttempt(false, "totp", user.id, user.email, "encryption_key_missing");
throw new Error("Internal Server Error");
}
const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY);
if (secret.length !== 32) {
logTwoFactorAttempt(false, "totp", user.id, user.email, "invalid_2fa_secret");
throw new Error("Invalid two factor secret");
}
const isValidToken = (await import("./totp")).totpAuthenticatorCheck(credentials.totpCode, secret);
if (!isValidToken) {
if (await shouldLogAuthFailure(user.email)) {
logTwoFactorAttempt(false, "totp", user.id, user.email, "invalid_totp_code");
}
throw new Error("Invalid two factor code");
}
logTwoFactorAttempt(true, "totp", user.id, user.email);
}
let authMethod;
if (!user.twoFactorEnabled) {
authMethod = "password_only";
} else if (credentials.backupCode) {
authMethod = "password_and_backup_code";
} else {
authMethod = "password_and_totp";
}
logAuthSuccess("authenticationSucceeded", "credentials", authMethod, user.id, user.email);
return {
id: user.id,
email: user.email,
@@ -144,11 +221,19 @@ export const authOptions: NextAuthOptions = {
},
},
async authorize(credentials, _req) {
// For token verification, we can't rate limit effectively by token (single-use)
// So we use a generic identifier for token abuse attempts
const identifier = "email_verification_attempts";
let user;
try {
if (!credentials?.token) {
if (await shouldLogAuthFailure(identifier)) {
logEmailVerificationAttempt(false, "token_not_provided");
}
throw new Error("Token not found");
}
const { id } = await verifyToken(credentials?.token);
user = await prisma.user.findUnique({
where: {
@@ -156,23 +241,39 @@ export const authOptions: NextAuthOptions = {
},
});
} catch (e) {
logger.error(e, "Error in CredentialsProvider authorize");
if (await shouldLogAuthFailure(identifier)) {
logEmailVerificationAttempt(false, "invalid_token", UNKNOWN_DATA, undefined, {
tokenProvided: !!credentials?.token,
});
}
throw new Error("Either a user does not match the provided token or the token is invalid");
}
if (!user) {
if (await shouldLogAuthFailure(identifier)) {
logEmailVerificationAttempt(false, "user_not_found_for_token");
}
throw new Error("Either a user does not match the provided token or the token is invalid");
}
if (user.emailVerified) {
logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email);
throw new Error("Email already verified");
}
if (user.isActive === false) {
logEmailVerificationAttempt(false, "account_inactive", user.id, user.email);
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
user = await updateUser(user.id, { emailVerified: new Date() });
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
emailVerifiedAt: user.emailVerified,
});
// send new user to brevo after email verification
createBrevoCustomer({ id: user.id, email: user.email });

View File

@@ -1,38 +1,401 @@
import { describe, expect, test } from "vitest";
import { hashPassword, verifyPassword } from "./utils";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
createAuditIdentifier,
hashPassword,
logAuthAttempt,
logAuthEvent,
logAuthSuccess,
logEmailVerificationAttempt,
logSignOut,
logTwoFactorAttempt,
shouldLogAuthFailure,
verifyPassword,
} from "./utils";
describe("Password Utils", () => {
const password = "password";
const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy";
// Mock the audit event handler
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
describe("hashPassword", () => {
test("should hash a password", async () => {
const hashedPassword = await hashPassword(password);
// Mock crypto for consistent hash testing
vi.mock("crypto", () => ({
createHash: vi.fn(() => ({
update: vi.fn(() => ({
digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string
})),
})),
}));
expect(typeof hashedPassword).toBe("string");
expect(hashedPassword).not.toBe(password);
expect(hashedPassword.length).toBe(60);
});
// Mock Sentry
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
test("should generate different hashes for the same password", async () => {
const hash1 = await hashPassword(password);
const hash2 = await hashPassword(password);
// Mock constants
vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "test-sentry-dsn",
IS_PRODUCTION: true,
REDIS_URL: "redis://localhost:6379",
}));
expect(hash1).not.toBe(hash2);
});
// Mock Redis client
vi.mock("@/modules/cache/redis", () => ({
default: null, // Intentionally simulate Redis unavailability to test fail-closed security behavior
}));
describe("Auth Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("verifyPassword", () => {
afterEach(() => {
vi.clearAllTimers();
});
describe("Password Utils", () => {
const password = "password";
const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy";
test("should hash a password", async () => {
const newHashedPassword = await hashPassword(password);
expect(typeof newHashedPassword).toBe("string");
expect(newHashedPassword).not.toBe(password);
expect(newHashedPassword.length).toBe(60);
});
test("should verify a correct password", async () => {
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
test("should reject an incorrect password", async () => {
const isValid = await verifyPassword("WrongPassword123!", hashedPassword);
expect(isValid).toBe(false);
});
});
describe("Audit Identifier Utils", () => {
test("should create a hashed identifier for email", () => {
const email = "user@example.com";
const identifier = createAuditIdentifier(email, "email");
expect(identifier).toBe("email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
expect(identifier).not.toContain("user@example.com");
});
test("should return unknown for empty/unknown identifiers", () => {
expect(createAuditIdentifier("")).toBe("unknown");
expect(createAuditIdentifier("unknown")).toBe("unknown");
expect(createAuditIdentifier("unknown_user")).toBe("unknown");
});
test("should create consistent hashes for same input", () => {
const email = "test@example.com";
const id1 = createAuditIdentifier(email, "email");
const id2 = createAuditIdentifier(email, "email");
expect(id1).toBe(id2);
});
test("should use default prefix when none provided", () => {
const identifier = createAuditIdentifier("test@example.com");
expect(identifier).toMatch(/^actor_/);
});
});
describe("Rate Limiting", () => {
test("should always allow successful authentication logging", async () => {
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
});
test("should implement fail-closed behavior when Redis is unavailable", async () => {
const email = "rate-limit-test@example.com";
// When Redis is unavailable (mocked as null), the system fails closed for security.
// This prevents authentication failure logging when we cannot enforce rate limiting,
// ensuring consistent security posture across distributed systems.
// All authentication failure attempts should return false (do not log).
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 1st failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 2nd failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 3rd failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 4th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 5th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 6th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 7th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 8th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 9th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 10th failure - blocked
});
});
describe("Audit Logging Functions", () => {
test("should log auth event with hashed identifier", () => {
logAuthEvent("authenticationAttempted", "failure", "unknown", "user@example.com", {
failureReason: "invalid_password",
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "authenticationAttempted",
targetType: "user",
userId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
targetId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
organizationId: "unknown",
status: "failure",
userType: "user",
newObject: {
failureReason: "invalid_password",
},
});
});
test("should use provided userId when available", () => {
logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", {
requires2FA: true,
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "passwordVerified",
targetType: "user",
userId: "user_123",
targetId: "user_123",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: {
requires2FA: true,
},
});
});
test("should log authentication attempt with correct structure", () => {
logAuthAttempt(
"invalid_password",
"credentials",
"password_validation",
"user_123",
"user@example.com"
);
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "authenticationAttempted",
status: "failure",
userId: "user_123",
newObject: expect.objectContaining({
failureReason: "invalid_password",
provider: "credentials",
authMethod: "password_validation",
}),
})
);
});
test("should log successful authentication", () => {
logAuthSuccess(
"authenticationSucceeded",
"credentials",
"password_only",
"user_123",
"user@example.com"
);
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "authenticationSucceeded",
status: "success",
userId: "user_123",
newObject: expect.objectContaining({
provider: "credentials",
authMethod: "password_only",
}),
})
);
});
test("should log two-factor verification", () => {
logTwoFactorAttempt(true, "totp", "user_123", "user@example.com");
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "twoFactorVerified",
status: "success",
newObject: expect.objectContaining({
provider: "credentials",
authMethod: "totp",
}),
})
);
});
test("should log failed two-factor attempt", () => {
logTwoFactorAttempt(false, "backup_code", "user_123", "user@example.com", "invalid_backup_code");
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "twoFactorAttempted",
status: "failure",
newObject: expect.objectContaining({
provider: "credentials",
authMethod: "backup_code",
failureReason: "invalid_backup_code",
}),
})
);
});
test("should log email verification", () => {
logEmailVerificationAttempt(true, undefined, "user_123", "user@example.com", {
emailVerifiedAt: new Date().toISOString(),
});
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "emailVerified",
status: "success",
newObject: expect.objectContaining({
provider: "token",
authMethod: "email_verification",
}),
})
);
});
test("should log failed email verification", () => {
logEmailVerificationAttempt(false, "invalid_token", "user_123", "user@example.com", {
tokenProvided: true,
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "emailVerificationAttempted",
targetType: "user",
userId: "user_123",
userType: "user",
targetId: "user_123",
organizationId: UNKNOWN_DATA,
status: "failure",
newObject: {
failureReason: "invalid_token",
provider: "token",
authMethod: "email_verification",
tokenProvided: true,
},
});
});
test("should log user sign out event", () => {
logSignOut("user_123", "user@example.com", {
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org_123",
});
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "userSignedOut",
targetType: "user",
userId: "user_123",
userType: "user",
targetId: "user_123",
organizationId: UNKNOWN_DATA,
status: "success",
newObject: {
provider: "session",
authMethod: "sign_out",
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org_123",
},
});
});
test("should log sign out with default reason", () => {
logSignOut("user_123", "user@example.com");
expect(queueAuditEventBackground).toHaveBeenCalledWith({
action: "userSignedOut",
targetType: "user",
userId: "user_123",
userType: "user",
targetId: "user_123",
organizationId: UNKNOWN_DATA,
status: "success",
newObject: {
provider: "session",
authMethod: "sign_out",
reason: "user_initiated",
organizationId: undefined,
redirectUrl: undefined,
},
});
});
});
describe("PII Protection", () => {
test("should never log actual email addresses", () => {
const email = "sensitive@company.com";
logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email);
const logCall = (queueAuditEventBackground as any).mock.calls[0][0];
const logString = JSON.stringify(logCall);
expect(logString).not.toContain("sensitive@company.com");
expect(logString).not.toContain("company.com");
expect(logString).not.toContain("sensitive");
});
test("should create consistent hashed identifiers", () => {
const email = "user@example.com";
logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email);
logAuthAttempt("user_not_found", "credentials", "user_lookup", "unknown", email);
const calls = (queueAuditEventBackground as any).mock.calls;
expect(calls[0][0].userId).toBe(calls[1][0].userId);
});
});
describe("Sentry Integration", () => {
test("should capture authentication failures to Sentry", () => {
logAuthEvent("authenticationAttempted", "failure", "user_123", "user@example.com", {
failureReason: "invalid_password",
provider: "credentials",
authMethod: "password_validation",
tags: { security_event: "password_failure" },
});
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
tags: expect.objectContaining({
component: "authentication",
action: "authenticationAttempted",
status: "failure",
security_event: "password_failure",
}),
extra: expect.objectContaining({
userId: "user_123",
provider: "credentials",
authMethod: "password_validation",
failureReason: "invalid_password",
}),
})
);
});
test("should not capture successful authentication to Sentry", () => {
vi.clearAllMocks();
logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", {
provider: "credentials",
authMethod: "password_validation",
});
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,11 @@
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import redis from "@/modules/cache/redis";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { compare, hash } from "bcryptjs";
import { createHash, randomUUID } from "crypto";
import { logger } from "@formbricks/logger";
export const hashPassword = async (password: string) => {
const hashedPassword = await hash(password, 12);
@@ -9,3 +16,283 @@ export const verifyPassword = async (password: string, hashedPassword: string) =
const isValid = await compare(password, hashedPassword);
return isValid;
};
/**
* Creates a consistent hashed identifier for audit logging that protects PII
* while still allowing pattern tracking and rate limiting.
*
* @param identifier - The identifier to hash (email, IP, etc.)
* @param prefix - Optional prefix for the hash (e.g., "email", "ip")
* @returns A consistent SHA-256 hash that can be used for tracking without exposing PII
*/
export const createAuditIdentifier = (identifier: string, prefix: string = "actor"): string => {
if (!identifier || identifier === "unknown" || identifier === "unknown_user") {
return UNKNOWN_DATA;
}
// Create a consistent hash that can be used for pattern detection
// Use a longer hash for better collision resistance in compliance scenarios
const hash = createHash("sha256").update(identifier.toLowerCase()).digest("hex");
return `${prefix}_${hash.substring(0, 32)}`; // Use first 32 chars for better uniqueness
};
export const logAuthEvent = (
action: TAuditAction,
status: TAuditStatus,
userId: string,
email?: string,
additionalData: Record<string, any> = {}
) => {
const auditActorId = userId === UNKNOWN_DATA && email ? createAuditIdentifier(email, "email") : userId;
// Log failures to Sentry for monitoring and alerting
if (status === "failure" && SENTRY_DSN && IS_PRODUCTION) {
const error = new Error(`Authentication ${action} failed`);
Sentry.captureException(error, {
tags: {
component: "authentication",
action,
status,
...(additionalData.tags ?? {}),
},
extra: {
userId: auditActorId,
provider: additionalData.provider,
authMethod: additionalData.authMethod,
failureReason: additionalData.failureReason,
...additionalData,
},
});
}
queueAuditEventBackground({
action,
targetType: "user",
userId: auditActorId,
targetId: auditActorId,
organizationId: UNKNOWN_DATA,
status,
userType: "user",
newObject: {
...additionalData,
},
});
};
/**
* Helper function for logging authentication attempts with consistent failure reasons.
*
* @param failureReason - Specific reason for authentication failure
* @param provider - Authentication provider (credentials, token, etc.)
* @param authMethod - Authentication method (password, totp, backup_code, etc.)
* @param userId - User ID (use UNKNOWN_DATA if not available)
* @param email - User email (optional) - used ONLY to create hashed identifier, never stored
* @param additionalData - Additional context data
*/
export const logAuthAttempt = (
failureReason: string,
provider: string,
authMethod: string,
userId: string = UNKNOWN_DATA,
email?: string,
additionalData: Record<string, any> = {}
) => {
logAuthEvent("authenticationAttempted", "failure", userId, email, {
failureReason,
provider,
authMethod,
...additionalData,
});
};
/**
* Helper function for logging successful authentication events.
*
* @param action - The specific success action (passwordVerified, twoFactorVerified, etc.)
* @param provider - Authentication provider
* @param authMethod - Authentication method
* @param userId - User ID
* @param email - User email - used ONLY to create hashed identifier, never stored
* @param additionalData - Additional context data
*/
export const logAuthSuccess = (
action: TAuditAction,
provider: string,
authMethod: string,
userId: string,
email: string,
additionalData: Record<string, any> = {}
) => {
logAuthEvent(action, "success", userId, email, {
provider,
authMethod,
...additionalData,
});
};
/**
* Helper function for logging two-factor authentication attempts.
*
* @param isSuccess - Whether the 2FA attempt was successful
* @param authMethod - 2FA method (totp, backup_code)
* @param userId - User ID
* @param email - User email - used ONLY to create hashed identifier, never stored
* @param failureReason - Failure reason (only for failed attempts)
* @param additionalData - Additional context data
*/
export const logTwoFactorAttempt = (
isSuccess: boolean,
authMethod: string,
userId: string,
email: string,
failureReason?: string,
additionalData: Record<string, any> = {}
) => {
const action = isSuccess ? "twoFactorVerified" : "twoFactorAttempted";
const status = isSuccess ? "success" : "failure";
logAuthEvent(action, status, userId, email, {
provider: "credentials",
authMethod,
...(failureReason && !isSuccess ? { failureReason } : {}),
...additionalData,
});
};
/**
* Helper function for logging email verification attempts.
*
* @param isSuccess - Whether the verification was successful
* @param failureReason - Failure reason (only for failed attempts)
* @param userId - User ID (use UNKNOWN_DATA if not available)
* @param email - User email (optional) - used ONLY to create hashed identifier, never stored
* @param additionalData - Additional context data
*/
export const logEmailVerificationAttempt = (
isSuccess: boolean,
failureReason?: string,
userId: string = UNKNOWN_DATA,
email?: string,
additionalData: Record<string, any> = {}
) => {
const action = isSuccess ? "emailVerified" : "emailVerificationAttempted";
const status = isSuccess ? "success" : "failure";
logAuthEvent(action, status, userId, email, {
provider: "token",
authMethod: "email_verification",
...(failureReason && !isSuccess ? { failureReason } : {}),
...additionalData,
});
};
// Rate limiting constants
const RATE_LIMIT_WINDOW = 5 * 60 * 1000; // 5 minutes
const AGGREGATION_THRESHOLD = 3; // After 3 failures, start aggregating
/**
* Rate limiting decision function for authentication audit logs.
* Uses Redis for distributed rate limiting across Kubernetes pods.
*
* **What this function does:**
* - Returns true/false to indicate whether an auth attempt should be logged
* - Always returns true for successful authentications (no rate limiting)
* - For failures: allows first 3 attempts per identifier within 5-minute window
* - After 3 failures: allows every 10th attempt OR after 1+ minute gap
* - Uses hashed identifiers to protect PII while enabling tracking
* - Returns false if Redis is unavailable (fail closed)
*
* **Use cases:**
* - Gate authentication failure logging to prevent spam
* - Provide consistent rate limiting decisions across Kubernetes pods
* - Protect user PII through identifier hashing
*
* **Example usage:**
* ```typescript
* if (await shouldLogAuthFailure(user.email)) {
* logAuthAttempt("invalid_password", "credentials", "password", user.id, user.email);
* }
* ```
*
* @param identifier - Unique identifier for rate limiting (email, token, etc.) - will be hashed
* @param isSuccess - Whether this is a successful authentication (defaults to false)
* @returns Promise<boolean> - Whether this attempt should be logged to audit trail
*/
export const shouldLogAuthFailure = async (
identifier: string,
isSuccess: boolean = false
): Promise<boolean> => {
// Always log successful authentications
if (isSuccess) return true;
const rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`;
const now = Date.now();
if (redis) {
try {
// Use Redis for distributed rate limiting
const multi = redis.multi();
const windowStart = now - RATE_LIMIT_WINDOW;
// Remove expired entries and count recent failures
multi.zremrangebyscore(rateLimitKey, 0, windowStart);
multi.zcard(rateLimitKey);
multi.zadd(rateLimitKey, now, `${now}:${randomUUID()}`);
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const currentCount = results[1][1] as number;
// Apply throttling logic
if (currentCount <= AGGREGATION_THRESHOLD) {
return true;
}
// Check if we should log (every 10th or after 1 minute gap)
const recentEntries = await redis.zrange(rateLimitKey, -10, -1);
if (recentEntries.length === 0) return true;
const lastLogTime = parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
const timeSinceLastLog = now - lastLogTime;
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
// If Redis fails, do not log as Redis is required for audit logs
return false;
}
} else {
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
// If Redis not configured, do not log as Redis is required for audit logs
return false;
}
};
/**
* Logs a user sign out event for audit compliance.
*
* @param userId - The ID of the user signing out
* @param userEmail - The email of the user signing out
* @param context - Additional context about the sign out (reason, redirect URL, etc.)
*/
export const logSignOut = (
userId: string,
userEmail: string,
context?: {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
redirectUrl?: string;
organizationId?: string;
}
) => {
logAuthEvent("userSignedOut", "success", userId, userEmail, {
provider: "session",
authMethod: "sign_out",
reason: context?.reason || "user_initiated", // NOSONAR // We want to check for empty strings
redirectUrl: context?.redirectUrl,
organizationId: context?.organizationId,
});
};

View File

@@ -6,10 +6,12 @@ import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { actionClient } from "@/lib/utils/action-client";
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
import { z } from "zod";
@@ -32,88 +34,100 @@ const ZCreateUserAction = z.object({
),
});
export const createUserAction = actionClient.schema(ZCreateUserAction).action(async ({ parsedInput }) => {
if (IS_TURNSTILE_CONFIGURED) {
if (!parsedInput.turnstileToken || !TURNSTILE_SECRET_KEY) {
captureFailedSignup(parsedInput.email, parsedInput.name);
throw new UnknownError("Server configuration error");
}
export const createUserAction = actionClient.schema(ZCreateUserAction).action(
withAuditLogging(
"created",
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
if (IS_TURNSTILE_CONFIGURED) {
if (!parsedInput.turnstileToken || !TURNSTILE_SECRET_KEY) {
captureFailedSignup(parsedInput.email, parsedInput.name);
throw new UnknownError("Server configuration error");
}
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, parsedInput.turnstileToken);
if (!isHuman) {
captureFailedSignup(parsedInput.email, parsedInput.name);
throw new UnknownError("reCAPTCHA verification failed");
}
}
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, parsedInput.turnstileToken);
if (!isHuman) {
captureFailedSignup(parsedInput.email, parsedInput.name);
throw new UnknownError("reCAPTCHA verification failed");
}
}
const { inviteToken, emailVerificationDisabled } = parsedInput;
const hashedPassword = await hashPassword(parsedInput.password);
const user = await createUser({
email: parsedInput.email.toLowerCase(),
name: parsedInput.name,
password: hashedPassword,
locale: parsedInput.userLocale,
});
const { inviteToken, emailVerificationDisabled } = parsedInput;
const hashedPassword = await hashPassword(parsedInput.password);
const user = await createUser({
email: parsedInput.email.toLowerCase(),
name: parsedInput.name,
password: hashedPassword,
locale: parsedInput.userLocale,
});
// Handle invite flow
if (inviteToken) {
const inviteTokenData = verifyInviteToken(inviteToken);
const invite = await getInvite(inviteTokenData.inviteId);
if (!invite) {
throw new Error("Invalid invite ID");
}
// Handle invite flow
if (inviteToken) {
const inviteTokenData = verifyInviteToken(inviteToken);
const invite = await getInvite(inviteTokenData.inviteId);
if (!invite) {
throw new Error("Invalid invite ID");
}
await createMembership(invite.organizationId, user.id, {
accepted: true,
role: invite.role,
});
if (invite.teamIds) {
await createTeamMembership(
{
organizationId: invite.organizationId,
await createMembership(invite.organizationId, user.id, {
accepted: true,
role: invite.role,
teamIds: invite.teamIds,
},
user.id
);
});
if (invite.teamIds) {
await createTeamMembership(
{
organizationId: invite.organizationId,
role: invite.role,
teamIds: invite.teamIds,
},
user.id
);
}
await updateUser(user.id, {
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [invite.organizationId],
},
});
ctx.auditLoggingCtx.organizationId = invite.organizationId;
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
await deleteInvite(invite.id);
} else {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isMultiOrgEnabled) {
const organization = await createOrganization({ name: `${user.name}'s Organization` });
await createMembership(organization.id, user.id, {
role: "owner",
accepted: true,
});
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
alert: { ...user.notificationSettings?.alert },
weeklySummary: { ...user.notificationSettings?.weeklySummary },
unsubscribedOrganizationIds: Array.from(
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
),
},
});
ctx.auditLoggingCtx.organizationId = organization.id;
}
}
// Send verification email if enabled
if (!emailVerificationDisabled) {
await sendVerificationEmail(user);
}
ctx.auditLoggingCtx.userId = user.id;
ctx.auditLoggingCtx.newObject = user;
return user;
}
await updateUser(user.id, {
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [invite.organizationId],
},
});
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
await deleteInvite(invite.id);
} else {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isMultiOrgEnabled) {
const organization = await createOrganization({ name: `${user.name}'s Organization` });
await createMembership(organization.id, user.id, {
role: "owner",
accepted: true,
});
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
alert: { ...user.notificationSettings?.alert },
weeklySummary: { ...user.notificationSettings?.weeklySummary },
unsubscribedOrganizationIds: Array.from(
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
),
},
});
}
}
// Send verification email if enabled
if (!emailVerificationDisabled) {
await sendVerificationEmail(user);
}
return user;
});
)
);

Some files were not shown because too many files have changed in this diff Show More