feat: add Webhook resource to PBAC system with permission enforcement (#23614)

* feat: add Webhook resource to PBAC system with permission enforcement

- Add Webhook resource to PBAC permission registry with CRUD actions
- Implement PBAC permission checks in webhook handlers (create, edit, delete)
- Add webhook permission translations to common.json
- Use PermissionCheckService with fallback roles [ADMIN, OWNER] for team webhooks
- Maintain backward compatibility when PBAC is disabled
- Follow same pattern as workflow PBAC implementation from PR #22845

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: implement PBAC permission filtering in webhook list handler

- Add PermissionCheckService to filter team webhooks by webhook.read permission
- Only show webhooks from teams where user has proper permissions
- Maintain backward compatibility with fallback to all team memberships

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* add migration for default roles

* new forUserMethod

* update webhook repository

* fix UI showing/hiding webhooks for webhoo.create teams

* WIP pbac procedure migratoin + tests

* add more roles to get fallback

* permissions in cmponents instead of readOnly

* passPermissions to list item

* push instant events logic

* Git merge

* wip teamId accessable refactor

* fix delete handler

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2025-09-18 07:37:45 -03:00
committed by GitHub
co-authored by sean@cal.com Devin AI
parent 98a075d062
commit a44a3e5b84
18 changed files with 1111 additions and 409 deletions
@@ -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 <WebhooksView data={data} isAdmin={isAdmin} />;
return <WebhooksView data={data} />;
};
export default WebhooksViewServerWrapper;
@@ -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",
@@ -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,
}}
/>
);
})}
@@ -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"],
},
},
};
@@ -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 }) => {
<CreateButtonWithTeamsList
color="secondary"
subtitle={t("create_for").toUpperCase()}
isAdmin={isAdmin}
createFunction={createFunction}
data-testid="new_webhook"
includeOrg={true}
withPermission={{
permission: "webhook.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}}
/>
);
};
@@ -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}
</p>
</Tooltip>
{!!props.readOnly && (
{!props.permissions.canEditWebhook && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
@@ -107,12 +109,12 @@ export default function WebhookListItem(props: {
</div>
</Tooltip>
</div>
{!props.readOnly && (
{(props.permissions.canEditWebhook || props.permissions.canDeleteWebhook) && (
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
data-testid="webhook-switch"
disabled={!canEditWebhook}
disabled={!props.permissions.canEditWebhook}
onCheckedChange={() =>
toggleWebhook.mutate({
id: webhook.id,
@@ -123,39 +125,48 @@ export default function WebhookListItem(props: {
}
/>
<Button
className="hidden lg:flex"
color="secondary"
onClick={props.onEditWebhook}
data-testid="webhook-edit-button">
{t("edit")}
</Button>
{props.permissions.canEditWebhook && (
<Button
className="hidden lg:flex"
color="secondary"
onClick={props.onEditWebhook}
data-testid="webhook-edit-button">
{t("edit")}
</Button>
)}
<Button
className="hidden lg:flex"
color="destructive"
StartIcon="trash"
variant="icon"
onClick={onDeleteWebhook}
/>
{props.permissions.canDeleteWebhook && (
<Button
className="hidden lg:flex"
color="destructive"
StartIcon="trash"
variant="icon"
onClick={onDeleteWebhook}
/>
)}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="lg:hidden" StartIcon="ellipsis" variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
{props.permissions.canEditWebhook && (
<DropdownMenuItem>
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem StartIcon="trash" color="destructive" onClick={onDeleteWebhook}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
{props.permissions.canDeleteWebhook && (
<DropdownMenuItem>
<DropdownItem StartIcon="trash" color="destructive" onClick={onDeleteWebhook}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
@@ -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 (
<div>
<WebhooksList webhooksByViewer={data} isAdmin={isAdmin} />
<WebhooksList webhooksByViewer={data} />
</div>
);
};
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 = ({
<SettingsHeader
title={t("webhooks")}
description={t("add_webhook_description", { appName: APP_NAME })}
CTA={webhooksByViewer.webhookGroups.length > 0 ? <CreateNewWebhookButton isAdmin={isAdmin} /> : null}
CTA={webhooksByViewer.webhookGroups.length > 0 ? <CreateNewWebhookButton /> : null}
borderInShellHeader={false}>
{!!webhookGroups.length ? (
<div className={classNames("mt-6")}>
@@ -70,8 +64,11 @@ const WebhooksList = ({
<WebhookListItem
key={webhook.id}
webhook={webhook}
readOnly={group.metadata?.readOnly ?? false}
lastItem={group.webhooks.length === index + 1}
permissions={{
canEditWebhook: group?.metadata?.canModify ?? false,
canDeleteWebhook: group?.metadata?.canDelete ?? false,
}}
onEditWebhook={() =>
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={<CreateNewWebhookButton isAdmin={isAdmin} />}
buttonRaw={<CreateNewWebhookButton />}
border={true}
/>
)}
+143 -132
View File
@@ -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,136 +31,6 @@ const filterWebhooks = (webhook: Webhook) => {
};
export class WebhookRepository {
static async getAllWebhooksByUserId({
userId,
userRole,
}: {
userId: number;
userRole?: UserPermissionRole;
}) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
username: true,
avatarUrl: true,
name: true,
webhooks: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
isOrganization: true,
name: true,
slug: true,
parentId: true,
metadata: true,
members: {
select: {
userId: true,
},
},
webhooks: true,
logoUrl: true,
},
},
},
},
},
});
if (!user) {
throw new Error("User not found");
}
let userWebhooks = user.webhooks;
userWebhooks = userWebhooks.filter(filterWebhooks);
let webhookGroups: WebhookGroup[] = [];
webhookGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
},
webhooks: userWebhooks,
metadata: {
readOnly: false,
},
});
const teamMemberships = user.teams.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
}));
const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug
? !membership.team.parentId
? `/team`
: `${membership.team.slug}`
: null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
metadata: {
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
};
});
webhookGroups = webhookGroups.concat(teamWebhookGroups);
if (userRole === "ADMIN") {
const platformWebhooks = await prisma.webhook.findMany({
where: { platform: true },
});
webhookGroups.push({
teamId: null,
profile: {
slug: "Platform",
name: "Platform",
image: getPlaceholderAvatar(null, "Platform"),
},
webhooks: platformWebhooks,
metadata: {
readOnly: false,
},
});
}
return {
webhookGroups: webhookGroups.filter((groupBy) => !!groupBy.webhooks?.length),
profiles: webhookGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
}
static async findByWebhookId(webhookId?: string) {
return await prisma.webhook.findUniqueOrThrow({
where: {
@@ -180,4 +51,144 @@ export class WebhookRepository {
},
});
}
static async getFilteredWebhooksForUser({
userId,
userRole,
}: {
userId: number;
userRole?: UserPermissionRole;
}) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
name: true,
avatarUrl: true,
webhooks: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
webhooks: true,
},
},
},
},
},
});
if (!user) {
throw new Error("User not found");
}
// 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 }),
},
webhooks: user.webhooks.filter(filterWebhooks),
metadata: {
canModify: true,
canDelete: true,
},
});
// 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;
}
// 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 || null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
metadata: {
canModify: canUpdate,
canDelete,
},
});
}
// Add platform webhooks for admins
if (userRole === "ADMIN") {
const platformWebhooks = await prisma.webhook.findMany({
where: { platform: true },
});
webhookGroups.push({
teamId: null,
profile: {
slug: "Platform",
name: "Platform",
image: getPlaceholderAvatar(null, "Platform"),
},
webhooks: platformWebhooks,
metadata: {
canDelete: true,
canModify: true,
},
});
}
return {
webhookGroups: webhookGroups.filter((group) => group.webhooks.length > 0),
profiles: webhookGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
}
}
@@ -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);
@@ -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 [
@@ -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,
});
}),
),
});
@@ -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 } };
@@ -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 }] });
@@ -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,
@@ -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,
});
@@ -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 } }] : [])],
});
}
@@ -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"],
});
});
});
});
@@ -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 (eventTypeId && eventTypeId !== webhook.eventTypeId) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
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 (!userHasAdminOwnerPermissionInTeam) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
} 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)) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
} else if (webhook.userId && webhook.userId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
if (!webhook) {
throw new TRPCError({ code: "NOT_FOUND" });
}
} else {
//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,
},
// 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 (!assertPartOfTeamWithRequiredAccessLevel(user?.teams, teamId)) {
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 (!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: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
}
} 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
if (teamId) {
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId,
permission,
fallbackRoles,
});
if (!hasPermission) {
throw new TRPCError({
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"]);