feat: Improve auth & permission system (#2845)

This commit is contained in:
Piyush Gupta
2024-07-09 13:38:20 +05:30
committed by GitHub
parent 6d0bd4a6ed
commit 54accbbeff
26 changed files with 888 additions and 423 deletions

View File

@@ -0,0 +1,17 @@
export const getFormattedErrorMessage = (result) => {
let message = "";
if (result.serverError) {
message = result.serverError;
} else {
const errors = result.validationErrors;
message = Object.keys(errors || {})
.map((key) => {
if (key === "_errors") return errors[key].join(", ");
return `${key ? `${key}` : ""}${errors?.[key]?._errors.join(", ")}`;
})
.join("\n");
}
return message;
};

View File

@@ -0,0 +1,24 @@
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { authOptions } from "../authOptions";
export const actionClient = createSafeActionClient({
handleReturnedServerError(e) {
if (e instanceof ResourceNotFoundError) {
return e.message;
} else if (e instanceof AuthorizationError) {
return e.message;
}
return DEFAULT_SERVER_ERROR_MESSAGE;
},
});
export const authenticatedActionClient = actionClient.use(async ({ next }) => {
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new AuthenticationError("Not authenticated");
}
return next({ ctx: { user: session.user } });
});

View File

@@ -0,0 +1,110 @@
import { ZProductUpdateInput } from "@formbricks/types/product";
export const Permissions = {
owner: {
product: {
create: true,
read: true,
update: true,
delete: true,
},
organization: {
update: true,
},
person: {
delete: true,
},
response: {
delete: true,
},
survey: {
create: true,
},
},
admin: {
product: {
create: true,
read: true,
update: true,
delete: true,
},
organization: {
update: true,
},
person: {
delete: true,
},
response: {
delete: true,
},
survey: {
create: true,
},
},
editor: {
product: {
create: true,
read: true,
update: true,
delete: true,
},
organization: {
update: false,
},
person: {
delete: true,
},
response: {
delete: true,
},
survey: {
create: true,
},
},
developer: {
product: {
create: true,
read: true,
update: ZProductUpdateInput.omit({
name: true,
}),
delete: true,
},
organization: {
update: false,
},
person: {
delete: true,
},
response: {
delete: true,
},
survey: {
create: true,
},
},
viewer: {
product: {
create: false,
read: true,
update: false,
delete: false,
},
organization: {
update: false,
},
person: {
delete: false,
},
response: {
delete: false,
},
survey: {
create: false,
},
},
};

View File

@@ -0,0 +1,64 @@
import { returnValidationErrors } from "next-safe-action";
import { ZodIssue, z } from "zod";
import { TOperation, TResource } from "@formbricks/types/actionClient";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembershipRole } from "@formbricks/types/memberships";
import { getMembershipRole } from "../membership/hooks/actions";
import { Permissions } from "./permissions";
export const getOperationPermissions = (role: TMembershipRole, entity: TResource, operation: TOperation) => {
const permission = Permissions[role][entity][operation];
if (typeof permission === "boolean" && !permission) {
throw new AuthorizationError("Not authorized");
}
return permission;
};
export const getRoleBasedSchema = <T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
role: TMembershipRole,
entity: TResource,
operation: TOperation
): z.ZodObject<T> => {
const data = getOperationPermissions(role, entity, operation);
return typeof data === "boolean" && data === true ? schema.strict() : data;
};
export const formatErrors = (errors: ZodIssue[]) => {
return {
...errors.reduce((acc, error) => {
acc[error.path.join(".")] = {
_errors: [error.message],
};
return acc;
}, {}),
};
};
export const checkAuthorization = async <T extends z.ZodRawShape>({
schema,
data,
userId,
organizationId,
rules,
}: {
schema?: z.ZodObject<T>;
data?: z.ZodObject<T>["_output"];
userId: string;
organizationId: string;
rules: [TResource, TOperation];
}) => {
const role = await getMembershipRole(userId, organizationId);
if (schema) {
const resultSchema = getRoleBasedSchema(schema, role, ...rules);
const parsedResult = resultSchema.safeParse(data);
if (!parsedResult.success) {
return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues));
}
} else {
getOperationPermissions(role, ...rules);
}
};

View File

@@ -2,7 +2,7 @@
import "server-only";
import { getServerSession } from "next-auth";
import { AuthenticationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user";
import { authOptions } from "../../authOptions";
import { getOrganizationByEnvironmentId } from "../../organization/service";
@@ -21,11 +21,16 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
throw new Error("Organization not found");
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(user.id, organization.id);
const currentUserMembership = await getMembershipRole(user.id, organization.id);
if (!currentUserMembership) {
throw new Error("Membership not found");
return currentUserMembership;
};
export const getMembershipRole = async (userId: string, organizationId: string) => {
const membership = await getMembershipByUserIdOrganizationId(userId, organizationId);
if (!membership) {
throw new AuthorizationError("Not authorized");
}
return currentUserMembership?.role;
return membership.role;
};

View File

@@ -0,0 +1,59 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "../environment/service";
import { getPerson } from "../person/service";
import { getProduct } from "../product/service";
import { getResponse } from "../response/service";
import { getSurvey } from "../survey/service";
/**
* GET organization ID from RESOURCE ID
*/
export const getOrganizationIdFromProductId = async (productId: string) => {
const product = await getProduct(productId);
if (!product) {
throw new ResourceNotFoundError("product", productId);
}
return product.organizationId;
};
export const getOrganizationIdFromEnvironmentId = async (environmentId: string) => {
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new ResourceNotFoundError("environment", environmentId);
}
const organizationId = await getOrganizationIdFromProductId(environment.productId);
return organizationId;
};
export const getOrganizationIdFromSurveyId = async (surveyId: string) => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("survey", surveyId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
return organizationId;
};
export const getOrganizationIdFromResponseId = async (responseId: string) => {
const response = await getResponse(responseId);
if (!response) {
throw new ResourceNotFoundError("response", responseId);
}
const organizationId = await getOrganizationIdFromSurveyId(response.surveyId);
return organizationId;
};
export const getOrganizationIdFromPersonId = async (personId: string) => {
const person = await getPerson(personId);
if (!person) {
throw new ResourceNotFoundError("person", personId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(person.environmentId);
return organizationId;
};

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
export const ZResource = z.enum([
"product",
"organization",
"environment",
"membership",
"invite",
"response",
"survey",
"person",
]);
export type TResource = z.infer<typeof ZResource>;
export const ZOperation = z.enum(["create", "read", "update", "delete"]);
export type TOperation = z.infer<typeof ZOperation>;

View File

@@ -1,8 +1,12 @@
"use server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getOrganizationIdFromResponseId } from "@formbricks/lib/organization/utils";
import { canUserAccessResponse } from "@formbricks/lib/response/auth";
import { deleteResponse, getResponse } from "@formbricks/lib/response/service";
import { canUserModifyResponseNote, canUserResolveResponseNote } from "@formbricks/lib/responseNote/auth";
@@ -58,14 +62,21 @@ export const deleteTagOnResponseAction = async (responseId: string, tagId: strin
return await deleteTagOnResponse(responseId, tagId);
};
export const deleteResponseAction = async (responseId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessResponse(session.user!.id, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const ZDeleteResponseAction = z.object({
responseId: z.string(),
});
return await deleteResponse(responseId);
};
export const deleteResponseAction = authenticatedActionClient
.schema(ZDeleteResponseAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
rules: ["response", "delete"],
});
return await deleteResponse(parsedInput.responseId);
});
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
const session = await getServerSession(authOptions);

View File

@@ -90,7 +90,7 @@ export const SingleResponseCard = ({
if (isViewer) {
throw new Error("You are not authorized to perform this action.");
}
await deleteResponseAction(response.id);
await deleteResponseAction({ responseId: response.id });
deleteResponse?.(response.id);
router.refresh();

View File

@@ -2,9 +2,13 @@
import { Prisma as prismaClient } from "@prisma/client";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { segmentCache } from "@formbricks/lib/segment/cache";
import { createSegment } from "@formbricks/lib/segment/service";
@@ -36,243 +40,241 @@ export const duplicateSurveyAction = async (environmentId: string, surveyId: str
return duplicatedSurvey;
};
export const copyToOtherEnvironmentAction = async (
environmentId: string,
surveyId: string,
targetEnvironmentId: string
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const ZCopyToOtherEnvironmentAction = z.object({
environmentId: z.string(),
surveyId: z.string(),
targetEnvironmentId: z.string(),
});
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
session.user.id,
environmentId
);
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
export const copyToOtherEnvironmentAction = authenticatedActionClient
.schema(ZCopyToOtherEnvironmentAction)
.action(async ({ ctx, parsedInput }) => {
// check if user has access to source and target environments
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
rules: ["survey", "create"],
});
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.targetEnvironmentId),
rules: ["survey", "create"],
});
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
session.user.id,
targetEnvironmentId
);
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const existingSurvey = await prisma.survey.findFirst({
where: {
id: surveyId,
environmentId,
},
include: {
triggers: {
include: {
actionClass: true,
},
const existingSurvey = await prisma.survey.findFirst({
where: {
id: parsedInput.surveyId,
environmentId: parsedInput.environmentId,
},
attributeFilters: {
include: {
attributeClass: true,
include: {
triggers: {
include: {
actionClass: true,
},
},
},
languages: {
select: {
default: true,
enabled: true,
language: {
select: {
id: true,
attributeFilters: {
include: {
attributeClass: true,
},
},
languages: {
select: {
default: true,
enabled: true,
language: {
select: {
id: true,
},
},
},
},
},
segment: true,
},
});
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
...(trigger.actionClass.type === "code"
? { key: trigger.actionClass.key }
: { name: trigger.actionClass.name }),
environment: {
id: targetEnvironmentId,
},
segment: true,
},
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.actionClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.actionClass.description,
type: trigger.actionClass.type,
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
...(trigger.actionClass.type === "code"
? { key: trigger.actionClass.key }
: {
noCodeConfig: trigger.actionClass.noCodeConfig
? structuredClone(trigger.actionClass.noCodeConfig)
: undefined,
}),
: { name: trigger.actionClass.name }),
environment: {
id: parsedInput.targetEnvironmentId,
},
},
});
targetEnvironmentTriggers.push(newTrigger.id);
} else {
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.actionClass.name,
environment: {
connect: {
id: parsedInput.targetEnvironmentId,
},
},
description: trigger.actionClass.description,
type: trigger.actionClass.type,
...(trigger.actionClass.type === "code"
? { key: trigger.actionClass.key }
: {
noCodeConfig: trigger.actionClass.noCodeConfig
? structuredClone(trigger.actionClass.noCodeConfig)
: undefined,
}),
},
});
targetEnvironmentTriggers.push(newTrigger.id);
} else {
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
}
}
}
let targetEnvironmentAttributeFilters: string[] = [];
// map the local attributeFilters to the target env
for (const attributeFilter of existingSurvey.attributeFilters) {
// check if attributeClass exists in target env.
// if not, create it
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
where: {
name: attributeFilter.attributeClass.name,
environment: {
id: targetEnvironmentId,
let targetEnvironmentAttributeFilters: string[] = [];
// map the local attributeFilters to the target env
for (const attributeFilter of existingSurvey.attributeFilters) {
// check if attributeClass exists in target env.
// if not, create it
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
where: {
name: attributeFilter.attributeClass.name,
environment: {
id: parsedInput.targetEnvironmentId,
},
},
});
if (!targetEnvironmentAttributeClass) {
const newAttributeClass = await prisma.attributeClass.create({
data: {
name: attributeFilter.attributeClass.name,
description: attributeFilter.attributeClass.description,
type: attributeFilter.attributeClass.type,
environment: {
connect: {
id: parsedInput.targetEnvironmentId,
},
},
},
});
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
} else {
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
}
}
const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id;
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
createdBy: undefined,
segmentId: undefined,
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: structuredClone(existingSurvey.questions),
thankYouCard: structuredClone(existingSurvey.thankYouCard),
languages: {
create: existingSurvey.languages?.map((surveyLanguage) => ({
languageId: surveyLanguage.language.id,
default: surveyLanguage.language.id === defaultLanguageId,
})),
},
triggers: {
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
attributeClassId: targetEnvironmentAttributeFilters[idx],
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: parsedInput.targetEnvironmentId,
},
},
creator: {
connect: {
id: ctx.user.id,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
styling: existingSurvey.styling ?? prismaClient.JsonNull,
segment: undefined,
},
});
if (!targetEnvironmentAttributeClass) {
const newAttributeClass = await prisma.attributeClass.create({
data: {
name: attributeFilter.attributeClass.name,
description: attributeFilter.attributeClass.description,
type: attributeFilter.attributeClass.type,
environment: {
connect: {
id: targetEnvironmentId,
// if the existing survey has an inline segment, we copy the filters and create a new inline segment and connect it to the new survey
if (existingSurvey.segment) {
if (existingSurvey.segment.isPrivate) {
const newInlineSegment = await createSegment({
environmentId: parsedInput.environmentId,
title: `${newSurvey.id}`,
isPrivate: true,
surveyId: newSurvey.id,
filters: existingSurvey.segment.filters,
});
await prisma.survey.update({
where: {
id: newSurvey.id,
},
data: {
segment: {
connect: {
id: newInlineSegment.id,
},
},
},
},
});
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
} else {
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
});
segmentCache.revalidate({
id: newInlineSegment.id,
environmentId: newSurvey.environmentId,
});
} else {
await prisma.survey.update({
where: {
id: newSurvey.id,
},
data: {
segment: {
connect: {
id: existingSurvey.segment.id,
},
},
},
});
segmentCache.revalidate({
id: existingSurvey.segment.id,
environmentId: newSurvey.environmentId,
});
}
}
}
const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id;
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
createdBy: undefined,
segmentId: undefined,
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: structuredClone(existingSurvey.questions),
thankYouCard: structuredClone(existingSurvey.thankYouCard),
languages: {
create: existingSurvey.languages?.map((surveyLanguage) => ({
languageId: surveyLanguage.language.id,
default: surveyLanguage.language.id === defaultLanguageId,
})),
},
triggers: {
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
attributeClassId: targetEnvironmentAttributeFilters[idx],
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: targetEnvironmentId,
},
},
creator: {
connect: {
id: session.user.id,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
styling: existingSurvey.styling ?? prismaClient.JsonNull,
segment: undefined,
},
surveyCache.revalidate({
id: newSurvey.id,
environmentId: parsedInput.targetEnvironmentId,
});
return newSurvey;
});
// if the existing survey has an inline segment, we copy the filters and create a new inline segment and connect it to the new survey
if (existingSurvey.segment) {
if (existingSurvey.segment.isPrivate) {
const newInlineSegment = await createSegment({
environmentId,
title: `${newSurvey.id}`,
isPrivate: true,
surveyId: newSurvey.id,
filters: existingSurvey.segment.filters,
});
await prisma.survey.update({
where: {
id: newSurvey.id,
},
data: {
segment: {
connect: {
id: newInlineSegment.id,
},
},
},
});
segmentCache.revalidate({
id: newInlineSegment.id,
environmentId: newSurvey.environmentId,
});
} else {
await prisma.survey.update({
where: {
id: newSurvey.id,
},
data: {
segment: {
connect: {
id: existingSurvey.segment.id,
},
},
},
});
segmentCache.revalidate({
id: existingSurvey.segment.id,
environmentId: newSurvey.environmentId,
});
}
}
surveyCache.revalidate({
id: newSurvey.id,
environmentId: targetEnvironmentId,
});
return newSurvey;
};
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

View File

@@ -85,7 +85,12 @@ export const SurveyDropDownMenu = ({
const copyToOtherEnvironment = async (surveyId: string) => {
setLoading(true);
try {
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
// await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
await copyToOtherEnvironmentAction({
environmentId,
surveyId,
targetEnvironmentId: otherEnvironment.id,
});
if (otherEnvironment.type === "production") {
toast.success("Survey copied to production env.");
} else if (otherEnvironment.type === "development") {