Enforce team booking visibility
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
@@ -39,13 +40,20 @@ const Page = async ({ params }: PageProps) => {
|
||||
const userId = session.user.id;
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
|
||||
// No teams in cal.diy, so canReadOthersBookings is always false.
|
||||
const canReadOthersBookings = false;
|
||||
|
||||
const [bookingAuditEnabled, bookingsV3Enabled] = await Promise.all([
|
||||
const [bookingAuditEnabled, bookingsV3Enabled, adminTeamMembershipCount] = await Promise.all([
|
||||
featuresRepository.checkIfUserHasFeature(userId, "booking-audit"),
|
||||
featuresRepository.checkIfUserHasFeature(userId, "bookings-v3"),
|
||||
prisma.membership.count({
|
||||
where: {
|
||||
userId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const canReadOthersBookings = adminTeamMembershipCount > 0;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
|
||||
+143
-5
@@ -1,7 +1,7 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import kysely from "@calcom/kysely";
|
||||
import type { Booking, EventType, User } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Booking, EventType, Team, User } from "@calcom/prisma/client";
|
||||
import { BookingStatus, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getBookings } from "./get.handler";
|
||||
|
||||
@@ -11,6 +11,13 @@ let eventType1: EventType;
|
||||
let booking1: Booking;
|
||||
let booking2: Booking;
|
||||
let booking3: Booking;
|
||||
let team1: Team;
|
||||
let team2: Team;
|
||||
let teamEventType1: EventType;
|
||||
let teamEventType2: EventType;
|
||||
let teamBookingAssignedToMember: Booking;
|
||||
let teamBookingAssignedToOwner: Booking;
|
||||
let otherTeamBooking: Booking;
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
@@ -101,19 +108,104 @@ describe("getBookings - integration", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
team1 = await prisma.team.create({
|
||||
data: {
|
||||
name: `GetBookings Team 1 ${timestamp}`,
|
||||
slug: `getbookings-team-1-${timestamp}`,
|
||||
members: {
|
||||
create: [
|
||||
{ userId: user1.id, role: MembershipRole.OWNER, accepted: true },
|
||||
{ userId: user2.id, role: MembershipRole.MEMBER, accepted: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
team2 = await prisma.team.create({
|
||||
data: {
|
||||
name: `GetBookings Team 2 ${timestamp}`,
|
||||
slug: `getbookings-team-2-${timestamp}`,
|
||||
members: {
|
||||
create: [{ userId: user2.id, role: MembershipRole.OWNER, accepted: true }],
|
||||
},
|
||||
},
|
||||
});
|
||||
teamEventType1 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: `GetBookings Team Event 1 ${timestamp}`,
|
||||
slug: `getbookings-team-event-1-${timestamp}`,
|
||||
length: 30,
|
||||
teamId: team1.id,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
},
|
||||
});
|
||||
teamEventType2 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: `GetBookings Team Event 2 ${timestamp}`,
|
||||
slug: `getbookings-team-event-2-${timestamp}`,
|
||||
length: 30,
|
||||
teamId: team2.id,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
},
|
||||
});
|
||||
teamBookingAssignedToMember = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `getbookings-team-member-${timestamp}`,
|
||||
title: "Team booking assigned to member",
|
||||
startTime: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
userId: user2.id,
|
||||
eventTypeId: teamEventType1.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
teamBookingAssignedToOwner = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `getbookings-team-owner-${timestamp}`,
|
||||
title: "Team booking assigned to owner",
|
||||
startTime: new Date(Date.now() + 11 * 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 11 * 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
userId: user1.id,
|
||||
eventTypeId: teamEventType1.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
otherTeamBooking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `getbookings-other-team-${timestamp}`,
|
||||
title: "Other team booking",
|
||||
startTime: new Date(Date.now() + 12 * 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 12 * 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
userId: user2.id,
|
||||
eventTypeId: teamEventType2.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
const bookingIds = [booking1?.id, booking2?.id, booking3?.id].filter(Boolean);
|
||||
const bookingIds = [
|
||||
booking1?.id,
|
||||
booking2?.id,
|
||||
booking3?.id,
|
||||
teamBookingAssignedToMember?.id,
|
||||
teamBookingAssignedToOwner?.id,
|
||||
otherTeamBooking?.id,
|
||||
].filter(Boolean);
|
||||
if (bookingIds.length > 0) {
|
||||
await prisma.attendee.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
|
||||
}
|
||||
const eventTypeIds = [eventType1?.id].filter(Boolean);
|
||||
const eventTypeIds = [eventType1?.id, teamEventType1?.id, teamEventType2?.id].filter(Boolean);
|
||||
if (eventTypeIds.length > 0) {
|
||||
await prisma.eventType.deleteMany({ where: { id: { in: eventTypeIds } } });
|
||||
}
|
||||
const teamIds = [team1?.id, team2?.id].filter(Boolean);
|
||||
if (teamIds.length > 0) {
|
||||
await prisma.membership.deleteMany({ where: { teamId: { in: teamIds } } });
|
||||
await prisma.team.deleteMany({ where: { id: { in: teamIds } } });
|
||||
}
|
||||
const userIds = [user1?.id, user2?.id].filter(Boolean);
|
||||
if (userIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
@@ -236,4 +328,50 @@ describe("getBookings - integration", () => {
|
||||
expect(bookingIds).toContain(booking3.id);
|
||||
expect(bookingIds).toContain(booking1.id);
|
||||
});
|
||||
|
||||
it("shows all team bookings to accepted owners", async () => {
|
||||
const result = await getBookings({
|
||||
user: { id: user1.id, email: user1.email, orgId: null },
|
||||
prisma,
|
||||
kysely,
|
||||
bookingListingByStatus: ["upcoming"],
|
||||
filters: {},
|
||||
take: 50,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
const bookingIds = result.bookings.map((booking) => booking.id);
|
||||
expect(bookingIds).toContain(teamBookingAssignedToMember.id);
|
||||
expect(bookingIds).toContain(teamBookingAssignedToOwner.id);
|
||||
});
|
||||
|
||||
it("shows only assigned team bookings to a member", async () => {
|
||||
const result = await getBookings({
|
||||
user: { id: user2.id, email: user2.email, orgId: null },
|
||||
prisma,
|
||||
kysely,
|
||||
bookingListingByStatus: ["upcoming"],
|
||||
filters: {},
|
||||
take: 50,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
const bookingIds = result.bookings.map((booking) => booking.id);
|
||||
expect(bookingIds).toContain(teamBookingAssignedToMember.id);
|
||||
expect(bookingIds).not.toContain(teamBookingAssignedToOwner.id);
|
||||
});
|
||||
|
||||
it("does not leak bookings across teams", async () => {
|
||||
const result = await getBookings({
|
||||
user: { id: user1.id, email: user1.email, orgId: null },
|
||||
prisma,
|
||||
kysely,
|
||||
bookingListingByStatus: ["upcoming"],
|
||||
filters: {},
|
||||
take: 50,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
expect(result.bookings.map((booking) => booking.id)).not.toContain(otherTeamBooking.id);
|
||||
});
|
||||
});
|
||||
@@ -18,13 +18,6 @@ import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TGetInputSchema } from "./get.schema";
|
||||
|
||||
class PermissionCheckService {
|
||||
constructor(_prisma?: unknown) {}
|
||||
async checkPermission(..._args: unknown[]) { return true; }
|
||||
async hasPermission(..._args: unknown[]) { return true; }
|
||||
async getTeamIdsWithPermission(..._args: unknown[]): Promise<number[]> { return []; }
|
||||
}
|
||||
|
||||
type GetOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
@@ -114,15 +107,7 @@ export async function getBookings({
|
||||
take: number;
|
||||
skip: number;
|
||||
}) {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const fallbackRoles: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
|
||||
|
||||
const teamIdsWithBookingPermission = await permissionCheckService.getTeamIdsWithPermission({
|
||||
userId: user.id,
|
||||
permission: "booking.read",
|
||||
fallbackRoles,
|
||||
orgId: user.orgId ?? undefined,
|
||||
});
|
||||
const teamIdsWithBookingPermission = await getAcceptedAdminTeamIds(prisma, user.id);
|
||||
|
||||
// Only fetch user IDs from teams if we need to validate userIds filter
|
||||
// PERFORMANCE: We no longer need to fetch all emails/IDs for the main query since we use subqueries
|
||||
@@ -248,57 +233,7 @@ export async function getBookings({
|
||||
.where("Attendee.email", "=", user.email),
|
||||
tables: ["Booking", "Attendee", "BookingSeat"],
|
||||
});
|
||||
// 4. Scope depends on `user.orgId`:
|
||||
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get bookings where organization/team members are attendees
|
||||
// PERFORMANCE: Use subquery with team membership instead of materializing all emails (can be 400+ for large orgs)
|
||||
if (teamIdsWithBookingPermission?.length) {
|
||||
bookingQueries.push({
|
||||
query: kysely
|
||||
.selectFrom("Booking")
|
||||
.select("Booking.id")
|
||||
.select("Booking.startTime")
|
||||
.select("Booking.endTime")
|
||||
.select("Booking.createdAt")
|
||||
.select("Booking.updatedAt")
|
||||
.innerJoin("Attendee", "Attendee.bookingId", "Booking.id")
|
||||
.where("Attendee.email", "in", (eb) =>
|
||||
eb
|
||||
.selectFrom("users")
|
||||
.select("users.email")
|
||||
.innerJoin("Membership", "Membership.userId", "users.id")
|
||||
.where("Membership.teamId", "in", teamIdsWithBookingPermission)
|
||||
),
|
||||
tables: ["Booking", "Attendee"],
|
||||
});
|
||||
}
|
||||
// 5. Scope depends on `user.orgId`:
|
||||
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get bookings where organization/team members are attendees via seatsReference
|
||||
// PERFORMANCE: Use subquery with team membership instead of materializing all emails
|
||||
if (teamIdsWithBookingPermission?.length) {
|
||||
bookingQueries.push({
|
||||
query: kysely
|
||||
.selectFrom("Booking")
|
||||
.select("Booking.id")
|
||||
.select("Booking.startTime")
|
||||
.select("Booking.endTime")
|
||||
.select("Booking.createdAt")
|
||||
.select("Booking.updatedAt")
|
||||
.innerJoin("Attendee", "Attendee.bookingId", "Booking.id")
|
||||
.innerJoin("BookingSeat", "Attendee.id", "BookingSeat.attendeeId")
|
||||
.where("Attendee.email", "in", (eb) =>
|
||||
eb
|
||||
.selectFrom("users")
|
||||
.select("users.email")
|
||||
.innerJoin("Membership", "Membership.userId", "users.id")
|
||||
.where("Membership.teamId", "in", teamIdsWithBookingPermission)
|
||||
),
|
||||
tables: ["Booking", "Attendee", "BookingSeat"],
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Scope depends on `user.orgId`:
|
||||
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get booking created for an event type within the organization/team
|
||||
// PERFORMANCE: Use subquery to get event type IDs instead of materializing them
|
||||
// Accepted team owners/admins can see bookings created for event types owned by their teams.
|
||||
if (teamIdsWithBookingPermission?.length) {
|
||||
bookingQueries.push({
|
||||
query: kysely
|
||||
@@ -317,28 +252,6 @@ export async function getBookings({
|
||||
tables: ["Booking"],
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Scope depends on `user.orgId`:
|
||||
// - If Current user is ORG_OWNER/ADMIN or has booking.read permission, get bookings created by users within the same organization/team
|
||||
// PERFORMANCE: Use subquery with team membership instead of materializing all user IDs
|
||||
if (teamIdsWithBookingPermission?.length) {
|
||||
bookingQueries.push({
|
||||
query: kysely
|
||||
.selectFrom("Booking")
|
||||
.select("Booking.id")
|
||||
.select("Booking.startTime")
|
||||
.select("Booking.endTime")
|
||||
.select("Booking.createdAt")
|
||||
.select("Booking.updatedAt")
|
||||
.where("Booking.userId", "in", (eb) =>
|
||||
eb
|
||||
.selectFrom("Membership")
|
||||
.select("Membership.userId")
|
||||
.where("Membership.teamId", "in", teamIdsWithBookingPermission)
|
||||
),
|
||||
tables: ["Booking"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queriesWithFilters = bookingQueries.map(({ query, tables }) => {
|
||||
@@ -989,6 +902,7 @@ async function getUserIdsFromTeamIds(prisma: PrismaClient, teamIds: number[]): P
|
||||
teamId: {
|
||||
in: teamIds,
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -999,6 +913,23 @@ async function getUserIdsFromTeamIds(prisma: PrismaClient, teamIds: number[]): P
|
||||
return Array.from(new Set(users.map((user) => user.id)));
|
||||
}
|
||||
|
||||
async function getAcceptedAdminTeamIds(prisma: PrismaClient, userId: number): Promise<number[]> {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return memberships.map((membership) => membership.teamId);
|
||||
}
|
||||
|
||||
function addStatusesQueryFilters(query: BookingsUnionQuery, statuses: InputByStatus[]) {
|
||||
if (statuses?.length) {
|
||||
return query.where(({ eb, or, and }) =>
|
||||
|
||||
Reference in New Issue
Block a user