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 && ( {t("readonly")} @@ -107,12 +109,12 @@ export default function WebhookListItem(props: { - {!props.readOnly && ( + {(props.permissions.canEditWebhook || props.permissions.canDeleteWebhook) && (
toggleWebhook.mutate({ id: webhook.id, @@ -123,39 +125,48 @@ export default function WebhookListItem(props: { } /> - + {props.permissions.canEditWebhook && ( + + )} -
diff --git a/packages/features/webhooks/pages/webhooks-view.tsx b/packages/features/webhooks/pages/webhooks-view.tsx index 981470085c2723..edfa2350e58fa8 100644 --- a/packages/features/webhooks/pages/webhooks-view.tsx +++ b/packages/features/webhooks/pages/webhooks-view.tsx @@ -7,33 +7,27 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; -import type { WebhooksByViewer } from "@calcom/trpc/server/routers/viewer/webhook/getByViewer.handler"; import classNames from "@calcom/ui/classNames"; import { Avatar } from "@calcom/ui/components/avatar"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { WebhookListItem, CreateNewWebhookButton } from "../components"; +type WebhooksByViewer = RouterOutputs["viewer"]["webhook"]["getByViewer"]; + type Props = { - data: RouterOutputs["viewer"]["webhook"]["getByViewer"]; - isAdmin: boolean; + data: WebhooksByViewer; }; -const WebhooksView = ({ data, isAdmin }: Props) => { +const WebhooksView = ({ data }: Props) => { return (
- +
); }; -const WebhooksList = ({ - webhooksByViewer, - isAdmin, -}: { - webhooksByViewer: WebhooksByViewer; - isAdmin: boolean; -}) => { +const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer }) => { const { t } = useLocale(); const router = useRouter(); const { profiles, webhookGroups } = webhooksByViewer; @@ -45,7 +39,7 @@ const WebhooksList = ({ 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"]);