feat: add PBAC permission checks to insights (#23592)

* feat: add PBAC permission checks to insights

- Add layout-level protection for insights pages with session.user.org.id check
- Add API-level protection for insights tRPC endpoints using insightsPbacProcedure
- Enhance routing form insights with PBAC team filtering
- Use insights.read permission with OWNER/ADMIN fallback roles
- Replace all userBelongsToTeamProcedure with insightsPbacProcedure in insights router
- Fix lint issues: remove unused variables and functions, fix non-null assertion

Implements Permission-Based Access Control (PBAC) for insights functionality following the same patterns as event type PBAC implementation from PR #22618.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: clean up unused imports in routing-forms

- Remove unused isFormCreateEditAllowed import
- Remove unused TRPCError import
- Change MembershipRole to type import for better tree-shaking

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* address feedback

* clean up permission check

* revert unnecessary changes

* fix procedure

* allow MEMBER for insights

* support teams not under orgs

* fix type error

* revert unnecessary changes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae LeeandDevin AI authored and GitHub committed 2025-09-09 14:26:20 +01:00
1 parent ad4cf0cc3a
commit 1137047606
8 files changed
+159 -98

No files matched your search

@@ -0,0 +1,34 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { hasInsightsPermission } from "@calcom/features/insights/server/hasInsightsPermission";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export async function checkInsightsPagePermission() {
const featuresRepository = new FeaturesRepository(prisma);
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
redirect("/");
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
const hasPermission = await hasInsightsPermission({
userId: session.user.id,
organizationId: session.user.org?.id,
});
if (!hasPermission) {
redirect("/");
}
return session;
}
@@ -1,34 +1,22 @@
import { getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import { prisma } from "@calcom/prisma";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository(prisma);
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
const t = await getTranslate();
return (
<>
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</Shell>
</div>
</>
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</Shell>
</div>
);
}
@@ -1,13 +1,11 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import InsightsPage from "~/insights/insights-view";
import { checkInsightsPagePermission } from "./checkInsightsPagePermission";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
@@ -18,7 +16,7 @@ export const generateMetadata = async () =>
);
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await checkInsightsPagePermission();
const { timeZone } = await prisma.user.findUniqueOrThrow({
where: { id: session?.user.id ?? -1 },
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
@@ -12,5 +14,7 @@ export const generateMetadata = async () =>
);
export default async function Page() {
await checkInsightsPagePermission();
return <InsightsVirtualQueuesPage />;
}
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
import InsightsRoutingPage from "~/insights/insights-routing-view";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
@@ -12,5 +14,7 @@ export const generateMetadata = async () =>
);
export default async function Page() {
await checkInsightsPagePermission();
return <InsightsRoutingPage />;
}
@@ -0,0 +1,39 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { MembershipRole } from "@calcom/prisma/enums";
export async function hasInsightsPermission({
userId,
organizationId,
}: {
userId: number;
organizationId: number | null | undefined;
}) {
if (organizationId) {
const permissionCheckService = new PermissionCheckService();
return await permissionCheckService.checkPermission({
userId,
teamId: organizationId,
permission: "insights.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], // even members can see their own data
});
} else {
const permissionCheckService = new PermissionCheckService();
const teamIds = await MembershipRepository.findUserTeamIds({ userId });
for (const teamId of teamIds) {
const hasPermission = await permissionCheckService.checkPermission({
userId,
teamId,
permission: "insights.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], // even members can see their own data
});
if (hasPermission) {
return true;
}
}
}
return false;
}
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import mapKeys from "lodash/mapKeys";
// eslint-disable-next-line no-restricted-imports
@@ -13,12 +13,12 @@ import {
import { getInsightsBookingService } from "@calcom/lib/di/containers/InsightsBooking";
import { getInsightsRoutingService } from "@calcom/lib/di/containers/InsightsRouting";
import type { PrismaClient } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { hasInsightsPermission } from "./hasInsightsPermission";
import { getTimeView, getDateRanges, type GetDateRangesParams } from "./insightsDateUtils";
import { RoutingEventsInsights } from "./routing-events";
import { VirtualQueuesInsights } from "./virtual-queues";
@@ -43,8 +43,6 @@ interface BuildBaseWhereConditionType {
ctx: BuildBaseWhereConditionCtxType;
}
const bookingStatusSchema = z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).optional();
export const buildBaseWhereCondition = async ({
teamId,
eventTypeId,
@@ -172,22 +170,6 @@ export const buildBaseWhereCondition = async ({
};
};
const buildHashMapForUsers = <
T extends { avatarUrl: string | null; id: number; username: string | null; [key: string]: unknown }
>(
usersFromTeam: T[]
) => {
const userHashMap = new Map<number | null, Omit<T, "avatarUrl"> & { avatarUrl: string }>();
usersFromTeam.forEach((user) => {
userHashMap.set(user.id, {
...user,
// TODO: Use AVATAR_FALLBACK when avatar.png endpoint is fased out
avatarUrl: user.avatarUrl || `/${user.username}/avatar.png`,
});
});
return userHashMap;
};
const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, getRawInput }) => {
const parse = UserBelongsToTeamInput.safeParse(await getRawInput());
if (!parse.success) {
@@ -254,6 +236,21 @@ const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, getRa
});
});
const insightsPbacProcedure = userBelongsToTeamProcedure.use(async ({ ctx, next }) => {
const hasPermission = await hasInsightsPermission({
userId: ctx.user.id,
organizationId: ctx.user.organizationId,
});
if (!hasPermission) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx,
});
});
const userSelect = {
id: true,
name: true,
@@ -311,8 +308,6 @@ export interface IResultTeamList {
isOrg?: boolean;
}
const BATCH_SIZE = 1000; // Adjust based on your needs
/**
* Helper function to create InsightsBookingService with standardized parameters
*/
@@ -360,7 +355,7 @@ function createInsightsRoutingService(
}
export const insightsRouter = router({
bookingKPIStats: userBelongsToTeamProcedure
bookingKPIStats: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const currentPeriodService = createInsightsBookingService(ctx, input);
@@ -460,32 +455,30 @@ export const insightsRouter = router({
},
};
}),
eventTrends: userBelongsToTeamProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { startDate, endDate, timeZone } = input;
eventTrends: insightsPbacProcedure.input(bookingRepositoryBaseInputSchema).query(async ({ ctx, input }) => {
const { startDate, endDate, timeZone } = input;
// Calculate timeView and dateRanges
const timeView = getTimeView(startDate, endDate);
const dateRanges = getDateRanges({
startDate,
endDate,
timeView,
// Calculate timeView and dateRanges
const timeView = getTimeView(startDate, endDate);
const dateRanges = getDateRanges({
startDate,
endDate,
timeView,
timeZone,
weekStart: ctx.user.weekStart,
});
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getEventTrendsStats({
timeZone,
weekStart: ctx.user.weekStart,
dateRanges,
});
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getEventTrendsStats({
timeZone,
dateRanges,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
popularEvents: userBelongsToTeamProcedure
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
popularEvents: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
@@ -496,7 +489,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
averageEventDuration: userBelongsToTeamProcedure
averageEventDuration: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { startDate, endDate, timeZone } = input;
@@ -536,7 +529,8 @@ export const insightsRouter = router({
for (const booking of allBookings) {
const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
if (resultMap.has(periodStart)) {
const current = resultMap.get(periodStart)!;
const current = resultMap.get(periodStart);
if (!current) continue;
current.totalDuration += booking.eventLength || 0;
current.count += 1;
}
@@ -552,7 +546,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithMostCancelledBookings: userBelongsToTeamProcedure
membersWithMostCancelledBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
@@ -563,7 +557,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithMostCompletedBookings: userBelongsToTeamProcedure
membersWithMostCompletedBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
@@ -574,7 +568,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithLeastCompletedBookings: userBelongsToTeamProcedure
membersWithLeastCompletedBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
@@ -585,7 +579,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithMostBookings: userBelongsToTeamProcedure
membersWithMostBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
@@ -596,7 +590,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithLeastBookings: userBelongsToTeamProcedure
membersWithLeastBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
@@ -760,7 +754,7 @@ export const insightsRouter = router({
return usersInTeam.map((membership) => membership.user);
}),
eventTypeList: userBelongsToTeamProcedure
eventTypeList: insightsPbacProcedure
.input(
z.object({
teamId: z.coerce.number().nullish(),
@@ -786,13 +780,13 @@ export const insightsRouter = router({
return eventTypeList;
}),
recentRatings: userBelongsToTeamProcedure
recentRatings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
return await insightsBookingService.getRecentRatingsStats();
}),
membersWithMostNoShow: userBelongsToTeamProcedure
membersWithMostNoShow: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
@@ -803,19 +797,19 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithHighestRatings: userBelongsToTeamProcedure
membersWithHighestRatings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
return await insightsBookingService.getMembersRatingStats("DESC");
}),
membersWithLowestRatings: userBelongsToTeamProcedure
membersWithLowestRatings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
return await insightsBookingService.getMembersRatingStats("ASC");
}),
rawData: userBelongsToTeamProcedure
rawData: insightsPbacProcedure
.input(
bookingRepositoryBaseInputSchema.extend({
limit: z.number().max(100).optional(),
@@ -837,10 +831,10 @@ export const insightsRouter = router({
}
}),
getRoutingFormsForFilters: userBelongsToTeamProcedure
getRoutingFormsForFilters: insightsPbacProcedure
.input(z.object({ userId: z.number().optional(), teamId: z.number().optional(), isAll: z.boolean() }))
.query(async ({ ctx, input }) => {
const { userId, teamId, isAll } = input;
const { teamId, isAll } = input;
return await RoutingEventsInsights.getRoutingFormsForFilters({
userId: ctx.user.id,
teamId,
@@ -848,13 +842,13 @@ export const insightsRouter = router({
organizationId: ctx.user.organizationId ?? undefined,
});
}),
routingFormsByStatus: userBelongsToTeamProcedure
routingFormsByStatus: insightsPbacProcedure
.input(insightsRoutingServiceInputSchema)
.query(async ({ ctx, input }) => {
const insightsRoutingService = createInsightsRoutingService(ctx, input);
return await insightsRoutingService.getRoutingFormStats();
}),
routingFormResponses: userBelongsToTeamProcedure
routingFormResponses: insightsPbacProcedure
.input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => {
const insightsRoutingService = createInsightsRoutingService(ctx, input);
@@ -864,7 +858,7 @@ export const insightsRouter = router({
offset: input.offset,
});
}),
routingFormResponsesForDownload: userBelongsToTeamProcedure
routingFormResponsesForDownload: insightsPbacProcedure
.input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => {
const headersPromise = RoutingEventsInsights.getRoutingFormHeaders({
@@ -889,7 +883,7 @@ export const insightsRouter = router({
dataPromise,
});
}),
getRoutingFormFieldOptions: userBelongsToTeamProcedure
getRoutingFormFieldOptions: insightsPbacProcedure
.input(
z.object({
userId: z.number().optional(),
@@ -906,7 +900,7 @@ export const insightsRouter = router({
});
return options;
}),
failedBookingsByField: userBelongsToTeamProcedure
failedBookingsByField: insightsPbacProcedure
.input(insightsRoutingServiceInputSchema)
.query(async ({ ctx, input }) => {
const insightsRoutingService = createInsightsRoutingService(ctx, input);
@@ -916,7 +910,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
routingFormResponsesHeaders: userBelongsToTeamProcedure
routingFormResponsesHeaders: insightsPbacProcedure
.input(
z.object({
userId: z.number().optional(),
@@ -936,7 +930,7 @@ export const insightsRouter = router({
return headers || [];
}),
routedToPerPeriod: userBelongsToTeamProcedure
routedToPerPeriod: insightsPbacProcedure
.input(routedToPerPeriodInputSchema)
.query(async ({ ctx, input }) => {
const { period, limit, searchQuery, ...rest } = input;
@@ -952,7 +946,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
routedToPerPeriodCsv: userBelongsToTeamProcedure
routedToPerPeriodCsv: insightsPbacProcedure
.input(routedToPerPeriodCsvInputSchema)
.query(async ({ ctx, input }) => {
const { period, searchQuery, ...rest } = input;
@@ -985,7 +979,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
getRoutingFunnelData: userBelongsToTeamProcedure
getRoutingFunnelData: insightsPbacProcedure
.input(routingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const timeView = getTimeView(input.startDate, input.endDate);
@@ -1003,7 +997,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
bookingsByHourStats: userBelongsToTeamProcedure
bookingsByHourStats: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { timeZone } = input;
@@ -1017,7 +1011,7 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
recentNoShowGuests: userBelongsToTeamProcedure
recentNoShowGuests: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");