feat: watchlist (#17947)

* lock users on signup if their email is in blacklist

* add to turbo env list

* prevent locked user or blocked email domain from using API

* Refactored to only run 1 query to find the user

* Fixing tests

* WIP

* WIP

* WIP

* Discard changes to turbo.json

* Fixed tests

* Update isLockedOrBlocked.test.ts

* Update isAdmin.integration-test.ts

* Update tsconfig.json

* chore: rename to watchlist

Signed-off-by: Omar López <zomars@me.com>

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
Omar López
2024-12-03 18:43:01 +01:00
committed by GitHub
co-authored by sean-brydon Keith Williams sean-brydon
parent 59993891b8
commit 1d8326ed83
16 changed files with 382 additions and 34 deletions
+22 -9
View File
@@ -6,6 +6,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
@@ -24,23 +25,35 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
if (!hasValidLicense && IS_PRODUCTION) {
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
}
// Check if the apiKey query param is provided.
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
// remove the prefix from the user provided api_key. If no env set default to "cal_"
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
// Hash the key again before matching against the database records.
const hashedKey = hashAPIKey(strippedApiKey);
// Check if the hashed api key exists in database.
const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } });
// If cannot find any api key. Throw a 401 Unauthorized.
if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" });
const apiKey = await prisma.apiKey.findUnique({
where: { hashedKey },
include: {
user: {
select: { role: true, locked: true, email: true },
},
},
});
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
return res.status(401).json({ error: "This apiKey is expired" });
return res.status(401).json({ error: "This API key is expired." });
}
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });
if (!apiKey.userId || !apiKey.user)
return res.status(404).json({ error: "No user found for this API key." });
// save the user id in the request for later use
req.userId = apiKey.userId;
req.user = apiKey.user;
const { isAdmin, scope } = await isAdminGuard(req);
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
if (userIsLockedOrBlocked)
return res.status(403).json({ error: "You are not authorized to perform this request." });
req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide;
req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin;
+3 -4
View File
@@ -6,8 +6,7 @@ import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
import { ScopeOfAdmin } from "./scopeOfAdmin";
export const isAdminGuard = async (req: NextApiRequest) => {
const { userId } = req;
const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } });
const { user, userId } = req;
if (!user) return { isAdmin: false, scope: null };
const { role: userRole } = user;
@@ -34,7 +33,7 @@ export const isAdminGuard = async (req: NextApiRequest) => {
},
},
});
if (!orgOwnerOrAdminMemberships.length) return { isAdmin: false, scope: null };
if (orgOwnerOrAdminMemberships.length > 0) return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
return { isAdmin: false, scope: null };
};
@@ -0,0 +1,9 @@
import type { NextApiRequest } from "next";
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
export async function isLockedOrBlocked(req: NextApiRequest) {
const user = req.user;
if (!user?.email) return false;
return user.locked || (await checkIfEmailInWatchlistController(user.email));
}
+1
View File
@@ -8,6 +8,7 @@ export declare module "next" {
session?: Session | null;
userId: number;
user?: { role: string; locked: boolean; email: string } | null;
method: string;
// session: { user: { id: number } };
// query: Partial<{ [key: string]: string | string[] }>;
@@ -1,17 +1,17 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
import { ScopeOfAdmin } from "../../../lib/utils/scopeOfAdmin";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
@@ -20,11 +20,6 @@ afterEach(() => {
vi.resetAllMocks();
});
vi.mock("@calcom/prisma");
vi.mock("~/lib/utils/isAdmin", () => ({
isAdminGuard: vi.fn(),
}));
describe("Verify API key", () => {
let service: LicenseKeyService;
@@ -45,7 +40,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(false);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -69,7 +63,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -91,10 +84,18 @@ describe("Verify API key", () => {
},
prisma,
});
prismaMock.apiKey.findUnique.mockResolvedValue({
id: 1,
userId: 2,
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
locked: false,
},
},
},
});
const middleware = {
@@ -102,7 +103,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -125,10 +125,36 @@ describe("Verify API key", () => {
},
prisma,
});
prismaMock.apiKey.findUnique.mockResolvedValue({
id: 1,
userId: 2,
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "org-admin@acme.com",
role: UserPermissionRole.USER,
locked: false,
teams: {
create: {
accepted: true,
role: MembershipRole.OWNER,
team: {
create: {
name: "ACME",
isOrganization: true,
organizationSettings: {
create: {
isAdminAPIEnabled: true,
orgAutoAcceptEmail: "acme.com",
},
},
},
},
},
},
},
},
},
});
const middleware = {
@@ -136,7 +162,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -149,4 +174,44 @@ describe("Verify API key", () => {
expect(req.isSystemWideAdmin).toBe(false);
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
});
it("should return 403 if user is locked or blocked", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "locked@example.com",
role: UserPermissionRole.USER,
locked: true,
},
},
},
});
const middleware = {
fn: verifyApiKey,
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
const serverNext = vi.fn((next: void) => Promise.resolve(next));
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(403);
expect(JSON.parse(res._getData())).toEqual({ error: "You are not authorized to perform this request." });
expect(serverNext).not.toHaveBeenCalled();
});
});
@@ -19,6 +19,7 @@ describe("isAdmin guard", () => {
});
req.userId = 0;
req.user = undefined;
const { isAdmin, scope } = await isAdminGuard(req);
@@ -35,6 +36,7 @@ describe("isAdmin guard", () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
req.userId = memberUser.id;
req.user = memberUser;
const { isAdmin, scope } = await isAdminGuard(req);
@@ -51,6 +53,7 @@ describe("isAdmin guard", () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
req.userId = adminUser.id;
req.user = adminUser;
const { isAdmin, scope } = await isAdminGuard(req);
@@ -67,6 +70,8 @@ describe("isAdmin guard", () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
req.userId = adminUser.id;
req.user = adminUser;
const { isAdmin, scope } = await isAdminGuard(req);
expect(isAdmin).toBe(true);
expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
@@ -81,6 +86,8 @@ describe("isAdmin guard", () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
req.userId = adminUser.id;
req.user = adminUser;
const { isAdmin } = await isAdminGuard(req);
expect(isAdmin).toBe(false);
});
@@ -0,0 +1,88 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach } from "vitest";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
describe("isLockedOrBlocked", () => {
beforeEach(async () => {
await prismock.watchlist.createMany({
data: [
{
type: "DOMAIN",
value: "spam.com",
createdById: 1,
},
{
type: "DOMAIN",
value: "blocked.com",
createdById: 1,
},
],
});
});
it("should return false if no user in request", async () => {
const req = { userId: null, user: null } as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
it("should return false if user has no email", async () => {
const req = { userId: 123, user: { email: null } } as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
it("should return true if user is locked", async () => {
const req = {
userId: 123,
user: {
locked: true,
email: "test@example.com",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
});
it("should return true if user email domain is watchlisted", async () => {
const req = {
userId: 123,
user: {
locked: false,
email: "test@blocked.com",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
});
it("should return false if user is not locked and email domain is not watchlisted", async () => {
const req = {
userId: 123,
user: {
locked: false,
email: "test@example.com",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
it("should handle email domains case-insensitively", async () => {
const req = {
userId: 123,
user: {
locked: false,
email: "test@BLOCKED.COM",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
});
});
+1
View File
@@ -12,6 +12,7 @@
},
"include": [
"next-env.d.ts",
"./next.d.ts",
"**/*.ts",
"**/*.tsx",
"../../../packages/types/*.d.ts",
@@ -6,6 +6,7 @@ import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { HttpError } from "@calcom/lib/http-error";
@@ -38,6 +39,8 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
})
.parse(req.body);
const shouldLockByDefault = await checkIfEmailInWatchlistController(_email);
log.debug("handler", { email: _email });
let username: string | null = req.usernameStatus.requestedUserName;
@@ -191,6 +194,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
data: {
username,
email,
locked: shouldLockByDefault,
password: { create: { hash: hashedPassword } },
metadata: {
stripeCustomerId: customer.id,
@@ -0,0 +1,33 @@
import { startSpan } from "@sentry/nextjs";
import type { Watchlist } from "../watchlist.model";
import { WatchlistRepository } from "../watchlist.repository";
/**
* Controllers use Presenters to convert the data to a UI-friendly format just before
* returning it to the "consumer". This helps us ship less JavaScript to the client (logic
* and libraries to convert the data), helps prevent leaking any sensitive properties, like
* emails or hashed passwords, and also helps us slim down the amount of data we're sending
* back to the client.
*/
function presenter(watchlistedEmail: Watchlist | null) {
return startSpan({ name: "checkIfEmailInWatchlist Presenter", op: "serialize" }, () => {
return !!watchlistedEmail;
});
}
/**
* Controllers perform authentication checks and input validation before passing the input
* to the specific use cases. Controllers orchestrate Use Cases. They don't implement any
* logic, but define the whole operations using use cases.
*/
export async function checkIfEmailInWatchlistController(
email: string
): Promise<ReturnType<typeof presenter>> {
return await startSpan({ name: "checkIfEmailInWatchlist Controller" }, async () => {
const lowercasedEmail = email.toLowerCase();
const watchlistRepository = new WatchlistRepository();
const watchlistedEmail = await watchlistRepository.getEmailInWatchlist(lowercasedEmail);
return presenter(watchlistedEmail);
});
}
@@ -0,0 +1,22 @@
import type { z } from "zod";
import { _WatchlistModel } from "@calcom/prisma/zod/watchlist";
export const WatchlistModelSchema = _WatchlistModel.pick({
id: true,
type: true,
value: true,
description: true,
createdAt: true,
createdById: true,
updatedAt: true,
updatedById: true,
});
export type Watchlist = z.infer<typeof WatchlistModelSchema>;
export const insertWatchlistSchema = WatchlistModelSchema.pick({
type: true,
value: true,
description: true,
});
@@ -0,0 +1,5 @@
import type { Watchlist } from "./watchlist.model";
export interface IWatchlistRepository {
getEmailInWatchlist(email: string): Promise<Watchlist | null>;
}
@@ -0,0 +1,19 @@
import { WatchlistType } from "@calcom/prisma/enums";
import type { IWatchlistRepository } from "./watchlist.repository.interface";
export class MockFeaturesRepository implements IWatchlistRepository {
async getEmailInWatchlist(email: string) {
if (email !== "watchlisted@example.com") return null;
return {
id: "1",
type: WatchlistType.EMAIL,
value: "watchlisted@example.com",
description: "This is a watchlisted email",
createdAt: new Date(),
createdById: 1,
updatedAt: new Date(),
updatedById: 1,
};
}
}
@@ -0,0 +1,26 @@
import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import { WatchlistType } from "@calcom/prisma/enums";
import type { IWatchlistRepository } from "./watchlist.repository.interface";
export class WatchlistRepository implements IWatchlistRepository {
async getEmailInWatchlist(email: string) {
const [, domain] = email.split("@");
try {
const emailInWatchlist = await db.watchlist.findFirst({
where: {
OR: [
{ type: WatchlistType.EMAIL, value: email },
{ type: WatchlistType.DOMAIN, value: domain },
],
},
});
return emailInWatchlist;
} catch (err) {
captureException(err);
throw err;
}
}
}
@@ -0,0 +1,31 @@
-- CreateEnum
CREATE TYPE "WatchlistType" AS ENUM ('EMAIL', 'DOMAIN', 'USERNAME');
-- CreateTable
CREATE TABLE "Watchlist" (
"id" TEXT NOT NULL,
"type" "WatchlistType" NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdById" INTEGER NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedById" INTEGER,
CONSTRAINT "Watchlist_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Watchlist_id_key" ON "Watchlist"("id");
-- CreateIndex
CREATE INDEX "Watchlist_type_value_idx" ON "Watchlist"("type", "value");
-- CreateIndex
CREATE UNIQUE INDEX "Watchlist_type_value_key" ON "Watchlist"("type", "value");
-- AddForeignKey
ALTER TABLE "Watchlist" ADD CONSTRAINT "Watchlist_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Watchlist" ADD CONSTRAINT "Watchlist_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+25
View File
@@ -357,6 +357,8 @@ model User {
reassignedBookings Booking[] @relation("reassignByUser")
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
createdWatchlists Watchlist[] @relation("CreatedWatchlists")
updatedWatchlists Watchlist[] @relation("UpdatedWatchlists")
@@unique([email])
@@unique([email, username])
@@ -1659,3 +1661,26 @@ model EventTypeTranslation {
@@index([eventTypeId, field, targetLang]) // TODO: remove after migration
@@index([eventTypeId, field, targetLocale])
}
enum WatchlistType {
EMAIL
DOMAIN
USERNAME
}
model Watchlist {
id String @id @unique @default(cuid())
type WatchlistType
// The identifier of the Watchlisted entity (email or domain)
value String
description String?
createdAt DateTime @default(now())
createdBy User @relation("CreatedWatchlists", onDelete: Cascade, fields: [createdById], references: [id])
createdById Int
updatedAt DateTime @updatedAt
updatedBy User? @relation("UpdatedWatchlists", onDelete: SetNull, fields: [updatedById], references: [id])
updatedById Int?
@@unique([type, value])
@@index([type, value])
}