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
@@ -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,
});
};
@@ -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;
@@ -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", () => {
+30
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");
};
+23 -25
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");
};
@@ -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",
});
@@ -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",
});
@@ -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",
});
@@ -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",
});
@@ -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",
});
@@ -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",
});
@@ -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",
});
}
@@ -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",
});
@@ -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",
});
@@ -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",
});