Enforce team booking visibility

This commit is contained in:
2026-06-07 15:12:45 -06:00
parent 7e7f9108c6
commit 5026e0ea22
3 changed files with 175 additions and 98 deletions
@@ -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
@@ -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 }) =>