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:
co-authored by
sean@cal.com
Devin AI
parent
98a075d062
commit
a44a3e5b84
+1
-3
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user