diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx
index 93174bcd5f9051..4626d80c511fea 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import { APP_NAME } from "@calcom/lib/constants";
-import { UserPermissionRole } from "@calcom/prisma/enums";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -26,11 +25,10 @@ const WebhooksViewServerWrapper = async () => {
redirect("/auth/login");
}
- const isAdmin = session.user.role === UserPermissionRole.ADMIN;
const caller = await createRouterCaller(webhookRouter);
const data = await caller.getByViewer();
- return ;
+ return ;
};
export default WebhooksViewServerWrapper;
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 1225495428bfbb..72719a9d481fd0 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3498,6 +3498,11 @@
"pbac_desc_view_workflows": "View existing workflows and their configurations",
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
+ "pbac_resource_webhook": "Webhook",
+ "pbac_desc_create_webhooks": "Create webhooks",
+ "pbac_desc_view_webhooks": "View webhooks",
+ "pbac_desc_update_webhooks": "Update webhooks",
+ "pbac_desc_delete_webhooks": "Delete webhooks",
"pbac_desc_manage_workflows": "Full management access to all workflows",
"pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types",
diff --git a/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx b/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx
index 5e7c421eaa807a..d0c8c76a7b323a 100644
--- a/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx
+++ b/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx
@@ -365,6 +365,11 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
+ // TODO (SEAN): Implement Permissions here when we have event-types PR merged
+ permissions={{
+ canEditWebhook: !webhookLockedStatus.disabled,
+ canDeleteWebhook: !webhookLockedStatus.disabled,
+ }}
/>
);
})}
diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts
index 89850633c08610..d7b0eb84f6615f 100644
--- a/packages/features/pbac/domain/types/permission-registry.ts
+++ b/packages/features/pbac/domain/types/permission-registry.ts
@@ -9,6 +9,7 @@ export enum Resource {
Role = "role",
RoutingForm = "routingForm",
Workflow = "workflow",
+ Webhook = "webhook",
}
export enum CrudAction {
@@ -516,4 +517,36 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
dependsOn: ["routingForm.read"],
},
},
+ [Resource.Webhook]: {
+ _resource: {
+ i18nKey: "pbac_resource_webhook",
+ },
+ [CrudAction.Create]: {
+ description: "Create webhooks",
+ category: "webhook",
+ i18nKey: "pbac_action_create",
+ descriptionI18nKey: "pbac_desc_create_webhooks",
+ dependsOn: ["webhook.read"],
+ },
+ [CrudAction.Read]: {
+ description: "View webhooks",
+ category: "webhook",
+ i18nKey: "pbac_action_read",
+ descriptionI18nKey: "pbac_desc_view_webhooks",
+ },
+ [CrudAction.Update]: {
+ description: "Update webhooks",
+ category: "webhook",
+ i18nKey: "pbac_action_update",
+ descriptionI18nKey: "pbac_desc_update_webhooks",
+ dependsOn: ["webhook.read"],
+ },
+ [CrudAction.Delete]: {
+ description: "Delete webhooks",
+ category: "webhook",
+ i18nKey: "pbac_action_delete",
+ descriptionI18nKey: "pbac_desc_delete_webhooks",
+ dependsOn: ["webhook.read"],
+ },
+ },
};
diff --git a/packages/features/webhooks/components/CreateNewWebhookButton.tsx b/packages/features/webhooks/components/CreateNewWebhookButton.tsx
index db83c6ed98e36e..f01f856c429621 100644
--- a/packages/features/webhooks/components/CreateNewWebhookButton.tsx
+++ b/packages/features/webhooks/components/CreateNewWebhookButton.tsx
@@ -4,8 +4,9 @@ import { useRouter } from "next/navigation";
import { CreateButtonWithTeamsList } from "@calcom/features/ee/teams/components/createButton/CreateButtonWithTeamsList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { MembershipRole } from "@calcom/prisma/enums";
-export const CreateNewWebhookButton = ({ isAdmin }: { isAdmin: boolean }) => {
+export const CreateNewWebhookButton = () => {
const router = useRouter();
const { t } = useLocale();
const createFunction = (teamId?: number, platform?: boolean) => {
@@ -20,10 +21,13 @@ export const CreateNewWebhookButton = ({ isAdmin }: { isAdmin: boolean }) => {
);
};
diff --git a/packages/features/webhooks/components/WebhookListItem.tsx b/packages/features/webhooks/components/WebhookListItem.tsx
index 1d354da07174d2..ba590b70ac95af 100644
--- a/packages/features/webhooks/components/WebhookListItem.tsx
+++ b/packages/features/webhooks/components/WebhookListItem.tsx
@@ -36,12 +36,14 @@ export default function WebhookListItem(props: {
canEditWebhook?: boolean;
onEditWebhook: () => void;
lastItem: boolean;
- readOnly?: boolean;
+ permissions: {
+ canEditWebhook?: boolean;
+ canDeleteWebhook?: boolean;
+ };
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const { webhook } = props;
- const canEditWebhook = props.canEditWebhook ?? true;
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
async onSuccess() {
@@ -87,7 +89,7 @@ export default function WebhookListItem(props: {
{webhook.subscriberUrl}
- {!!props.readOnly && (
+ {!props.permissions.canEditWebhook && (
0 ? : null}
+ CTA={webhooksByViewer.webhookGroups.length > 0 ? : null}
borderInShellHeader={false}>
{!!webhookGroups.length ? (
@@ -70,8 +64,11 @@ const WebhooksList = ({
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id}`)
}
@@ -88,7 +85,7 @@ const WebhooksList = ({
headline={t("create_your_first_webhook")}
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
className="mt-6 rounded-b-lg"
- buttonRaw={}
+ buttonRaw={}
border={true}
/>
)}
diff --git a/packages/lib/server/repository/webhook.ts b/packages/lib/server/repository/webhook.ts
index a17cf3f86aa773..4b8c4d337e494b 100644
--- a/packages/lib/server/repository/webhook.ts
+++ b/packages/lib/server/repository/webhook.ts
@@ -1,5 +1,5 @@
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
-import { compareMembership } from "@calcom/lib/event-types/getEventTypesByViewer";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
@@ -14,7 +14,8 @@ type WebhookGroup = {
image?: string;
};
metadata?: {
- readOnly: boolean;
+ canModify: boolean;
+ canDelete: boolean;
};
webhooks: Webhook[];
};
@@ -30,7 +31,28 @@ const filterWebhooks = (webhook: Webhook) => {
};
export class WebhookRepository {
- static async getAllWebhooksByUserId({
+ static async findByWebhookId(webhookId?: string) {
+ return await prisma.webhook.findUniqueOrThrow({
+ where: {
+ id: webhookId,
+ },
+ select: {
+ id: true,
+ subscriberUrl: true,
+ payloadTemplate: true,
+ active: true,
+ eventTriggers: true,
+ secret: true,
+ teamId: true,
+ userId: true,
+ platform: true,
+ time: true,
+ timeUnit: true,
+ },
+ });
+ }
+
+ static async getFilteredWebhooksForUser({
userId,
userRole,
}: {
@@ -38,13 +60,12 @@ export class WebhookRepository {
userRole?: UserPermissionRole;
}) {
const user = await prisma.user.findUnique({
- where: {
- id: userId,
- },
+ where: { id: userId },
select: {
+ id: true,
username: true,
- avatarUrl: true,
name: true,
+ avatarUrl: true,
webhooks: true,
teams: {
where: {
@@ -55,18 +76,10 @@ export class WebhookRepository {
team: {
select: {
id: true,
- isOrganization: true,
name: true,
slug: true,
- parentId: true,
- metadata: true,
- members: {
- select: {
- userId: true,
- },
- },
- webhooks: true,
logoUrl: true,
+ webhooks: true,
},
},
},
@@ -78,64 +91,82 @@ export class WebhookRepository {
throw new Error("User not found");
}
- let userWebhooks = user.webhooks;
- userWebhooks = userWebhooks.filter(filterWebhooks);
- let webhookGroups: WebhookGroup[] = [];
+ // Use permission service which handles both PBAC and role-based fallbacks
+ const permissionService = new PermissionCheckService();
+
+ // Build webhook groups with proper permissions
+ const webhookGroups: WebhookGroup[] = [];
+ // Add user's personal webhooks
webhookGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
- image: getUserAvatarUrl({
- avatarUrl: user.avatarUrl,
- }),
+ image: getUserAvatarUrl({ avatarUrl: user.avatarUrl }),
},
- webhooks: userWebhooks,
+ webhooks: user.webhooks.filter(filterWebhooks),
metadata: {
- readOnly: false,
+ canModify: true,
+ canDelete: true,
},
});
- const teamMemberships = user.teams.map((membership) => ({
- teamId: membership.team.id,
- membershipRole: membership.role,
- }));
+ // Check permissions for each team
+ // The permission service handles PBAC when enabled and falls back to role-based permissions
+ for (const membership of user.teams) {
+ const teamId = membership.team.id;
+
+ // Check read permission (fallback: MEMBER, ADMIN, OWNER can read)
+ const canRead = await permissionService.checkPermission({
+ userId,
+ teamId,
+ permission: "webhook.read",
+ fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+
+ if (!canRead) {
+ // User doesn't have permission to view this team's webhooks
+ continue;
+ }
- const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => {
- const orgMembership = teamMemberships.find(
- (teamM) => teamM.teamId === membership.team.parentId
- )?.membershipRole;
- return {
+ // Check update/delete permissions in parallel (fallback: only ADMIN, OWNER can modify)
+ const [canUpdate, canDelete] = await Promise.all([
+ permissionService.checkPermission({
+ userId,
+ teamId,
+ permission: "webhook.update",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ }),
+ permissionService.checkPermission({
+ userId,
+ teamId,
+ permission: "webhook.delete",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ }),
+ ]);
+
+ webhookGroups.push({
teamId: membership.team.id,
profile: {
name: membership.team.name,
- slug: membership.team.slug
- ? !membership.team.parentId
- ? `/team`
- : `${membership.team.slug}`
- : null,
+ slug: membership.team.slug || null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
+ webhooks: membership.team.webhooks.filter(filterWebhooks),
metadata: {
- readOnly:
- membership.role ===
- (membership.team.parentId
- ? orgMembership && compareMembership(orgMembership, membership.role)
- ? orgMembership
- : MembershipRole.MEMBER
- : MembershipRole.MEMBER),
+ canModify: canUpdate,
+ canDelete,
},
- webhooks: membership.team.webhooks.filter(filterWebhooks),
- };
- });
-
- webhookGroups = webhookGroups.concat(teamWebhookGroups);
+ });
+ }
+ // Add platform webhooks for admins
if (userRole === "ADMIN") {
const platformWebhooks = await prisma.webhook.findMany({
where: { platform: true },
});
+
webhookGroups.push({
teamId: null,
profile: {
@@ -145,13 +176,14 @@ export class WebhookRepository {
},
webhooks: platformWebhooks,
metadata: {
- readOnly: false,
+ canDelete: true,
+ canModify: true,
},
});
}
return {
- webhookGroups: webhookGroups.filter((groupBy) => !!groupBy.webhooks?.length),
+ webhookGroups: webhookGroups.filter((group) => group.webhooks.length > 0),
profiles: webhookGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
@@ -159,25 +191,4 @@ export class WebhookRepository {
})),
};
}
-
- static async findByWebhookId(webhookId?: string) {
- return await prisma.webhook.findUniqueOrThrow({
- where: {
- id: webhookId,
- },
- select: {
- id: true,
- subscriberUrl: true,
- payloadTemplate: true,
- active: true,
- eventTriggers: true,
- secret: true,
- teamId: true,
- userId: true,
- platform: true,
- time: true,
- timeUnit: true,
- },
- });
- }
}
diff --git a/packages/prisma/migrations/20250905115031_add_webhooks_permissions_default_roles/migration.sql b/packages/prisma/migrations/20250905115031_add_webhooks_permissions_default_roles/migration.sql
new file mode 100644
index 00000000000000..5d09ca6bf88d8b
--- /dev/null
+++ b/packages/prisma/migrations/20250905115031_add_webhooks_permissions_default_roles/migration.sql
@@ -0,0 +1,22 @@
+-- Add webhook permissions for admin role
+INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
+SELECT
+ gen_random_uuid(), 'admin_role', resource, action, NOW()
+FROM (
+ VALUES
+ -- Attribute permissions
+ ('webhook', 'create'),
+ ('webhook', 'read'),
+ ('webhook', 'update'),
+ ('webhook', 'delete')
+) AS permissions(resource, action);
+
+-- Add read permission for member role
+INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
+SELECT
+ gen_random_uuid(), 'member_role', resource, action, NOW()
+FROM (
+ VALUES
+ -- Attribute permissions - read only
+ ('webhook', 'read')
+) AS permissions(resource, action);
diff --git a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts
index e34cb83c2f608b..9f55735945993a 100644
--- a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts
+++ b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts
@@ -112,7 +112,6 @@ export const teamsAndUserProfilesQuery = async ({ ctx, input }: TeamsAndUserProf
// Store permission results for teams that passed the filter
hasPermissionForFiltered = permissionChecks.filter((hasPermission) => hasPermission);
teamsData = teamsData.filter((_, index) => permissionChecks[index]);
-
}
return [
diff --git a/packages/trpc/server/routers/viewer/webhook/_router.tsx b/packages/trpc/server/routers/viewer/webhook/_router.tsx
index bb728bd81f8450..b5accc2fe52489 100644
--- a/packages/trpc/server/routers/viewer/webhook/_router.tsx
+++ b/packages/trpc/server/routers/viewer/webhook/_router.tsx
@@ -5,7 +5,7 @@ import { ZEditInputSchema } from "./edit.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZListInputSchema } from "./list.schema";
import { ZTestTriggerInputSchema } from "./testTrigger.schema";
-import { webhookProcedure } from "./util";
+import { createWebhookPbacProcedure } from "./util";
type WebhookRouterHandlerCache = {
list?: typeof import("./list.handler").listHandler;
@@ -20,118 +20,132 @@ type WebhookRouterHandlerCache = {
const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {};
export const webhookRouter = router({
- list: webhookProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => {
- if (!UNSTABLE_HANDLER_CACHE.list) {
- UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
+ list: createWebhookPbacProcedure("webhook.read")
+ .input(ZListInputSchema)
+ .query(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.list) {
+ UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.list) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.list({
+ ctx,
+ input,
+ });
+ }),
+
+ get: createWebhookPbacProcedure("webhook.read", ["ADMIN", "OWNER", "MEMBER"])
+ .input(ZGetInputSchema)
+ .query(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.get) {
+ UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler);
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.get) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.get({
+ ctx,
+ input,
+ });
+ }),
+
+ create: createWebhookPbacProcedure("webhook.create")
+ .input(ZCreateInputSchema)
+ .mutation(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.create) {
+ UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.create) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.create({
+ ctx,
+ input,
+ });
+ }),
+
+ edit: createWebhookPbacProcedure("webhook.update")
+ .input(ZEditInputSchema)
+ .mutation(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.edit) {
+ UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler);
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.edit) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.edit({
+ ctx,
+ input,
+ });
+ }),
+
+ delete: createWebhookPbacProcedure("webhook.delete")
+ .input(ZDeleteInputSchema)
+ .mutation(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.delete) {
+ UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler);
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.delete) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.delete({
+ ctx,
+ input,
+ });
+ }),
+
+ testTrigger: createWebhookPbacProcedure("webhook.update")
+ .input(ZTestTriggerInputSchema)
+ .mutation(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
+ UNSTABLE_HANDLER_CACHE.testTrigger = await import("./testTrigger.handler").then(
+ (mod) => mod.testTriggerHandler
+ );
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.testTrigger({
+ ctx,
+ input,
+ });
+ }),
+
+ getByViewer: createWebhookPbacProcedure("webhook.read", ["ADMIN", "OWNER", "MEMBER"]).query(
+ async ({ ctx }) => {
+ if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
+ UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
+ (mod) => mod.getByViewerHandler
+ );
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.getByViewer({
+ ctx,
+ });
}
-
- // Unreachable code but required for type safety
- if (!UNSTABLE_HANDLER_CACHE.list) {
- throw new Error("Failed to load handler");
- }
-
- return UNSTABLE_HANDLER_CACHE.list({
- ctx,
- input,
- });
- }),
-
- get: webhookProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => {
- if (!UNSTABLE_HANDLER_CACHE.get) {
- UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler);
- }
-
- // Unreachable code but required for type safety
- if (!UNSTABLE_HANDLER_CACHE.get) {
- throw new Error("Failed to load handler");
- }
-
- return UNSTABLE_HANDLER_CACHE.get({
- ctx,
- input,
- });
- }),
-
- create: webhookProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
- if (!UNSTABLE_HANDLER_CACHE.create) {
- UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
- }
-
- // Unreachable code but required for type safety
- if (!UNSTABLE_HANDLER_CACHE.create) {
- throw new Error("Failed to load handler");
- }
-
- return UNSTABLE_HANDLER_CACHE.create({
- ctx,
- input,
- });
- }),
-
- edit: webhookProcedure.input(ZEditInputSchema).mutation(async ({ ctx, input }) => {
- if (!UNSTABLE_HANDLER_CACHE.edit) {
- UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler);
- }
-
- // Unreachable code but required for type safety
- if (!UNSTABLE_HANDLER_CACHE.edit) {
- throw new Error("Failed to load handler");
- }
-
- return UNSTABLE_HANDLER_CACHE.edit({
- ctx,
- input,
- });
- }),
-
- delete: webhookProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => {
- if (!UNSTABLE_HANDLER_CACHE.delete) {
- UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler);
- }
-
- // Unreachable code but required for type safety
- if (!UNSTABLE_HANDLER_CACHE.delete) {
- throw new Error("Failed to load handler");
- }
-
- return UNSTABLE_HANDLER_CACHE.delete({
- ctx,
- input,
- });
- }),
-
- testTrigger: webhookProcedure.input(ZTestTriggerInputSchema).mutation(async ({ ctx, input }) => {
- if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
- UNSTABLE_HANDLER_CACHE.testTrigger = await import("./testTrigger.handler").then(
- (mod) => mod.testTriggerHandler
- );
- }
-
- // Unreachable code but required for type safety
- if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
- throw new Error("Failed to load handler");
- }
-
- return UNSTABLE_HANDLER_CACHE.testTrigger({
- ctx,
- input,
- });
- }),
-
- getByViewer: webhookProcedure.query(async ({ ctx }) => {
- if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
- UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
- (mod) => mod.getByViewerHandler
- );
- }
-
- // Unreachable code but required for type safety
- if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
- throw new Error("Failed to load handler");
- }
-
- return UNSTABLE_HANDLER_CACHE.getByViewer({
- ctx,
- });
- }),
+ ),
});
diff --git a/packages/trpc/server/routers/viewer/webhook/create.handler.ts b/packages/trpc/server/routers/viewer/webhook/create.handler.ts
index 6537cc46370309..5fbfd4db8f9b7b 100644
--- a/packages/trpc/server/routers/viewer/webhook/create.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/create.handler.ts
@@ -1,9 +1,11 @@
import { v4 } from "uuid";
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { updateTriggerForExistingBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
+import { MembershipRole } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -29,6 +31,23 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
+ if (input.teamId) {
+ const permissionService = new PermissionCheckService();
+
+ const hasPermission = await permissionService.checkPermission({
+ userId: user.id,
+ teamId: input.teamId,
+ permission: "webhook.create",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+
+ if (!hasPermission) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ });
+ }
+ }
+
// Add userId if platform, eventTypeId, and teamId are not provided
if (!input.platform && !input.eventTypeId && !input.teamId) {
webhookData.user = { connect: { id: user.id } };
diff --git a/packages/trpc/server/routers/viewer/webhook/delete.handler.ts b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts
index 07ae50fdaca868..ccc5d782ee0b06 100644
--- a/packages/trpc/server/routers/viewer/webhook/delete.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/delete.handler.ts
@@ -1,8 +1,12 @@
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { updateTriggerForExistingBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
+import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
+import { TRPCError } from "@trpc/server";
+
import type { TDeleteInputSchema } from "./delete.schema";
type DeleteOptions = {
@@ -21,6 +25,21 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
if (input.eventTypeId) {
where.AND.push({ eventTypeId: input.eventTypeId });
} else if (input.teamId) {
+ const permissionService = new PermissionCheckService();
+
+ const hasPermission = await permissionService.checkPermission({
+ userId: ctx.user.id,
+ teamId: input.teamId,
+ permission: "webhook.delete",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+
+ if (!hasPermission) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ });
+ }
+
where.AND.push({ teamId: input.teamId });
} else if (ctx.user.role == "ADMIN") {
where.AND.push({ OR: [{ platform: true }, { userId: ctx.user.id }] });
diff --git a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts
index 3d9a37253bbeaf..f80e16961e813f 100644
--- a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts
@@ -1,9 +1,11 @@
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import {
updateTriggerForExistingBookings,
deleteWebhookScheduledTriggers,
cancelNoShowTasksForBooking,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import { prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -37,6 +39,23 @@ export const editHandler = async ({ input, ctx }: EditOptions) => {
}
}
+ if (webhook.teamId) {
+ const permissionService = new PermissionCheckService();
+
+ const hasPermission = await permissionService.checkPermission({
+ userId: ctx.user.id,
+ teamId: webhook.teamId,
+ permission: "webhook.update",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+
+ if (!hasPermission) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ });
+ }
+ }
+
const updatedWebhook = await prisma.webhook.update({
where: {
id,
diff --git a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
index 35aa64a8205763..785abbde4fcce0 100644
--- a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
@@ -33,7 +33,9 @@ export type WebhooksByViewer = {
};
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
- return await WebhookRepository.getAllWebhooksByUserId({
+ // Use the new PBAC-aware method for fetching webhooks
+
+ return await WebhookRepository.getFilteredWebhooksForUser({
userId: ctx.user.id,
userRole: ctx.user.role,
});
diff --git a/packages/trpc/server/routers/viewer/webhook/list.handler.ts b/packages/trpc/server/routers/viewer/webhook/list.handler.ts
index 051d25778dd5dc..89c500d78ade43 100644
--- a/packages/trpc/server/routers/viewer/webhook/list.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/list.handler.ts
@@ -1,5 +1,7 @@
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
+import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TListInputSchema } from "./list.schema";
@@ -48,8 +50,26 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
where.AND?.push({ eventTypeId: input.eventTypeId });
}
} else {
+ const permissionService = new PermissionCheckService();
+ const teamIds = user?.teams?.map((m) => m.teamId) ?? [];
+ const allowedTeamIds = (
+ await Promise.all(
+ teamIds.map(async (teamId) => {
+ const ok = await permissionService.checkPermission({
+ userId: ctx.user.id,
+ teamId,
+ permission: "webhook.read",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+ return ok ? teamId : null;
+ })
+ )
+ ).filter((x): x is number => x !== null);
+
+ console.log("Allowed Team IDs:", allowedTeamIds);
+
where.AND?.push({
- OR: [{ userId: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
+ OR: [{ userId: ctx.user.id }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
});
}
diff --git a/packages/trpc/server/routers/viewer/webhook/util.test.ts b/packages/trpc/server/routers/viewer/webhook/util.test.ts
new file mode 100644
index 00000000000000..f3261d031f68bf
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/webhook/util.test.ts
@@ -0,0 +1,500 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
+import { prisma } from "@calcom/prisma";
+import type { MembershipRole } from "@calcom/prisma/enums";
+
+import { TRPCError } from "@trpc/server";
+
+import authedProcedure from "../../../procedures/authedProcedure";
+// Import after mocks are set up
+import { createWebhookPbacProcedure, webhookProcedure } from "./util";
+
+// Mock dependencies - use factory functions to avoid hoisting issues
+vi.mock("@calcom/prisma", () => ({
+ prisma: {
+ webhook: {
+ findUnique: vi.fn(),
+ },
+ eventType: {
+ findUnique: vi.fn(),
+ },
+ user: {
+ findUnique: vi.fn(),
+ },
+ },
+}));
+
+const mockCheckPermission = vi.fn();
+
+vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
+ PermissionCheckService: vi.fn().mockImplementation(() => ({
+ checkPermission: mockCheckPermission,
+ })),
+}));
+
+vi.mock("../../../procedures/authedProcedure", () => ({
+ default: {
+ input: vi.fn().mockReturnThis(),
+ use: vi.fn(),
+ },
+}));
+
+// Cast the mocked items to properly typed versions
+const mockPrisma = prisma as any;
+const mockAuthedProcedure = authedProcedure as any;
+
+describe("Webhook PBAC Procedures", () => {
+ const mockCtx = {
+ user: {
+ id: 1,
+ },
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset the mock to ensure clean state
+ mockCheckPermission.mockReset();
+ mockPrisma.webhook.findUnique.mockReset();
+ mockPrisma.eventType.findUnique.mockReset();
+ mockPrisma.user.findUnique.mockReset();
+ mockAuthedProcedure.use.mockClear();
+ });
+
+ describe("createWebhookPbacProcedure", () => {
+ const testPermission: PermissionString = "webhook.update";
+ const fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"];
+
+ it("should create a procedure with the specified permission", () => {
+ const procedure = createWebhookPbacProcedure(testPermission, fallbackRoles);
+
+ // Verify that authedProcedure methods were called
+ expect(mockAuthedProcedure.input).toHaveBeenCalled();
+ expect(mockAuthedProcedure.use).toHaveBeenCalled();
+ });
+
+ describe("middleware behavior", () => {
+ let middleware: any;
+
+ beforeEach(() => {
+ createWebhookPbacProcedure(testPermission, fallbackRoles);
+ // Get the middleware function that was passed to .use()
+ middleware = mockAuthedProcedure.use.mock.calls[0][0];
+ });
+
+ describe("when webhook ID is provided", () => {
+ it("should allow access when user has PBAC permission for team webhook", async () => {
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: 10,
+ userId: null,
+ eventTypeId: null,
+ user: null,
+ team: { id: 10 },
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+ mockCheckPermission.mockResolvedValue(true);
+
+ const next = vi.fn().mockResolvedValue("success");
+ const result = await middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1", teamId: 10 },
+ next,
+ });
+
+ expect(result).toBe("success");
+ expect(mockCheckPermission).toHaveBeenCalledWith({
+ userId: 1,
+ teamId: 10,
+ permission: testPermission,
+ fallbackRoles,
+ });
+ });
+
+ it("should throw FORBIDDEN when user lacks PBAC permission for team webhook", async () => {
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: 10,
+ userId: null,
+ eventTypeId: null,
+ user: null,
+ team: { id: 10 },
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+ mockCheckPermission.mockResolvedValue(false);
+
+ const next = vi.fn();
+
+ await expect(
+ middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1", teamId: 10 },
+ next,
+ })
+ ).rejects.toThrow(
+ new TRPCError({
+ code: "FORBIDDEN",
+ message: `Permission required: ${testPermission}`,
+ })
+ );
+ });
+
+ it("should allow access for personal webhook when user is the owner", async () => {
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: null,
+ userId: 1,
+ eventTypeId: null,
+ user: { id: 1 },
+ team: null,
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+
+ const next = vi.fn().mockResolvedValue("success");
+ const result = await middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1" },
+ next,
+ });
+
+ expect(result).toBe("success");
+ expect(mockCheckPermission).not.toHaveBeenCalled();
+ });
+
+ it("should throw FORBIDDEN for personal webhook when user is not the owner", async () => {
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: null,
+ userId: 2,
+ eventTypeId: null,
+ user: { id: 2 },
+ team: null,
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+
+ const next = vi.fn();
+
+ await expect(
+ middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1" },
+ next,
+ })
+ ).rejects.toThrow(
+ new TRPCError({
+ code: "FORBIDDEN",
+ message: `Permission required: ${testPermission}`,
+ })
+ );
+ });
+
+ it("should check team permissions for team event type webhook", async () => {
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: null,
+ userId: null,
+ eventTypeId: 100,
+ user: null,
+ team: null,
+ eventType: { id: 100, teamId: 10, userId: 2 },
+ };
+
+ const mockEventType = {
+ id: 100,
+ teamId: 10,
+ userId: 2,
+ team: { id: 10 },
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+ mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
+ mockCheckPermission.mockResolvedValue(true);
+
+ const next = vi.fn().mockResolvedValue("success");
+ const result = await middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1", eventTypeId: 100 },
+ next,
+ });
+
+ expect(result).toBe("success");
+ expect(mockCheckPermission).toHaveBeenCalledWith({
+ userId: 1,
+ teamId: 10,
+ permission: testPermission,
+ fallbackRoles,
+ });
+ });
+
+ it("should throw NOT_FOUND when webhook doesn't exist", async () => {
+ mockPrisma.webhook.findUnique.mockResolvedValue(null);
+
+ const next = vi.fn();
+
+ await expect(
+ middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1" },
+ next,
+ })
+ ).rejects.toThrow(new TRPCError({ code: "NOT_FOUND" }));
+ });
+
+ it("should throw UNAUTHORIZED when teamId doesn't match webhook teamId", async () => {
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: 10,
+ userId: null,
+ eventTypeId: null,
+ user: null,
+ team: { id: 10 },
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+
+ const next = vi.fn();
+
+ await expect(
+ middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1", teamId: 20 },
+ next,
+ })
+ ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
+ });
+ });
+
+ describe("when creating new webhook (no ID provided)", () => {
+ it("should allow creation with team PBAC permission", async () => {
+ mockCheckPermission.mockResolvedValue(true);
+
+ const next = vi.fn().mockResolvedValue("success");
+ const result = await middleware({
+ ctx: mockCtx,
+ input: { teamId: 10 },
+ next,
+ });
+
+ expect(result).toBe("success");
+ expect(mockCheckPermission).toHaveBeenCalledWith({
+ userId: 1,
+ teamId: 10,
+ permission: testPermission,
+ fallbackRoles,
+ });
+ });
+
+ it("should throw FORBIDDEN when lacking team PBAC permission", async () => {
+ mockCheckPermission.mockResolvedValue(false);
+
+ const next = vi.fn();
+
+ await expect(
+ middleware({
+ ctx: mockCtx,
+ input: { teamId: 10 },
+ next,
+ })
+ ).rejects.toThrow(
+ new TRPCError({
+ code: "FORBIDDEN",
+ message: `Permission required: ${testPermission}`,
+ })
+ );
+ });
+
+ it("should check team permissions for team event type creation", async () => {
+ const mockEventType = {
+ id: 100,
+ teamId: 10,
+ userId: 2,
+ team: { id: 10 },
+ };
+
+ mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
+ mockCheckPermission.mockResolvedValue(true);
+
+ const next = vi.fn().mockResolvedValue("success");
+ const result = await middleware({
+ ctx: mockCtx,
+ input: { eventTypeId: 100 },
+ next,
+ });
+
+ expect(result).toBe("success");
+ expect(mockCheckPermission).toHaveBeenCalledWith({
+ userId: 1,
+ teamId: 10,
+ permission: testPermission,
+ fallbackRoles,
+ });
+ });
+
+ it("should allow personal event type webhook creation for owner", async () => {
+ const mockEventType = {
+ id: 100,
+ teamId: null,
+ userId: 1,
+ team: null,
+ };
+
+ mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
+
+ const next = vi.fn().mockResolvedValue("success");
+ const result = await middleware({
+ ctx: mockCtx,
+ input: { eventTypeId: 100 },
+ next,
+ });
+
+ expect(result).toBe("success");
+ expect(mockCheckPermission).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when no input is provided", () => {
+ it("should call next() directly", async () => {
+ const next = vi.fn().mockResolvedValue("success");
+ const result = await middleware({
+ ctx: mockCtx,
+ input: undefined,
+ next,
+ });
+
+ expect(result).toBe("success");
+ expect(next).toHaveBeenCalled();
+ expect(mockPrisma.webhook.findUnique).not.toHaveBeenCalled();
+ expect(mockPrisma.eventType.findUnique).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe("webhookProcedure (legacy wrapper)", () => {
+ it("should work as expected", () => {
+ // The legacy webhookProcedure uses createWebhookPbacProcedure internally
+ // This is verified by the functional tests above
+ expect(createWebhookPbacProcedure).toBeDefined();
+ });
+ });
+
+ describe("Different permission scenarios", () => {
+ const permissions: { permission: PermissionString; operation: string }[] = [
+ { permission: "webhook.read", operation: "read" },
+ { permission: "webhook.create", operation: "create" },
+ { permission: "webhook.update", operation: "update" },
+ { permission: "webhook.delete", operation: "delete" },
+ ];
+
+ permissions.forEach(({ permission, operation }) => {
+ it(`should use ${permission} for ${operation} operations`, async () => {
+ createWebhookPbacProcedure(permission);
+ const middleware =
+ mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
+
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: 10,
+ userId: null,
+ eventTypeId: null,
+ user: null,
+ team: { id: 10 },
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+ mockCheckPermission.mockResolvedValue(true);
+
+ const next = vi.fn().mockResolvedValue("success");
+ await middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1" },
+ next,
+ });
+
+ expect(mockCheckPermission).toHaveBeenCalledWith({
+ userId: 1,
+ teamId: 10,
+ permission,
+ fallbackRoles: ["ADMIN", "OWNER"],
+ });
+ });
+ });
+ });
+
+ describe("Fallback role behavior", () => {
+ it("should use custom fallback roles when provided", async () => {
+ const customFallback: MembershipRole[] = ["OWNER"];
+ createWebhookPbacProcedure("webhook.delete", customFallback);
+ const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
+
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: 10,
+ userId: null,
+ eventTypeId: null,
+ user: null,
+ team: { id: 10 },
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+ mockCheckPermission.mockResolvedValue(true);
+
+ const next = vi.fn().mockResolvedValue("success");
+ await middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1" },
+ next,
+ });
+
+ expect(mockCheckPermission).toHaveBeenCalledWith({
+ userId: 1,
+ teamId: 10,
+ permission: "webhook.delete",
+ fallbackRoles: customFallback,
+ });
+ });
+
+ it("should use default fallback roles when not provided", async () => {
+ createWebhookPbacProcedure("webhook.update");
+ const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
+
+ const mockWebhook = {
+ id: "webhook-1",
+ teamId: 10,
+ userId: null,
+ eventTypeId: null,
+ user: null,
+ team: { id: 10 },
+ eventType: null,
+ };
+
+ mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
+ mockCheckPermission.mockResolvedValue(true);
+
+ const next = vi.fn().mockResolvedValue("success");
+ await middleware({
+ ctx: mockCtx,
+ input: { id: "webhook-1" },
+ next,
+ });
+
+ expect(mockCheckPermission).toHaveBeenCalledWith({
+ userId: 1,
+ teamId: 10,
+ permission: "webhook.update",
+ fallbackRoles: ["ADMIN", "OWNER"],
+ });
+ });
+ });
+});
diff --git a/packages/trpc/server/routers/viewer/webhook/util.ts b/packages/trpc/server/routers/viewer/webhook/util.ts
index 19909e92b83fb1..e576ed64659de2 100644
--- a/packages/trpc/server/routers/viewer/webhook/util.ts
+++ b/packages/trpc/server/routers/viewer/webhook/util.ts
@@ -1,140 +1,157 @@
-import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
+import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
-import type { Membership } from "@calcom/prisma/client";
+import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../../procedures/authedProcedure";
import { webhookIdAndEventTypeIdSchema } from "./types";
-export const webhookProcedure = authedProcedure
- .input(webhookIdAndEventTypeIdSchema.optional())
- .use(async ({ ctx, input, next }) => {
- // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input
+/**
+ * Creates a webhook procedure with configurable PBAC permissions
+ * @param permission - The specific permission required (e.g., "webhook.create", "webhook.update")
+ * @param fallbackRoles - Roles to check when PBAC is disabled (defaults to ["ADMIN", "OWNER"])
+ * @returns A procedure that checks the specified permission
+ */
+export const createWebhookPbacProcedure = (
+ permission: PermissionString,
+ fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"]
+) => {
+ return authedProcedure.input(webhookIdAndEventTypeIdSchema.optional()).use(async ({ ctx, input, next }) => {
+ // Endpoints that just read the logged in user's data - like 'list' don't necessarily have any input
if (!input) return next();
- const { id, teamId, eventTypeId } = input;
- const assertPartOfTeamWithRequiredAccessLevel = (memberships?: Membership[], teamId?: number) => {
- if (!memberships) return false;
- if (teamId) {
- return memberships.some(
- (membership) => membership.teamId === teamId && checkAdminOrOwner(membership.role)
- );
- }
- return memberships.some(
- (membership) => membership.userId === ctx.user.id && checkAdminOrOwner(membership.role)
- );
- };
+ const { id, teamId, eventTypeId } = input;
+ const permissionCheckService = new PermissionCheckService();
if (id) {
- //check if user is authorized to edit webhook
+ // Check if user is authorized to edit webhook
const webhook = await prisma.webhook.findUnique({
- where: {
- id: id,
- },
- include: {
- user: true,
- team: true,
- eventType: true,
+ where: { id },
+ select: {
+ id: true,
+ userId: true,
+ teamId: true,
+ eventTypeId: true,
},
});
- if (webhook) {
- if (teamId && teamId !== webhook.teamId) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- });
- }
+ if (!webhook) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
- if (eventTypeId && eventTypeId !== webhook.eventTypeId) {
+ // Validate consistency
+ if (teamId && teamId !== webhook.teamId) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ if (eventTypeId && eventTypeId !== webhook.eventTypeId) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ // For team webhooks, check PBAC permissions
+ if (webhook.teamId) {
+ const hasPermission = await permissionCheckService.checkPermission({
+ userId: ctx.user.id,
+ teamId: webhook.teamId,
+ permission,
+ fallbackRoles,
+ });
+
+ if (!hasPermission) {
throw new TRPCError({
- code: "UNAUTHORIZED",
+ code: "FORBIDDEN",
+ message: `Permission required: ${permission}`,
});
}
+ } else if (webhook.eventTypeId) {
+ // For event type webhooks, check if the user owns the event type or has team permissions
+ const eventType = await prisma.eventType.findUnique({
+ where: { id: webhook.eventTypeId },
+ include: { team: true },
+ });
- if (webhook.teamId) {
- const user = await prisma.user.findUnique({
- where: {
- id: ctx.user.id,
- },
- include: {
- teams: true,
- },
- });
-
- const userHasAdminOwnerPermissionInTeam =
- user &&
- user.teams.some(
- (membership) => membership.teamId === webhook.teamId && checkAdminOrOwner(membership.role)
- );
+ if (!eventType) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
- if (!userHasAdminOwnerPermissionInTeam) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
+ if (eventType.userId !== ctx.user.id) {
+ // Check team permissions if it's a team event type
+ if (eventType.teamId) {
+ const hasPermission = await permissionCheckService.checkPermission({
+ userId: ctx.user.id,
+ teamId: eventType.teamId,
+ permission,
+ fallbackRoles,
});
- }
- } else if (webhook.eventTypeId) {
- const eventType = await prisma.eventType.findUnique({
- where: {
- id: webhook.eventTypeId,
- },
- include: {
- team: {
- include: {
- members: true,
- },
- },
- },
- });
- if (eventType && eventType.userId !== ctx.user.id) {
- if (!assertPartOfTeamWithRequiredAccessLevel(eventType.team?.members)) {
+ if (!hasPermission) {
throw new TRPCError({
- code: "UNAUTHORIZED",
+ code: "FORBIDDEN",
+ message: `Permission required: ${permission}`,
});
}
+ } else {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: `Permission required: ${permission}`,
+ });
}
- } else if (webhook.userId && webhook.userId !== ctx.user.id) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- });
}
+ } else if (webhook.userId && webhook.userId !== ctx.user.id) {
+ // For personal webhooks, only the owner can manage
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: `Permission required: ${permission}`,
+ });
}
} else {
- //check if user is authorized to create webhook on event type or team
+ // Check if user is authorized to create webhook on event type or team
if (teamId) {
- const user = await prisma.user.findUnique({
- where: {
- id: ctx.user.id,
- },
- include: {
- teams: true,
- },
+ const hasPermission = await permissionCheckService.checkPermission({
+ userId: ctx.user.id,
+ teamId,
+ permission,
+ fallbackRoles,
});
- if (!assertPartOfTeamWithRequiredAccessLevel(user?.teams, teamId)) {
+ if (!hasPermission) {
throw new TRPCError({
- code: "UNAUTHORIZED",
+ code: "FORBIDDEN",
+ message: `Permission required: ${permission}`,
});
}
} else if (eventTypeId) {
const eventType = await prisma.eventType.findUnique({
- where: {
- id: eventTypeId,
- },
- include: {
- team: {
- include: {
- members: true,
- },
- },
- },
+ where: { id: eventTypeId },
+ include: { team: true },
});
- if (eventType && eventType.userId !== ctx.user.id) {
- if (!assertPartOfTeamWithRequiredAccessLevel(eventType.team?.members)) {
+ if (!eventType) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ if (eventType.userId !== ctx.user.id) {
+ // Check team permissions if it's a team event type
+ if (eventType.teamId) {
+ const hasPermission = await permissionCheckService.checkPermission({
+ userId: ctx.user.id,
+ teamId: eventType.teamId,
+ permission,
+ fallbackRoles,
+ });
+
+ if (!hasPermission) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: `Permission required: ${permission}`,
+ });
+ }
+ } else {
throw new TRPCError({
- code: "UNAUTHORIZED",
+ code: "FORBIDDEN",
+ message: `Permission required: ${permission}`,
});
}
}
@@ -143,3 +160,10 @@ export const webhookProcedure = authedProcedure
return next();
});
+};
+
+/**
+ * Legacy webhook procedure - uses the new PBAC procedure with webhook.update permission
+ * This maintains backward compatibility while supporting PBAC
+ */
+export const webhookProcedure = createWebhookPbacProcedure("webhook.update", ["ADMIN", "OWNER"]);