Skip to content

Commit a44a3e5

Browse files
feat: add Webhook resource to PBAC system with permission enforcement (calcom#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 calcom#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>
1 parent 98a075d commit a44a3e5

18 files changed

Lines changed: 1037 additions & 335 deletions

File tree

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
66
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
77
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
88
import { APP_NAME } from "@calcom/lib/constants";
9-
import { UserPermissionRole } from "@calcom/prisma/enums";
109
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
1110

1211
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -26,11 +25,10 @@ const WebhooksViewServerWrapper = async () => {
2625
redirect("/auth/login");
2726
}
2827

29-
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
3028
const caller = await createRouterCaller(webhookRouter);
3129
const data = await caller.getByViewer();
3230

33-
return <WebhooksView data={data} isAdmin={isAdmin} />;
31+
return <WebhooksView data={data} />;
3432
};
3533

3634
export default WebhooksViewServerWrapper;

apps/web/public/static/locales/en/common.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3498,6 +3498,11 @@
34983498
"pbac_desc_view_workflows": "View existing workflows and their configurations",
34993499
"pbac_desc_update_workflows": "Edit and modify workflow settings",
35003500
"pbac_desc_delete_workflows": "Remove workflows from the system",
3501+
"pbac_resource_webhook": "Webhook",
3502+
"pbac_desc_create_webhooks": "Create webhooks",
3503+
"pbac_desc_view_webhooks": "View webhooks",
3504+
"pbac_desc_update_webhooks": "Update webhooks",
3505+
"pbac_desc_delete_webhooks": "Delete webhooks",
35013506
"pbac_desc_manage_workflows": "Full management access to all workflows",
35023507
"pbac_desc_create_event_types": "Create event types",
35033508
"pbac_desc_view_event_types": "View event types",

packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,11 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
365365
setEditModalOpen(true);
366366
setWebhookToEdit(webhook);
367367
}}
368+
// TODO (SEAN): Implement Permissions here when we have event-types PR merged
369+
permissions={{
370+
canEditWebhook: !webhookLockedStatus.disabled,
371+
canDeleteWebhook: !webhookLockedStatus.disabled,
372+
}}
368373
/>
369374
);
370375
})}

packages/features/pbac/domain/types/permission-registry.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export enum Resource {
99
Role = "role",
1010
RoutingForm = "routingForm",
1111
Workflow = "workflow",
12+
Webhook = "webhook",
1213
}
1314

1415
export enum CrudAction {
@@ -516,4 +517,36 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
516517
dependsOn: ["routingForm.read"],
517518
},
518519
},
520+
[Resource.Webhook]: {
521+
_resource: {
522+
i18nKey: "pbac_resource_webhook",
523+
},
524+
[CrudAction.Create]: {
525+
description: "Create webhooks",
526+
category: "webhook",
527+
i18nKey: "pbac_action_create",
528+
descriptionI18nKey: "pbac_desc_create_webhooks",
529+
dependsOn: ["webhook.read"],
530+
},
531+
[CrudAction.Read]: {
532+
description: "View webhooks",
533+
category: "webhook",
534+
i18nKey: "pbac_action_read",
535+
descriptionI18nKey: "pbac_desc_view_webhooks",
536+
},
537+
[CrudAction.Update]: {
538+
description: "Update webhooks",
539+
category: "webhook",
540+
i18nKey: "pbac_action_update",
541+
descriptionI18nKey: "pbac_desc_update_webhooks",
542+
dependsOn: ["webhook.read"],
543+
},
544+
[CrudAction.Delete]: {
545+
description: "Delete webhooks",
546+
category: "webhook",
547+
i18nKey: "pbac_action_delete",
548+
descriptionI18nKey: "pbac_desc_delete_webhooks",
549+
dependsOn: ["webhook.read"],
550+
},
551+
},
519552
};

packages/features/webhooks/components/CreateNewWebhookButton.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { useRouter } from "next/navigation";
44

55
import { CreateButtonWithTeamsList } from "@calcom/features/ee/teams/components/createButton/CreateButtonWithTeamsList";
66
import { useLocale } from "@calcom/lib/hooks/useLocale";
7+
import { MembershipRole } from "@calcom/prisma/enums";
78

8-
export const CreateNewWebhookButton = ({ isAdmin }: { isAdmin: boolean }) => {
9+
export const CreateNewWebhookButton = () => {
910
const router = useRouter();
1011
const { t } = useLocale();
1112
const createFunction = (teamId?: number, platform?: boolean) => {
@@ -20,10 +21,13 @@ export const CreateNewWebhookButton = ({ isAdmin }: { isAdmin: boolean }) => {
2021
<CreateButtonWithTeamsList
2122
color="secondary"
2223
subtitle={t("create_for").toUpperCase()}
23-
isAdmin={isAdmin}
2424
createFunction={createFunction}
2525
data-testid="new_webhook"
2626
includeOrg={true}
27+
withPermission={{
28+
permission: "webhook.create",
29+
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
30+
}}
2731
/>
2832
);
2933
};

packages/features/webhooks/components/WebhookListItem.tsx

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ export default function WebhookListItem(props: {
3636
canEditWebhook?: boolean;
3737
onEditWebhook: () => void;
3838
lastItem: boolean;
39-
readOnly?: boolean;
39+
permissions: {
40+
canEditWebhook?: boolean;
41+
canDeleteWebhook?: boolean;
42+
};
4043
}) {
4144
const { t } = useLocale();
4245
const utils = trpc.useUtils();
4346
const { webhook } = props;
44-
const canEditWebhook = props.canEditWebhook ?? true;
4547

4648
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
4749
async onSuccess() {
@@ -87,7 +89,7 @@ export default function WebhookListItem(props: {
8789
{webhook.subscriberUrl}
8890
</p>
8991
</Tooltip>
90-
{!!props.readOnly && (
92+
{!props.permissions.canEditWebhook && (
9193
<Badge variant="gray" className="ml-2 ">
9294
{t("readonly")}
9395
</Badge>
@@ -107,12 +109,12 @@ export default function WebhookListItem(props: {
107109
</div>
108110
</Tooltip>
109111
</div>
110-
{!props.readOnly && (
112+
{(props.permissions.canEditWebhook || props.permissions.canDeleteWebhook) && (
111113
<div className="ml-2 flex items-center space-x-4">
112114
<Switch
113115
defaultChecked={webhook.active}
114116
data-testid="webhook-switch"
115-
disabled={!canEditWebhook}
117+
disabled={!props.permissions.canEditWebhook}
116118
onCheckedChange={() =>
117119
toggleWebhook.mutate({
118120
id: webhook.id,
@@ -123,39 +125,48 @@ export default function WebhookListItem(props: {
123125
}
124126
/>
125127

126-
<Button
127-
className="hidden lg:flex"
128-
color="secondary"
129-
onClick={props.onEditWebhook}
130-
data-testid="webhook-edit-button">
131-
{t("edit")}
132-
</Button>
128+
{props.permissions.canEditWebhook && (
129+
<Button
130+
className="hidden lg:flex"
131+
color="secondary"
132+
onClick={props.onEditWebhook}
133+
data-testid="webhook-edit-button">
134+
{t("edit")}
135+
</Button>
136+
)}
133137

134-
<Button
135-
className="hidden lg:flex"
136-
color="destructive"
137-
StartIcon="trash"
138-
variant="icon"
139-
onClick={onDeleteWebhook}
140-
/>
138+
{props.permissions.canDeleteWebhook && (
139+
<Button
140+
className="hidden lg:flex"
141+
color="destructive"
142+
StartIcon="trash"
143+
variant="icon"
144+
onClick={onDeleteWebhook}
145+
/>
146+
)}
141147

142148
<Dropdown>
143149
<DropdownMenuTrigger asChild>
144150
<Button className="lg:hidden" StartIcon="ellipsis" variant="icon" color="secondary" />
145151
</DropdownMenuTrigger>
146152
<DropdownMenuContent>
147-
<DropdownMenuItem>
148-
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
149-
{t("edit")}
150-
</DropdownItem>
151-
</DropdownMenuItem>
153+
{props.permissions.canEditWebhook && (
154+
<DropdownMenuItem>
155+
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
156+
{t("edit")}
157+
</DropdownItem>
158+
</DropdownMenuItem>
159+
)}
160+
152161
<DropdownMenuSeparator />
153162

154-
<DropdownMenuItem>
155-
<DropdownItem StartIcon="trash" color="destructive" onClick={onDeleteWebhook}>
156-
{t("delete")}
157-
</DropdownItem>
158-
</DropdownMenuItem>
163+
{props.permissions.canDeleteWebhook && (
164+
<DropdownMenuItem>
165+
<DropdownItem StartIcon="trash" color="destructive" onClick={onDeleteWebhook}>
166+
{t("delete")}
167+
</DropdownItem>
168+
</DropdownMenuItem>
169+
)}
159170
</DropdownMenuContent>
160171
</Dropdown>
161172
</div>

packages/features/webhooks/pages/webhooks-view.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,27 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
77
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
88
import { useLocale } from "@calcom/lib/hooks/useLocale";
99
import type { RouterOutputs } from "@calcom/trpc/react";
10-
import type { WebhooksByViewer } from "@calcom/trpc/server/routers/viewer/webhook/getByViewer.handler";
1110
import classNames from "@calcom/ui/classNames";
1211
import { Avatar } from "@calcom/ui/components/avatar";
1312
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
1413

1514
import { WebhookListItem, CreateNewWebhookButton } from "../components";
1615

16+
type WebhooksByViewer = RouterOutputs["viewer"]["webhook"]["getByViewer"];
17+
1718
type Props = {
18-
data: RouterOutputs["viewer"]["webhook"]["getByViewer"];
19-
isAdmin: boolean;
19+
data: WebhooksByViewer;
2020
};
2121

22-
const WebhooksView = ({ data, isAdmin }: Props) => {
22+
const WebhooksView = ({ data }: Props) => {
2323
return (
2424
<div>
25-
<WebhooksList webhooksByViewer={data} isAdmin={isAdmin} />
25+
<WebhooksList webhooksByViewer={data} />
2626
</div>
2727
);
2828
};
2929

30-
const WebhooksList = ({
31-
webhooksByViewer,
32-
isAdmin,
33-
}: {
34-
webhooksByViewer: WebhooksByViewer;
35-
isAdmin: boolean;
36-
}) => {
30+
const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer }) => {
3731
const { t } = useLocale();
3832
const router = useRouter();
3933
const { profiles, webhookGroups } = webhooksByViewer;
@@ -45,7 +39,7 @@ const WebhooksList = ({
4539
<SettingsHeader
4640
title={t("webhooks")}
4741
description={t("add_webhook_description", { appName: APP_NAME })}
48-
CTA={webhooksByViewer.webhookGroups.length > 0 ? <CreateNewWebhookButton isAdmin={isAdmin} /> : null}
42+
CTA={webhooksByViewer.webhookGroups.length > 0 ? <CreateNewWebhookButton /> : null}
4943
borderInShellHeader={false}>
5044
{!!webhookGroups.length ? (
5145
<div className={classNames("mt-6")}>
@@ -70,8 +64,11 @@ const WebhooksList = ({
7064
<WebhookListItem
7165
key={webhook.id}
7266
webhook={webhook}
73-
readOnly={group.metadata?.readOnly ?? false}
7467
lastItem={group.webhooks.length === index + 1}
68+
permissions={{
69+
canEditWebhook: group?.metadata?.canModify ?? false,
70+
canDeleteWebhook: group?.metadata?.canDelete ?? false,
71+
}}
7572
onEditWebhook={() =>
7673
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id}`)
7774
}
@@ -88,7 +85,7 @@ const WebhooksList = ({
8885
headline={t("create_your_first_webhook")}
8986
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
9087
className="mt-6 rounded-b-lg"
91-
buttonRaw={<CreateNewWebhookButton isAdmin={isAdmin} />}
88+
buttonRaw={<CreateNewWebhookButton />}
9289
border={true}
9390
/>
9491
)}

0 commit comments

Comments
 (0)