fix: Add authorisation for API Key server actions (#741)

* 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

* fix: club caching methods and use authzn errors

* feat: add caching in canUserAccessApiKey
This commit is contained in:
Shubham Palriwala
2023-10-02 13:43:19 +05:30
committed by GitHub
parent 532d66bb34
commit c470c024c5
16 changed files with 153 additions and 86 deletions

View File

@@ -5,17 +5,18 @@ import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import FormbricksClient from "../../FormbricksClient";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export default async function EnvironmentLayout({ children, params }) {
const session = await getServerSession(authOptions);
if (!session) {
return redirect(`/auth/login`);
}
const hasAccess = await hasUserEnvironmentAccess(session.user, params.environmentId);
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new Error("User does not have access to this environment");
throw new AuthorizationError("Not authorized");
}
return (

View File

@@ -1,6 +1,6 @@
import EditApiKeys from "./EditApiKeys";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getApiKeys } from "@formbricks/lib/services/apiKey";
import { getApiKeys } from "@formbricks/lib/apiKey/service";
import { getEnvironments } from "@formbricks/lib/services/environment";
export default async function ApiKeyList({

View File

@@ -34,19 +34,29 @@ export default function EditAPIKeys({
};
const handleDeleteKey = async () => {
await deleteApiKeyAction(activeKey.id);
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
setOpenDeleteKeyModal(false);
toast.success("API Key deleted");
try {
await deleteApiKeyAction(activeKey.id);
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
toast.success("API Key deleted");
} catch (e) {
toast.error("Unable to delete API Key");
} finally {
setOpenDeleteKeyModal(false);
}
};
const handleAddAPIKey = async (data) => {
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
const updatedApiKeys = [...apiKeysLocal!, apiKey];
setApiKeysLocal(updatedApiKeys);
setOpenAddAPIKeyModal(false);
toast.success("API key created");
try {
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
const updatedApiKeys = [...apiKeysLocal!, apiKey];
setApiKeysLocal(updatedApiKeys);
toast.success("API key created");
} catch (e) {
toast.error("Unable to create API Key");
} finally {
setOpenAddAPIKeyModal(false);
}
};
return (

View File

@@ -1,11 +1,32 @@
"use server";
import { deleteApiKey, createApiKey } from "@formbricks/lib/services/apiKey";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { deleteApiKey, createApiKey } from "@formbricks/lib/apiKey/service";
import { canUserAccessApiKey } from "@formbricks/lib/apiKey/auth";
import { TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export async function deleteApiKeyAction(id: string) {
return await deleteApiKey(id);
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessApiKey(session.user.id, id);
if (isAuthorized) {
return await deleteApiKey(id);
} else {
throw new AuthorizationError("Not authorized");
}
}
export async function createApiKeyAction(environmentId: string, apiKeyData: TApiKeyCreateInput) {
return await createApiKey(environmentId, apiKeyData);
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (isAuthorized) {
return await createApiKey(environmentId, apiKeyData);
} else {
throw new AuthorizationError("Not authorized");
}
}

View File

@@ -3,12 +3,12 @@
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/services/product";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/services/membership";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
export const updateProductAction = async (
environmentId: string,
@@ -34,8 +34,8 @@ export const updateProductAction = async (
throw err;
}
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
throw new AuthenticationError("You don't have access to this environment");
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
throw new AuthorizationError("Not authorized");
}
const updatedProduct = await updateProduct(productId, data);
@@ -62,15 +62,15 @@ export const deleteProductAction = async (environmentId: string, userId: string,
throw err;
}
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
throw new AuthenticationError("You don't have access to this environment");
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
throw new AuthorizationError("Not authorized");
}
const team = await getTeamByEnvironmentId(environmentId);
const membership = team ? await getMembershipByUserIdTeamId(userId, team.id) : null;
if (membership?.role !== "admin" && membership?.role !== "owner") {
throw new AuthenticationError("You are not allowed to delete products.");
throw new AuthorizationError("You are not allowed to delete products.");
}
const availableProducts = team ? await getProducts(team.id) : null;

View File

@@ -1,4 +1,4 @@
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
@@ -19,11 +19,15 @@ export async function GET(req: NextRequest) {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user, environmentId);
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
}

View File

@@ -1,4 +1,4 @@
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { TAuthenticationApiKey } from "@formbricks/types/v1/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { responses } from "@/lib/api/response";

View File

@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
import { transformErrorToDetails } from "@/lib/api/validator";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/services/response";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/v1/responses";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getSurvey } from "@formbricks/lib/services/survey";
import { authenticateRequest } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
@@ -21,7 +21,7 @@ const canUserAccessResponse = async (authentication: any, response: TResponse):
if (!survey) return false;
if (authentication.type === "session") {
return await hasUserEnvironmentAccess(authentication.session.user, survey.environmentId);
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
} else if (authentication.type === "apiKey") {
return survey.environmentId === authentication.environmentId;
} else {

View File

@@ -1,5 +1,5 @@
import { responses } from "@/lib/api/response";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { deleteWebhook, getWebhook } from "@formbricks/lib/services/webhook";
import { headers } from "next/headers";

View File

@@ -1,7 +1,7 @@
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { DatabaseError, InvalidInputError } from "@formbricks/types/v1/errors";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { createWebhook, getWebhooks } from "@formbricks/lib/services/webhook";
import { ZWebhookInput } from "@formbricks/types/v1/webhooks";
import { headers } from "next/headers";

View File

@@ -1,5 +1,6 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { prisma } from "@formbricks/database";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
@@ -22,7 +23,7 @@ export const hasEnvironmentAccess = async (
if (!user) {
return false;
}
const ownership = await hasUserEnvironmentAccess(user, environmentId);
const ownership = await hasUserEnvironmentAccess(user.id, environmentId);
if (!ownership) {
return false;
}
@@ -30,34 +31,6 @@ export const hasEnvironmentAccess = async (
return true;
};
export const hasUserEnvironmentAccess = async (user, environmentId) => {
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
team: {
select: {
memberships: {
select: {
userId: true,
},
},
},
},
},
},
},
});
const environmentUsers = environment?.product.team.memberships.map((member) => member.userId) || [];
if (environmentUsers.includes(user.id)) {
return true;
}
return false;
};
export const getPlan = async (req, res) => {
if (req.headers["x-api-key"]) {
const apiKey = req.headers["x-api-key"].toString();

View File

@@ -0,0 +1,22 @@
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getApiKey } from "./service";
import { unstable_cache } from "next/cache";
export const canUserAccessApiKey = async (userId: string, apiKeyId: string): Promise<boolean> => {
return await unstable_cache(
async () => {
if (!userId) return false;
const apiKeyFromServer = await getApiKey(apiKeyId);
if (!apiKeyFromServer) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, apiKeyFromServer.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
},
[`users-${userId}-apiKeys-${apiKeyId}`],
{ revalidate: 30 * 60, tags: [`apiKeys-${apiKeyId}`] }
)(); // 30 minutes
};

View File

@@ -10,21 +10,21 @@ import { cache } from "react";
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/v1/environment";
export const getApiKey = async (apiKey: string): Promise<TApiKey | null> => {
validateInputs([apiKey, z.string()]);
if (!apiKey) {
export const getApiKey = async (apiKeyId: string): Promise<TApiKey | null> => {
validateInputs([apiKeyId, z.string()]);
if (!apiKeyId) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: getHash(apiKey),
id: apiKeyId,
},
});
if (!apiKeyData) {
throw new ResourceNotFoundError("API Key", apiKey);
throw new ResourceNotFoundError("API Key from ID", apiKeyId);
}
return apiKeyData;

View File

@@ -0,0 +1,34 @@
import { prisma } from "@formbricks/database";
import { unstable_cache } from "next/cache";
export const hasUserEnvironmentAccess = async (userId: string, environmentId: string) => {
return await unstable_cache(
async () => {
if (!userId) return false;
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
team: {
select: {
memberships: {
select: {
userId: true,
},
},
},
},
},
},
},
});
const environmentUsers = environment?.product.team.memberships.map((member) => member.userId) || [];
return environmentUsers.includes(userId);
},
[`users-${userId}-environments-${environmentId}`],
{ revalidate: 30 * 60, tags: [`environments-${environmentId}`] }
)(); // 30 minutes
};

View File

@@ -16,6 +16,7 @@
"@formbricks/types": "*",
"@paralleldrive/cuid2": "^2.2.2",
"date-fns": "^2.30.0",
"next-auth": "^4.22.3",
"jsonwebtoken": "^9.0.2",
"markdown-it": "^13.0.2",
"nodemailer": "^6.9.5",

43
pnpm-lock.yaml generated
View File

@@ -28,7 +28,7 @@ importers:
version: 3.12.7
turbo:
specifier: latest
version: 1.10.13
version: 1.10.3
apps/demo:
dependencies:
@@ -22049,64 +22049,65 @@ packages:
dependencies:
safe-buffer: 5.2.1
/turbo-darwin-64@1.10.13:
resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==}
/turbo-darwin-64@1.10.3:
resolution: {integrity: sha512-IIB9IomJGyD3EdpSscm7Ip1xVWtYb7D0x7oH3vad3gjFcjHJzDz9xZ/iw/qItFEW+wGFcLSRPd+1BNnuLM8AsA==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-darwin-arm64@1.10.13:
resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==}
/turbo-darwin-arm64@1.10.3:
resolution: {integrity: sha512-SBNmOZU9YEB0eyNIxeeQ+Wi0Ufd+nprEVp41rgUSRXEIpXjsDjyBnKnF+sQQj3+FLb4yyi/yZQckB+55qXWEsw==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-linux-64@1.10.13:
resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==}
/turbo-linux-64@1.10.3:
resolution: {integrity: sha512-kvAisGKE7xHJdyMxZLvg53zvHxjqPK1UVj4757PQqtx9dnjYHSc8epmivE6niPgDHon5YqImzArCjVZJYpIGHQ==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-linux-arm64@1.10.13:
resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==}
/turbo-linux-arm64@1.10.3:
resolution: {integrity: sha512-Qgaqln0IYRgyL0SowJOi+PNxejv1I2xhzXOI+D+z4YHbgSx87ox1IsALYBlK8VRVYY8VCXl+PN12r1ioV09j7A==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-windows-64@1.10.13:
resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==}
/turbo-windows-64@1.10.3:
resolution: {integrity: sha512-rbH9wManURNN8mBnN/ZdkpUuTvyVVEMiUwFUX4GVE5qmV15iHtZfDLUSGGCP2UFBazHcpNHG1OJzgc55GFFrUw==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo-windows-arm64@1.10.13:
resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==}
/turbo-windows-arm64@1.10.3:
resolution: {integrity: sha512-ThlkqxhcGZX39CaTjsHqJnqVe+WImjX13pmjnpChz6q5HHbeRxaJSFzgrHIOt0sUUVx90W/WrNRyoIt/aafniw==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo@1.10.13:
resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==}
/turbo@1.10.3:
resolution: {integrity: sha512-U4gKCWcKgLcCjQd4Pl8KJdfEKumpyWbzRu75A6FCj6Ctea1PIm58W6Ltw1QXKqHrl2pF9e1raAskf/h6dlrPCA==}
hasBin: true
requiresBuild: true
optionalDependencies:
turbo-darwin-64: 1.10.13
turbo-darwin-arm64: 1.10.13
turbo-linux-64: 1.10.13
turbo-linux-arm64: 1.10.13
turbo-windows-64: 1.10.13
turbo-windows-arm64: 1.10.13
turbo-darwin-64: 1.10.3
turbo-darwin-arm64: 1.10.3
turbo-linux-64: 1.10.3
turbo-linux-arm64: 1.10.3
turbo-windows-64: 1.10.3
turbo-windows-arm64: 1.10.3
dev: true
/tween-functions@1.2.0: