fix: add authorisation for tags actions (#897)

* poc: use server session and api key validation on deletion

* feat: use server session and api key validation on deletion and creation

* feat: packages/lib/apiKey for apiKey services and auth

* shubham/auth-for-api-key

* fix: caching

* feat: handle authorization for tag creation, updation & deletion

* fix: use cached wrapper

* fix: club caching methods and use authzn errors

* feat: add caching in canUserAccessApiKey

* fix: suggrsted changes and authzn for response as well

* fix: work on suggested changes

* fix broken lock file

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Shubham Palriwala
2023-10-02 19:30:25 +05:30
committed by GitHub
parent a9c8e99cd4
commit 8c0aba82e5
23 changed files with 190 additions and 52 deletions

View File

@@ -4,7 +4,7 @@ import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { capitalizeFirstLetter } from "@/lib/utils";
import { getPerson } from "@formbricks/lib/services/person";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSessionCount } from "@formbricks/lib/services/session";
export default async function AttributesSection({ personId }: { personId: string }) {

View File

@@ -1,5 +1,5 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";

View File

@@ -1,15 +1,38 @@
"use server";
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/services/tag";
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/tag/service";
import { canUserAccessTag } from "@formbricks/lib/tag/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export const deleteTagAction = async (tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTag(session.user.id, tagId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteTag(tagId);
};
export const updateTagNameAction = async (tagId: string, name: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTag(session.user.id, tagId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await updateTagName(tagId, name);
};
export const mergeTagsAction = async (originalTagId: string, newTagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorizedForOld = await canUserAccessTag(session.user.id, originalTagId);
const isAuthorizedForNew = await canUserAccessTag(session.user.id, newTagId);
if (!isAuthorizedForOld || !isAuthorizedForNew) throw new AuthorizationError("Not authorized");
return await mergeTags(originalTagId, newTagId);
};

View File

@@ -1,7 +1,7 @@
import EditTagsWrapper from "./EditTagsWrapper";
import SettingsTitle from "../SettingsTitle";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTagsOnResponsesCount } from "@formbricks/lib/services/tagOnResponse";
export default async function MembersSettingsPage({ params }) {

View File

@@ -1,6 +1,6 @@
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurveyResponses } from "@formbricks/lib/response/service";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";

View File

@@ -1,9 +1,15 @@
"use server";
import { deleteResponse } from "@formbricks/lib/services/response";
import { deleteResponse } from "@formbricks/lib/response/service";
import { updateResponseNote, resolveResponseNote } from "@formbricks/lib/services/responseNote";
import { createTag } from "@formbricks/lib/services/tag";
import { addTagToRespone, deleteTagFromResponse } from "@formbricks/lib/services/tagOnResponse";
import { createTag } from "@formbricks/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/services/tagOnResponse";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
import { canUserAccessResponse } from "@formbricks/lib/response/auth";
import { canUserAccessTagOnResponse } from "@formbricks/lib/tagOnResponse/auth";
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
await updateResponseNote(responseNoteId, text);
@@ -14,17 +20,41 @@ export const resolveResponseNoteAction = async (responseNoteId: string) => {
};
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");
return await deleteResponse(responseId);
};
export const createTagAction = async (environmentId: string, tagName: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createTag(environmentId, tagName);
};
export const addTagToResponeAction = async (responseId: string, tagId: string) => {
export const createTagToResponeAction = async (responseId: string, tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await addTagToRespone(responseId, tagId);
};
export const removeTagFromResponseAction = async (responseId: string, tagId: string) => {
return await deleteTagFromResponse(responseId, tagId);
export const deleteTagOnResponseAction = async (responseId: string, tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteTagOnResponse(responseId, tagId);
};

View File

@@ -9,9 +9,9 @@ import { useRouter } from "next/navigation";
import { Button } from "@formbricks/ui";
import { TTag } from "@formbricks/types/v1/tags";
import {
addTagToResponeAction,
createTagToResponeAction,
createTagAction,
removeTagFromResponseAction,
deleteTagOnResponseAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
interface ResponseTagsWrapperProps {
@@ -38,7 +38,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
const onDelete = async (tagId: string) => {
try {
await removeTagFromResponseAction(responseId, tagId);
await deleteTagOnResponseAction(responseId, tagId);
router.refresh();
} catch (e) {
@@ -89,7 +89,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
tagName: tag.name,
},
]);
addTagToResponeAction(responseId, tag.id).then(() => {
createTagToResponeAction(responseId, tag.id).then(() => {
setSearchValue("");
setOpen(false);
router.refresh();
@@ -121,7 +121,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
},
]);
addTagToResponeAction(responseId, tagId).then(() => {
createTagToResponeAction(responseId, tagId).then(() => {
setSearchValue("");
setOpen(false);
router.refresh();

View File

@@ -8,7 +8,7 @@ import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constant
import ResponsesLimitReachedBanner from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponsesLimitReachedBanner";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
export default async function Page({ params }) {
const session = await getServerSession(authOptions);

View File

@@ -7,7 +7,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getServerSession } from "next-auth";
export default async function Page({ params }) {

View File

@@ -2,7 +2,7 @@ import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { updateResponse } from "@formbricks/lib/services/response";
import { updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/services/survey";
import { ZResponseUpdateInput } from "@formbricks/types/v1/responses";
import { NextResponse } from "next/server";

View File

@@ -3,7 +3,7 @@ import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/v1/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createResponse } from "@formbricks/lib/services/response";
import { createResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";

View File

@@ -1,7 +1,7 @@
import { responses } from "@/lib/api/response";
import { NextResponse } from "next/server";
import { transformErrorToDetails } from "@/lib/api/validator";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/services/response";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/v1/responses";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getSurvey } from "@formbricks/lib/services/survey";

View File

@@ -1,5 +1,5 @@
import { responses } from "@/lib/api/response";
import { getEnvironmentResponses } from "@formbricks/lib/services/response";
import { getEnvironmentResponses } from "@formbricks/lib/response/service";
import { authenticateRequest } from "@/app/api/v1/auth";
import { DatabaseError } from "@formbricks/types/v1/errors";

View File

@@ -1,3 +1,5 @@
import { ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getApiKey } from "./service";
import { unstable_cache } from "next/cache";
@@ -5,7 +7,7 @@ import { unstable_cache } from "next/cache";
export const canUserAccessApiKey = async (userId: string, apiKeyId: string): Promise<boolean> =>
await unstable_cache(
async () => {
if (!userId) return false;
validateInputs([userId, ZId], [apiKeyId, ZId]);
const apiKeyFromServer = await getApiKey(apiKeyId);
if (!apiKeyFromServer) return false;

View File

@@ -1,10 +1,12 @@
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/v1/environment";
import { unstable_cache } from "next/cache";
import { validateInputs } from "../utils/validate";
export const hasUserEnvironmentAccess = async (userId: string, environmentId: string) => {
return await unstable_cache(
async () => {
if (!userId) return false;
validateInputs([userId, ZId], [environmentId, ZId]);
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,

View File

@@ -0,0 +1,28 @@
import { ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getResponse, getResponseCacheTag } from "./service";
import { unstable_cache } from "next/cache";
import { getSurvey } from "../services/survey";
export const canUserAccessResponse = async (userId: string, responseId: string): Promise<boolean> =>
await unstable_cache(
async () => {
validateInputs([userId, ZId], [responseId, ZId]);
if (!userId) return false;
const response = await getResponse(responseId);
if (!response) return false;
const survey = await getSurvey(response.surveyId);
if (!survey) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
},
[`users-${userId}-responses-${responseId}`],
{ revalidate: 30 * 60, tags: [getResponseCacheTag(responseId)] }
)(); // 30 minutes

View File

@@ -12,7 +12,7 @@ import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import { cache } from "react";
import "server-only";
import { getPerson, transformPrismaPerson } from "./person";
import { getPerson, transformPrismaPerson } from "../services/person";
import { captureTelemetry } from "../telemetry";
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/v1/environment";
@@ -78,6 +78,8 @@ const responseSelection = {
export const getResponsesCacheTag = (surveyId: string) => `surveys-${surveyId}-responses`;
export const getResponseCacheTag = (responseId: string) => `responses-${responseId}`;
export const getResponsesByPersonId = async (personId: string): Promise<Array<TResponse> | null> => {
validateInputs([personId, ZId]);
try {

View File

@@ -15,7 +15,7 @@ import { z } from "zod";
import { captureTelemetry } from "../telemetry";
import { validateInputs } from "../utils/validate";
import { getDisplaysCacheTag } from "./displays";
import { getResponsesCacheTag } from "./response";
import { getResponsesCacheTag } from "../response/service";
// surveys cache key and tags
const getSurveysCacheKey = (environmentId: string): string => `environments-${environmentId}-surveys`;

View File

@@ -2,6 +2,9 @@ import { prisma } from "@formbricks/database";
import { TTagsCount } from "@formbricks/types/v1/tags";
import { cache } from "react";
export const getTagOnResponseCacheTag = (tagId: string, responseId: string) =>
`tagsOnResponse-${tagId}-${responseId}`;
export const addTagToRespone = async (responseId: string, tagId: string) => {
try {
const tagOnResponse = await prisma.tagsOnResponses.create({
@@ -16,7 +19,7 @@ export const addTagToRespone = async (responseId: string, tagId: string) => {
}
};
export const deleteTagFromResponse = async (responseId: string, tagId: string) => {
export const deleteTagOnResponse = async (responseId: string, tagId: string) => {
try {
const deletedTag = await prisma.tagsOnResponses.delete({
where: {

24
packages/lib/tag/auth.ts Normal file
View File

@@ -0,0 +1,24 @@
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getTag } from "./service";
import { unstable_cache } from "next/cache";
import { ZId } from "@formbricks/types/v1/environment";
export const canUserAccessTag = async (userId: string, tagId: string): Promise<boolean> =>
await unstable_cache(
async () => {
validateInputs([userId, ZId], [tagId, ZId]);
const tag = await getTag(tagId);
if (!tag) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, tag.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
},
[`${userId}-${tagId}`],
{
revalidate: 30 * 60, // 30 minutes
}
)();

View File

@@ -16,6 +16,20 @@ export const getTagsByEnvironmentId = cache(async (environmentId: string): Promi
}
});
export const getTag = async (tagId: string): Promise<TTag | null> => {
try {
const tag = await prisma.tag.findUnique({
where: {
id: tagId,
},
});
return tag;
} catch (error) {
throw error;
}
};
export const createTag = async (environmentId: string, name: string): Promise<TTag> => {
try {
const tag = await prisma.tag.create({

View File

@@ -0,0 +1,24 @@
import { validateInputs } from "../utils/validate";
import { unstable_cache } from "next/cache";
import { ZId } from "@formbricks/types/v1/environment";
import { canUserAccessResponse } from "../response/auth";
import { canUserAccessTag } from "../tag/auth";
import { getTagOnResponseCacheTag } from "../services/tagOnResponse";
export const canUserAccessTagOnResponse = async (
userId: string,
tagId: string,
responseId: string
): Promise<boolean> =>
await unstable_cache(
async () => {
validateInputs([userId, ZId], [tagId, ZId], [responseId, ZId]);
const isAuthorizedForTag = await canUserAccessTag(userId, tagId);
const isAuthorizedForResponse = await canUserAccessResponse(userId, responseId);
return isAuthorizedForTag && isAuthorizedForResponse;
},
[`users-${userId}-tagOnResponse-${tagId}-${responseId}`],
{ revalidate: 30 * 60, tags: [getTagOnResponseCacheTag(tagId, responseId)] }
)();

36
pnpm-lock.yaml generated
View File

@@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
@@ -434,7 +438,7 @@ importers:
version: 9.0.0(eslint@8.50.0)
eslint-config-turbo:
specifier: latest
version: 1.10.14(eslint@8.50.0)
version: 1.8.8(eslint@8.50.0)
eslint-plugin-react:
specifier: 7.33.2
version: 7.33.2(eslint@8.50.0)
@@ -10673,13 +10677,13 @@ packages:
resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==}
dev: true
/eslint-config-turbo@1.10.14(eslint@8.50.0):
resolution: {integrity: sha512-ZeB+IcuFXy1OICkLuAplVa0euoYbhK+bMEQd0nH9+Lns18lgZRm33mVz/iSoH9VdUzl/1ZmFmoK+RpZc+8R80A==}
/eslint-config-turbo@1.8.8(eslint@8.50.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.50.0
eslint-plugin-turbo: 1.10.14(eslint@8.50.0)
eslint-plugin-turbo: 1.8.8(eslint@8.50.0)
dev: true
/eslint-import-resolver-node@0.3.9:
@@ -10885,12 +10889,11 @@ packages:
semver: 6.3.1
string.prototype.matchall: 4.0.8
/eslint-plugin-turbo@1.10.14(eslint@8.50.0):
resolution: {integrity: sha512-sBdBDnYr9AjT1g4lR3PBkZDonTrMnR4TvuGv5W0OiF7z9az1rI68yj2UHJZvjkwwcGu5mazWA1AfB0oaagpmfg==}
/eslint-plugin-turbo@1.8.8(eslint@8.50.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
dotenv: 16.0.3
eslint: 8.50.0
dev: true
@@ -22036,70 +22039,58 @@ packages:
dependencies:
safe-buffer: 5.2.1
/turbo-darwin-64@1.10.13:
resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-darwin-arm64@1.10.13:
resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-linux-64@1.10.13:
resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-linux-arm64@1.10.13:
resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-windows-64@1.10.13:
resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo-windows-arm64@1.10.13:
resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo@1.10.13:
resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==}
hasBin: true
requiresBuild: true
optionalDependencies:
turbo-darwin-64: 1.10.13
turbo-darwin-arm64: 1.10.13
@@ -22107,7 +22098,6 @@ packages:
turbo-linux-arm64: 1.10.13
turbo-windows-64: 1.10.13
turbo-windows-arm64: 1.10.13
dev: true
/tween-functions@1.2.0:
@@ -23974,7 +23964,3 @@ packages:
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false