Skip to content

Comments

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

Merged
keithwillcode merged 19 commits intomainfrom
devin/pbac-webhooks-1757061104
Sep 18, 2025
Merged

feat: add Webhook resource to PBAC system with permission enforcement#23614
keithwillcode merged 19 commits intomainfrom
devin/pbac-webhooks-1757061104

Conversation

@sean-brydon
Copy link
Member

@sean-brydon sean-brydon commented Sep 5, 2025

What does this PR do?

Video Demo: https://cap.so/s/a9r9agv8yvqgt7z

This PR implements Permission-Based Access Control (PBAC) for webhooks, following the same pattern established in #22845 for workflows. The implementation adds webhook resource permissions to the PBAC system and enforces permission checks for team-level webhook operations.

Key Changes:

  • Adds Webhook resource to PBAC permission registry with CRUD actions
  • Implements PBAC permission checks in webhook handlers (create, edit, delete)
  • Adds webhook permission translations for i18n
  • Uses fallback roles [ADMIN, OWNER] for backward compatibility when PBAC is disabled

Scope: Only team-level webhooks are affected. User-level webhooks and EventType-level webhooks continue using existing authorization logic.


Requested by @sean-brydon
Link to Devin run: https://app.devin.ai/sessions/540f6ef60980462dbbc215c063b5081a

How should this be tested?

⚠️ Testing Requirements:

  • Edit packages/features/pbac/services/permission-check.service.ts to set DOGFOOD_PBAC_INTERNALLY to false
  • Set up a team with PBAC enabled
  • Create users with different roles and custom webhook permissions
  • Test webhook CRUD operations with various permission combinations
  • Verify fallback behavior when PBAC is disabled

Test scenarios:

  1. User with webhook.create permission can create team webhooks
  2. User without webhook.update permission cannot edit team webhooks
  3. User without webhook.delete permission cannot delete team webhooks
  4. Admin/Owner roles work as fallback when PBAC disabled
  5. User-level webhooks remain unaffected by team permissions

Critical Review Points

🚨 Incomplete Implementation:

  • list.handler.ts only has import added but missing actual permission filtering logic
  • No database migration included to grant webhook permissions to existing roles

🔍 Validation Needed:

  • Confirm ADMIN/OWNER as appropriate fallback roles for webhook management
  • Verify scope limitation to team webhooks only is correct
  • Check if EventType webhook authorization should also use PBAC

Mandatory Tasks

  • I have self-reviewed the code ✅
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - follows existing PBAC pattern
  • I confirm automated tests are in place that prove my fix is effective or that my feature works. ⚠️ No tests added - needs testing

Checklist

  • My code follows the style guidelines and matches the existing workflow PBAC pattern
  • Code includes proper TypeScript imports and error handling
  • Fallback roles ensure backward compatibility
  • i18n translations added for all new permission strings

- 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>
@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 5, 2025

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

This change integrates PBAC for webhooks across the stack: adds a Webhook resource with CRUD entries to the permission registry and seeds role permissions; updates locales with webhook i18n keys; replaces membership-based checks with PermissionCheckService in the webhook repository (adds findByWebhookId, changes group metadata to canModify/canDelete, filters empty groups, and simplifies selected fields); refactors TRPC webhook procedures to use createWebhookPbacProcedure and adds per-endpoint PBAC checks; updates UI to use a permissions object (removes isAdmin); and adds unit tests for PBAC middleware.

Possibly related PRs

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly describes the primary change—adding a Webhook resource to the PBAC system and enforcing its permissions—and aligns directly with the diff’s focus on webhook CRUD authorization and registry updates.
Description Check ✅ Passed The description clearly outlines the PR’s purpose, summarizing the PBAC integration for webhooks, listing key changes, showing testing instructions, and noting critical review points, all of which directly relate to the changeset.

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly describes the primary change—adding a Webhook resource to the PBAC system and enforcing its permissions—and aligns directly with the diff’s focus on webhook CRUD authorization and registry updates.
Description Check ✅ Passed The description clearly outlines the PR’s purpose, summarizing the PBAC integration for webhooks, listing key changes, showing testing instructions, and noting critical review points, all of which directly relate to the changeset.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- 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>
@pull-request-size pull-request-size bot added size/L and removed size/M labels Sep 5, 2025
@vercel
Copy link

vercel bot commented Sep 5, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
cal Ignored Ignored Sep 17, 2025 8:46am
cal-eu Ignored Ignored Sep 17, 2025 8:46am

@sean-brydon sean-brydon marked this pull request as ready for review September 8, 2025 10:49
@sean-brydon sean-brydon requested a review from a team as a code owner September 8, 2025 10:49
@github-actions
Copy link
Contributor

github-actions bot commented Sep 8, 2025

E2E results are ready!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/trpc/server/routers/viewer/webhook/list.handler.ts (1)

32-52: Add PBAC check for eventType-scoped listing to prevent cross-team leakage

When input.eventTypeId is provided, there’s no authorization. A user can pass an event type from another team and retrieve its webhooks. Gate this path: allow if (a) personal event type owned by the user, or (b) team event type where webhook.read is permitted (with ADMIN/OWNER fallback).

Apply this diff:

     if (input?.eventTypeId) {
+      const permissionService = new PermissionCheckService();
+      const evt = await prisma.eventType.findUnique({
+        where: { id: input.eventTypeId },
+        select: { teamId: true, userId: true },
+      });
+      if (evt?.teamId) {
+        const ok = await permissionService.checkPermission({
+          userId: ctx.user.id,
+          teamId: evt.teamId,
+          permission: "webhook.read",
+          fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+        });
+        if (!ok) return [];
+      } else if (evt?.userId && evt.userId !== ctx.user.id) {
+        return [];
+      }
       const managedParentEvt = await prisma.eventType.findFirst({
         where: {
           id: input.eventTypeId,
           parentId: {
             not: null,
           },
         },
         select: {
           parentId: true,
         },
       });
♻️ Duplicate comments (1)
packages/trpc/server/routers/viewer/webhook/list.handler.ts (1)

53-68: Fix PBAC filtering: don’t block personal webhooks and honor ADMIN/OWNER fallbacks per team

  • Early return on Line 62 drops user-owned webhooks when no team permission exists.
  • Using getTeamIdsWithPermission ignores fallback roles when PBAC is disabled.
  • Switch to per-team checkPermission with fallbackRoles and build the OR clause dynamically.

Apply this diff:

-      const permissionService = new PermissionCheckService();
-      const teamsWithReadPermission = await permissionService.getTeamIdsWithPermission(
-        ctx.user.id,
-        "webhook.read"
-      );
-
-      const teamIds = user?.teams.map((membership) => membership.teamId) || [];
-      const authorizedTeamIds = teamIds.filter((teamId) => teamsWithReadPermission.includes(teamId));
-
-      if (authorizedTeamIds.length === 0) {
-        return [];
-      }
-
-      where.AND?.push({
-        OR: [{ userId: ctx.user.id }, { teamId: { in: authorizedTeamIds } }],
-      });
+      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);
+
+      where.AND?.push({
+        OR: [{ userId: ctx.user.id }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
+      });
🧹 Nitpick comments (3)
packages/trpc/server/routers/viewer/webhook/list.handler.ts (3)

3-3: Import fallback roles for legacy mode support

Required by the per-team permission checks to honor ADMIN/OWNER when PBAC is disabled.

Apply this diff:

 import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
+import { MembershipRole } from "@calcom/prisma/enums";

22-29: Narrow Prisma select to only required fields

You only need membership teamIds; selecting entire teams relation overfetches.

Apply this diff:

   const user = await prisma.user.findUnique({
     where: {
       id: ctx.user.id,
     },
     select: {
-      teams: true,
+      teams: { select: { teamId: true } },
     },
   });

76-78: Return a minimal shape to avoid leaking sensitive columns

Align with “select only what you need.” If Webhook has secrets (e.g., secret, signingKey), exclude them explicitly.

Proposed shape (adjust as per model):

-  return await prisma.webhook.findMany({
-    where,
-  });
+  return await prisma.webhook.findMany({
+    where,
+    select: {
+      id: true,
+      teamId: true,
+      userId: true,
+      eventTypeId: true,
+      appId: true,
+      eventTriggers: true,
+      active: true,
+      createdAt: true,
+      updatedAt: true,
+      // secret: false, // ensure any secret-like fields are excluded
+    },
+  });

If you confirm the exact Webhook schema, I can tailor the select to guarantee no secrets/PII are exposed.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6a13b0a and 2d7a186.

📒 Files selected for processing (2)
  • packages/trpc/server/routers/viewer/webhook/list.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/webhook/util.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/trpc/server/routers/viewer/webhook/util.ts
🧰 Additional context used
📓 Path-based instructions (3)
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/review.mdc)

**/*.ts: For Prisma queries, only select data you need; never use include, always use select
Ensure the credential.key field is never returned from tRPC endpoints or APIs

Files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/review.mdc)

Flag excessive Day.js use in performance-critical code; prefer native Date or Day.js .utc() in hot paths like loops

Files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
**/*.{ts,tsx,js,jsx}

⚙️ CodeRabbit configuration file

Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module.

Files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
🧠 Learnings (5)
📓 Common learnings
Learnt from: sean-brydon
PR: calcom/cal.com#23614
File: packages/features/pbac/domain/types/permission-registry.ts:520-551
Timestamp: 2025-09-08T12:46:13.448Z
Learning: Webhooks in Cal.com support both team-level and organization-level scopes, so webhook PBAC permissions should not be constrained to Team scope only.
📚 Learning: 2025-08-26T08:08:23.395Z
Learnt from: SinghaAnirban005
PR: calcom/cal.com#23343
File: packages/features/insights/server/trpc-router.ts:1080-1101
Timestamp: 2025-08-26T08:08:23.395Z
Learning: In packages/features/insights/server/trpc-router.ts, when filtering personal event types (userId provided, no teamId, not isAll), the query correctly uses user.id (authenticated user) instead of the input userId parameter for security reasons. This prevents users from accessing other users' personal event types by passing arbitrary user IDs.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
📚 Learning: 2025-08-26T20:23:28.396Z
Learnt from: Udit-takkar
PR: calcom/cal.com#22995
File: packages/features/calAIPhone/providers/retellAI/services/AgentService.ts:83-88
Timestamp: 2025-08-26T20:23:28.396Z
Learning: In calcom/cal.com PR #22995, the workflow update handler in packages/trpc/server/routers/viewer/workflows/update.handler.ts includes workflow-level authorization via isAuthorized(userWorkflow, ctx.user.id, "workflow.update") which validates the user can update the workflow before calling updateToolsFromAgentId (per maintainer Udit-takkar).

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
📚 Learning: 2025-08-27T12:15:43.830Z
Learnt from: Udit-takkar
PR: calcom/cal.com#22995
File: packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts:41-44
Timestamp: 2025-08-27T12:15:43.830Z
Learning: In calcom/cal.com, the AgentService.getAgent() method in packages/features/calAIPhone/providers/retellAI/services/AgentService.ts does NOT include authorization checks - it only validates the agentId parameter and directly calls the repository without verifying user/team access. This contrasts with other methods like getAgentWithDetails() which properly use findByIdWithUserAccessAndDetails() for authorization. When reviewing updateToolsFromAgentId() calls, always verify both agent ownership and eventType ownership are checked.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
📚 Learning: 2025-09-08T12:46:13.448Z
Learnt from: sean-brydon
PR: calcom/cal.com#23614
File: packages/features/pbac/domain/types/permission-registry.ts:520-551
Timestamp: 2025-09-08T12:46:13.448Z
Learning: Webhooks in Cal.com support both team-level and organization-level scopes, so webhook PBAC permissions should not be constrained to Team scope only.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
🧬 Code graph analysis (1)
packages/trpc/server/routers/viewer/webhook/list.handler.ts (1)
packages/features/pbac/services/permission-check.service.ts (1)
  • PermissionCheckService (21-366)
⏰ Context from checks skipped due to timeout of 180000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Install dependencies / Yarn install & cache

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/trpc/server/routers/viewer/webhook/list.handler.ts (1)

23-30: Minimize Prisma selection and exclude pending memberships

Select only accepted teamIds to reduce payload and avoid evaluating permissions for pending invites.

-  const user = await prisma.user.findUnique({
+  const user = await prisma.user.findUnique({
     where: {
       id: ctx.user.id,
     },
     select: {
-      teams: true,
+      teams: {
+        where: { accepted: true },
+        select: { teamId: true },
+      },
     },
   });
packages/lib/server/repository/webhook.ts (1)

164-181: Platform webhooks must exclude secret and unnecessary fields

Return only safe fields, and optionally filter out app-managed webhooks for consistency.

Apply this diff:

-    if (userRole === "ADMIN") {
-      const platformWebhooks = await prisma.webhook.findMany({
-        where: { platform: true },
-      });
+    if (userRole === "ADMIN") {
+      const platformWebhooks = await prisma.webhook.findMany({
+        where: { platform: true, appId: { notIn: ["zapier", "make"] } },
+        select: {
+          id: true,
+          subscriberUrl: true,
+          payloadTemplate: true,
+          active: true,
+          eventTriggers: true,
+          teamId: true,
+          userId: true,
+          platform: true,
+          time: true,
+          timeUnit: true,
+          appId: true,
+        },
+      });
♻️ Duplicate comments (6)
packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts (1)

18-21: Align types with repository output: replace readOnly with canModify/canDelete

WebhookRepository.getFilteredWebhooksForUser returns metadata: { canModify, canDelete } and spreads these onto profiles. Update local types to match to avoid downstream type/runtime mismatches.

   metadata?: {
-    readOnly: boolean;
+    canModify: boolean;
+    canDelete: boolean;
   };
@@
 export type WebhooksByViewer = {
   webhookGroups: WebhookGroup[];
   profiles: {
-    readOnly?: boolean | undefined;
+    canModify?: boolean | undefined;
+    canDelete?: boolean | undefined;
     slug: string | null;
     name: string | null;
     image?: string | undefined;
     teamId: number | null | undefined;
   }[];
 };

Also applies to: 24-33

packages/trpc/server/routers/viewer/webhook/delete.handler.ts (1)

26-28: Authorization for eventTypeId likely handled by PBAC middleware — confirm to avoid gaps

If the delete route is wrapped with createWebhookPbacProcedure("webhook.delete"), this branch is fine; otherwise add an ownership/team check for the event’s team.

#!/bin/bash
# Verify delete route uses PBAC procedure
rg -nP -C3 'createWebhookPbacProcedure\([^)]*webhook\.delete[^)]*\)' packages/trpc/server/routers/viewer/webhook
packages/trpc/server/routers/viewer/webhook/util.test.ts (1)

9-11: Move mocks above imports so they actually take effect

authedProcedure and prisma are imported before their vi.mock(...) calls, so the real modules are loaded and mocks won’t apply. Import modules-under-test only after all mocks are defined. Also drop the unused webhookProcedure import.

Apply:

-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";
+import { TRPCError } from "@trpc/server";
+
+// Mocks first ────────────────────────────────────────────────────────────────
+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(),
+  },
+}));
+
+// Imports after mocks ────────────────────────────────────────────────────────
+import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
+import { prisma } from "@calcom/prisma";
+import type { MembershipRole } from "@calcom/prisma/enums";
+import authedProcedure from "../../../procedures/authedProcedure";
+import { createWebhookPbacProcedure } from "./util";

Also applies to: 36-41

packages/trpc/server/routers/viewer/webhook/util.ts (1)

70-74: Prisma: replace include with select to avoid overfetching

Only id, userId, and teamId are used from eventType. Replace include: { team: true } with a minimal select.

Apply:

-        const eventType = await prisma.eventType.findUnique({
-          where: { id: webhook.eventTypeId },
-          include: { team: true },
-        });
+        const eventType = await prisma.eventType.findUnique({
+          where: { id: webhook.eventTypeId },
+          select: { id: true, userId: true, teamId: true },
+        });
-        const eventType = await prisma.eventType.findUnique({
-          where: { id: eventTypeId },
-          include: { team: true },
-        });
+        const eventType = await prisma.eventType.findUnique({
+          where: { id: eventTypeId },
+          select: { id: true, userId: true, teamId: true },
+        });

Also applies to: 126-130

packages/lib/server/repository/webhook.ts (2)

34-53: Block IDOR and stop exposing webhook.secret; require viewer context and auth

Current method:

  • accepts optional webhookId (can be undefined → Prisma error),
  • returns secret,
  • performs no authorization.

Harden it to require viewerId/userRole, perform auth (team, personal, platform), and never return secret.

Apply this diff:

-  static async findByWebhookId(webhookId?: string) {
-    return await prisma.webhook.findUniqueOrThrow({
-      where: {
-        id: webhookId,
-      },
-      select: {
-        id: true,
-        subscriberUrl: true,
-        payloadTemplate: true,
-        active: true,
-        eventTriggers: true,
-        secret: true,
-        teamId: true,
-        userId: true,
-        platform: true,
-        time: true,
-        timeUnit: true,
-      },
-    });
-  }
+  static async findByWebhookId({
+    webhookId,
+    viewerId,
+    userRole,
+  }: {
+    webhookId: string;
+    viewerId: number;
+    userRole?: UserPermissionRole;
+  }) {
+    if (!webhookId) throw new Error("webhookId is required");
+
+    // Minimal fetch for auth context
+    const base = await prisma.webhook.findUniqueOrThrow({
+      where: { id: webhookId },
+      select: { id: true, teamId: true, userId: true, platform: true },
+    });
+
+    // AuthZ
+    if (base.platform) {
+      if (userRole !== "ADMIN") throw new Error("Not authorized");
+    } else if (base.teamId) {
+      const permissionService = new PermissionCheckService();
+      const canRead = await permissionService.checkPermission({
+        userId: viewerId,
+        teamId: base.teamId,
+        permission: "webhook.read",
+        fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+      });
+      if (!canRead) throw new Error("Not authorized");
+    } else if (base.userId !== viewerId) {
+      throw new Error("Not authorized");
+    }
+
+    // Safe fields only (no secret)
+    return prisma.webhook.findUniqueOrThrow({
+      where: { id: webhookId },
+      select: {
+        id: true,
+        subscriberUrl: true,
+        payloadTemplate: true,
+        active: true,
+        eventTriggers: true,
+        teamId: true,
+        userId: true,
+        platform: true,
+        time: true,
+        timeUnit: true,
+        appId: true,
+      },
+    });
+  }

186-191: Profiles should mirror filtered groups

profiles is built from webhookGroups before filtering, causing orphan profiles for zero-webhook groups.

Apply this diff:

-    return {
-      webhookGroups: webhookGroups.filter((group) => group.webhooks.length > 0),
-      profiles: webhookGroups.map((group) => ({
+    const filteredGroups = webhookGroups.filter((group) => group.webhooks.length > 0);
+    return {
+      webhookGroups: filteredGroups,
+      profiles: filteredGroups.map((group) => ({
         teamId: group.teamId,
         ...group.profile,
         ...group.metadata,
       })),
     };
🧹 Nitpick comments (21)
packages/trpc/server/routers/viewer/webhook/list.handler.ts (1)

54-69: Correct per-team permission check; remove debug logging

Per-team check via PermissionCheckService with [ADMIN, OWNER] fallback is good. Drop the console.log before merging.

-      console.log("Allowed Team IDs:", allowedTeamIds);

Also applies to: 72-74

packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx (1)

358-373: Avoid dual prop patterns: use the new permissions prop only

You’re passing both canEditWebhook and permissions. Prefer the permissions object to prevent divergence.

-                      <WebhookListItem
+                      <WebhookListItem
                         key={webhook.id}
                         webhook={webhook}
                         lastItem={webhooks.length === index + 1}
-                        canEditWebhook={!webhookLockedStatus.disabled}
                         onEditWebhook={() => {
                           setEditModalOpen(true);
                           setWebhookToEdit(webhook);
                         }}
                         // TODO (SEAN): Implement Permissions here when we have event-types PR merged
                         permissions={{
                           canEditWebhook: !webhookLockedStatus.disabled,
                           canDeleteWebhook: !webhookLockedStatus.disabled,
                         }}
                       />
packages/trpc/server/routers/viewer/webhook/create.handler.ts (1)

34-49: Remove manual PBAC check from handler

The create route is already wrapped with createWebhookPbacProcedure("webhook.create") in packages/trpc/server/routers/viewer/webhook/_router.tsx (line 59), so you can remove the manual permission check in create.handler.ts to avoid duplication.

packages/features/webhooks/components/CreateNewWebhookButton.tsx (3)

21-24: Avoid uppercasing localized strings

toUpperCase() can break locale-specific casing. Prefer styling (e.g., CSS uppercase) over string mutation.


12-18: Prefer absolute paths for router.push to avoid relative navigation issues

These relative routes can misresolve depending on current path depth. Consider using absolute paths (e.g., with WEBAPP_URL/constants) for consistency.


35-35: Prefer named exports over default exports

Stick to named exports for better tree-shaking/refactors.

Apply:

-export default CreateNewWebhookButton;
packages/prisma/migrations/20250905115031_add_webhooks_permissions_default_roles/migration.sql (2)

2-12: Seed OWNER role too (if distinct from ADMIN)

If OWNER is a distinct role in RolePermission, grant the same CRUD permissions to 'owner_role' for parity with UI fallbacks.

Proposed addition:

+-- Add webhook permissions for owner role
+INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
+SELECT gen_random_uuid(), 'owner_role', resource, action, NOW()
+FROM (
+  VALUES
+    ('webhook', 'create'),
+    ('webhook', 'read'),
+    ('webhook', 'update'),
+    ('webhook', 'delete')
+) AS permissions(resource, action);

2-12: Make inserts idempotent to avoid duplicate rows on re-runs

If this migration might be replayed on environments with seeded data, guard against duplicates.

Example (Postgres):

-INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
-SELECT
-  gen_random_uuid(), 'admin_role', resource, action, NOW()
-FROM (
-  VALUES
-    ('webhook', 'create'),
-    ('webhook', 'read'),
-    ('webhook', 'update'),
-    ('webhook', 'delete')
-) AS permissions(resource, action);
+INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
+SELECT gen_random_uuid(), 'admin_role', v.resource, v.action, NOW()
+FROM (VALUES
+  ('webhook', 'create'),
+  ('webhook', 'read'),
+  ('webhook', 'update'),
+  ('webhook', 'delete')
+) AS v(resource, action)
+LEFT JOIN "RolePermission" rp
+  ON rp."roleId" = 'admin_role' AND rp.resource = v.resource AND rp.action = v.action
+WHERE rp.id IS NULL;

Repeat similarly for 'member_role'.

Also applies to: 15-22

packages/features/webhooks/pages/webhooks-view.tsx (1)

96-96: Prefer named exports over default exports

Follow repo guideline to avoid default exports in components.

Apply:

-export default WebhooksView;
+export { WebhooksView as default }; // or export named elsewhere and update imports
packages/features/webhooks/components/WebhookListItem.tsx (3)

36-43: Remove legacy canEditWebhook prop; rely on permissions object only

The standalone canEditWebhook prop is now redundant and risks confusion.

Apply:

 export default function WebhookListItem(props: {
   webhook: WebhookProps;
-  canEditWebhook?: boolean;
   onEditWebhook: () => void;
   lastItem: boolean;
   permissions: {
     canEditWebhook?: boolean;
     canDeleteWebhook?: boolean;
   };
 }) {

112-126: Hide the toggle switch when user cannot edit

Currently rendered but disabled; minor UX polish to only render when permitted.

Apply:

-      {(props.permissions.canEditWebhook || props.permissions.canDeleteWebhook) && (
+      {(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={!props.permissions.canEditWebhook}
-            onCheckedChange={() =>
-              toggleWebhook.mutate({
-                id: webhook.id,
-                active: !webhook.active,
-                payloadTemplate: webhook.payloadTemplate,
-                eventTypeId: webhook.eventTypeId || undefined,
-              })
-            }
-          />
+          {props.permissions.canEditWebhook && (
+            <Switch
+              defaultChecked={webhook.active}
+              data-testid="webhook-switch"
+              onCheckedChange={() =>
+                toggleWebhook.mutate({
+                  id: webhook.id,
+                  active: !webhook.active,
+                  payloadTemplate: webhook.payloadTemplate,
+                  eventTypeId: webhook.eventTypeId || undefined,
+                })
+              }
+            />
+          )}

70-77: Add a confirmation before destructive delete

Prevent accidental deletions; localized strings already exist.

Apply:

   const onDeleteWebhook = () => {
-    // TODO: Confimation dialog before deleting
-    deleteWebhook.mutate({
-      id: webhook.id,
-      eventTypeId: webhook.eventTypeId || undefined,
-      teamId: webhook.teamId || undefined,
-    });
+    if (
+      window.confirm(
+        `${t("delete_webhook_confirmation_message")}\n\n${t("confirm_delete_webhook")}`
+      )
+    ) {
+      deleteWebhook.mutate({
+        id: webhook.id,
+        eventTypeId: webhook.eventTypeId || undefined,
+        teamId: webhook.teamId || undefined,
+      });
+    }
   };
packages/trpc/server/routers/viewer/webhook/util.test.ts (3)

54-62: Reset both .use and .input spies to prevent cross-test leaks

Currently only .use is cleared. Clear .input too.

Apply:

   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();
+    mockAuthedProcedure.use.mockClear();
+    mockAuthedProcedure.input?.mockClear?.();
   });

132-144: Use robust TRPCError assertions; matching instances is brittle

rejects.toThrow(new TRPCError(...)) can be flaky. Prefer matching by shape.

Apply (example for each occurrence):

-).rejects.toThrow(
-  new TRPCError({
-    code: "FORBIDDEN",
-    message: `Permission required: ${testPermission}`,
-  })
-);
+).rejects.toMatchObject({
+  code: "FORBIDDEN",
+  message: `Permission required: ${testPermission}`,
+});
-).rejects.toThrow(new TRPCError({ code: "NOT_FOUND" }));
+).rejects.toMatchObject({ code: "NOT_FOUND" });
-).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
+).rejects.toMatchObject({ code: "UNAUTHORIZED" });

Also applies to: 185-197, 301-313, 248-249, 272-273


382-388: This doesn't test the legacy wrapper; either import it or adjust the description

You’re not importing or asserting anything about webhookProcedure. Import it and assert it’s defined, or rename the test to reflect what’s actually asserted.

Possible change:

-import { createWebhookPbacProcedure } from "./util";
+import { createWebhookPbacProcedure, webhookProcedure } from "./util";
...
-  describe("webhookProcedure (legacy wrapper)", () => {
+  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();
+      expect(webhookProcedure).toBeDefined();
     });
   });
packages/trpc/server/routers/viewer/webhook/util.ts (1)

165-170: Legacy alias is fine for BC, but consider deprecating with JSDoc

Add a @deprecated tag to steer new code towards the factory.

Example:

/**
 * @deprecated Use createWebhookPbacProcedure(...) directly.
 */
export const webhookProcedure = createWebhookPbacProcedure("webhook.update", ["ADMIN", "OWNER"]);
packages/trpc/server/routers/viewer/webhook/_router.tsx (2)

23-39: List route: enforce PBAC via filtering in the handler

Since the PBAC middleware skips checks when there’s no input, ensure list.handler filters by teams the viewer has webhook.read on (e.g., PermissionCheckService.getTeamIdsWithPermission) and scopes the query accordingly. This addresses the “missing permission filtering logic in list.handler.ts” noted in the PR objectives.


133-150: getByViewer route likely needs the same server-side filtering

No input means middleware won’t check permissions. Ensure getByViewer.handler restricts results to teams/orgs where the viewer has webhook.read.

packages/lib/server/repository/webhook.ts (3)

20-21: Type safety: use a “safe webhook” type to prevent secret regressions

Change WebhookGroup.webhooks to a safe type (no secret) and let Prisma’s select enforce it.

Add this type outside the diffed ranges:

type SafeWebhook = Pick<
  Webhook,
  | "id"
  | "subscriberUrl"
  | "payloadTemplate"
  | "active"
  | "eventTriggers"
  | "teamId"
  | "userId"
  | "platform"
  | "time"
  | "timeUnit"
  | "appId"
>;

type WebhookGroup = {
  // ...
  webhooks: SafeWebhook[];
};

23-31: Minor: use a Set for appIds

Tiny perf/readability nit.

Apply this diff:

-const filterWebhooks = (webhook: Webhook) => {
-  const appIds = [
-    "zapier",
-    "make",
-    // Add more if needed
-  ];
-  return !appIds.some((appId: string) => webhook.appId == appId);
-};
+const filterWebhooks = (webhook: Webhook) => {
+  const appIds = new Set(["zapier", "make"]);
+  return !appIds.has(webhook.appId ?? "");
+};

33-53: Enforce CI guardrail against leaking secret
Fail CI if any select: {...secret: true} or implicit webhook selects or standalone .secret usages slip in:

#!/bin/bash
rg -nP --type=ts -C2 'select\s*:\s*\{[^}]*\bsecret\s*:\s*true'
rg -nP --type=ts -C3 'select\s*:\s*\{[^}]*\bwebhooks\s*:\s*true'
rg -nP --type=ts -C2 '\.secret\b'
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3c72d7a and 95b7c69.

📒 Files selected for processing (18)
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx (1 hunks)
  • apps/web/public/static/locales/en/common.json (1 hunks)
  • packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx (1 hunks)
  • packages/features/pbac/domain/types/permission-registry.ts (2 hunks)
  • packages/features/webhooks/components/CreateNewWebhookButton.tsx (2 hunks)
  • packages/features/webhooks/components/WebhookListItem.tsx (4 hunks)
  • packages/features/webhooks/pages/webhooks-view.tsx (4 hunks)
  • packages/lib/server/repository/webhook.ts (6 hunks)
  • packages/prisma/migrations/20250905115031_add_webhooks_permissions_default_roles/migration.sql (1 hunks)
  • packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts (0 hunks)
  • packages/trpc/server/routers/viewer/webhook/_router.tsx (2 hunks)
  • packages/trpc/server/routers/viewer/webhook/create.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/webhook/edit.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts (1 hunks)
  • packages/trpc/server/routers/viewer/webhook/list.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/webhook/util.test.ts (1 hunks)
  • packages/trpc/server/routers/viewer/webhook/util.ts (2 hunks)
💤 Files with no reviewable changes (1)
  • packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/review.mdc)

**/*.ts: For Prisma queries, only select data you need; never use include, always use select
Ensure the credential.key field is never returned from tRPC endpoints or APIs

Files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
  • packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
  • packages/trpc/server/routers/viewer/webhook/util.ts
  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts
  • packages/trpc/server/routers/viewer/webhook/edit.handler.ts
  • packages/trpc/server/routers/viewer/webhook/util.test.ts
  • packages/trpc/server/routers/viewer/webhook/create.handler.ts
  • packages/features/pbac/domain/types/permission-registry.ts
  • packages/lib/server/repository/webhook.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/review.mdc)

Flag excessive Day.js use in performance-critical code; prefer native Date or Day.js .utc() in hot paths like loops

Files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
  • packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
  • packages/features/webhooks/components/CreateNewWebhookButton.tsx
  • packages/trpc/server/routers/viewer/webhook/util.ts
  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts
  • packages/trpc/server/routers/viewer/webhook/edit.handler.ts
  • packages/trpc/server/routers/viewer/webhook/util.test.ts
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx
  • packages/trpc/server/routers/viewer/webhook/create.handler.ts
  • packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx
  • packages/features/webhooks/components/WebhookListItem.tsx
  • packages/features/pbac/domain/types/permission-registry.ts
  • packages/trpc/server/routers/viewer/webhook/_router.tsx
  • packages/features/webhooks/pages/webhooks-view.tsx
  • packages/lib/server/repository/webhook.ts
**/*.{ts,tsx,js,jsx}

⚙️ CodeRabbit configuration file

Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module.

Files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
  • packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
  • packages/features/webhooks/components/CreateNewWebhookButton.tsx
  • packages/trpc/server/routers/viewer/webhook/util.ts
  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts
  • packages/trpc/server/routers/viewer/webhook/edit.handler.ts
  • packages/trpc/server/routers/viewer/webhook/util.test.ts
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx
  • packages/trpc/server/routers/viewer/webhook/create.handler.ts
  • packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx
  • packages/features/webhooks/components/WebhookListItem.tsx
  • packages/features/pbac/domain/types/permission-registry.ts
  • packages/trpc/server/routers/viewer/webhook/_router.tsx
  • packages/features/webhooks/pages/webhooks-view.tsx
  • packages/lib/server/repository/webhook.ts
**/*.tsx

📄 CodeRabbit inference engine (.cursor/rules/review.mdc)

Always use t() for text localization in frontend code; direct text embedding should trigger a warning

Files:

  • packages/features/webhooks/components/CreateNewWebhookButton.tsx
  • apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/page.tsx
  • packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx
  • packages/features/webhooks/components/WebhookListItem.tsx
  • packages/trpc/server/routers/viewer/webhook/_router.tsx
  • packages/features/webhooks/pages/webhooks-view.tsx
🧠 Learnings (12)
📓 Common learnings
Learnt from: sean-brydon
PR: calcom/cal.com#23614
File: packages/features/pbac/domain/types/permission-registry.ts:520-551
Timestamp: 2025-09-08T12:46:13.448Z
Learning: Webhooks in Cal.com support both team-level and organization-level scopes, so webhook PBAC permissions should not be constrained to Team scope only.
📚 Learning: 2025-08-26T08:08:23.395Z
Learnt from: SinghaAnirban005
PR: calcom/cal.com#23343
File: packages/features/insights/server/trpc-router.ts:1080-1101
Timestamp: 2025-08-26T08:08:23.395Z
Learning: In packages/features/insights/server/trpc-router.ts, when filtering personal event types (userId provided, no teamId, not isAll), the query correctly uses user.id (authenticated user) instead of the input userId parameter for security reasons. This prevents users from accessing other users' personal event types by passing arbitrary user IDs.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
  • packages/trpc/server/routers/viewer/webhook/util.ts
  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts
  • packages/trpc/server/routers/viewer/webhook/create.handler.ts
📚 Learning: 2025-08-27T12:15:43.830Z
Learnt from: Udit-takkar
PR: calcom/cal.com#22995
File: packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts:41-44
Timestamp: 2025-08-27T12:15:43.830Z
Learning: In calcom/cal.com, the AgentService.getAgent() method in packages/features/calAIPhone/providers/retellAI/services/AgentService.ts does NOT include authorization checks - it only validates the agentId parameter and directly calls the repository without verifying user/team access. This contrasts with other methods like getAgentWithDetails() which properly use findByIdWithUserAccessAndDetails() for authorization. When reviewing updateToolsFromAgentId() calls, always verify both agent ownership and eventType ownership are checked.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts
📚 Learning: 2025-09-08T12:46:13.448Z
Learnt from: sean-brydon
PR: calcom/cal.com#23614
File: packages/features/pbac/domain/types/permission-registry.ts:520-551
Timestamp: 2025-09-08T12:46:13.448Z
Learning: Webhooks in Cal.com support both team-level and organization-level scopes, so webhook PBAC permissions should not be constrained to Team scope only.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
  • packages/trpc/server/routers/viewer/webhook/util.ts
  • packages/features/pbac/domain/types/permission-registry.ts
📚 Learning: 2025-09-09T03:29:42.984Z
Learnt from: emrysal
PR: calcom/cal.com#23692
File: packages/lib/server/service/InsightsBookingBaseService.ts:16-16
Timestamp: 2025-09-09T03:29:42.984Z
Learning: In the Cal.com codebase, readonlyPrisma is still an instance of PrismaClient, making type changes from `typeof readonlyPrisma` to `PrismaClient` less critical since they are fundamentally compatible types.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/list.handler.ts
📚 Learning: 2025-07-28T11:50:23.946Z
Learnt from: CR
PR: calcom/cal.com#0
File: .cursor/rules/review.mdc:0-0
Timestamp: 2025-07-28T11:50:23.946Z
Learning: Applies to **/*.ts : For Prisma queries, only select data you need; never use `include`, always use `select`

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/util.ts
📚 Learning: 2025-08-21T12:28:42.018Z
Learnt from: alishaz-polymath
PR: calcom/cal.com#23247
File: packages/features/webhooks/lib/factory/WebhookPayloadFactory.ts:274-282
Timestamp: 2025-08-21T12:28:42.018Z
Learning: In webhook DTOs in packages/features/webhooks/lib/dto/types.ts, the booking fields are restricted structures containing only specific fields (id, eventTypeId, userId, and sometimes additional fields like startTime or smsReminderNumber) rather than full database booking objects, so there are no security or PII leakage concerns when using these booking objects in webhook payloads.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/util.ts
📚 Learning: 2025-08-21T13:44:06.805Z
Learnt from: supalarry
PR: calcom/cal.com#23217
File: apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts:93-94
Timestamp: 2025-08-21T13:44:06.805Z
Learning: In apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts, repository functions that use explicit Prisma select clauses (like getEventTypeWithSeats) are used for specific purposes and don't need to include all EventType fields like bookingRequiresAuthentication. These methods don't feed into the general OutputEventTypesService_2024_06_14 flow.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/util.ts
📚 Learning: 2025-09-03T11:54:05.409Z
Learnt from: supalarry
PR: calcom/cal.com#23514
File: apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts:579-582
Timestamp: 2025-09-03T11:54:05.409Z
Learning: In calcom/cal.com bookings repository methods, when Prisma include uses `eventType: true`, all eventType fields including seatsShowAttendees are automatically included in the selection. Explicit field selection is not required when using `true` for nested relations.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/util.ts
📚 Learning: 2025-09-03T11:54:05.409Z
Learnt from: supalarry
PR: calcom/cal.com#23514
File: apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts:579-582
Timestamp: 2025-09-03T11:54:05.409Z
Learning: In calcom/cal.com bookings repository methods, when Prisma select uses `eventType: true`, all eventType fields including seatsShowAttendees are automatically included in the selection. Explicit field selection is not required when using `true` for nested relations.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/util.ts
📚 Learning: 2025-09-06T11:00:34.348Z
Learnt from: ShashwatPS
PR: calcom/cal.com#23638
File: packages/trpc/server/routers/viewer/calendars/setDestinationReminder.handler.test.ts:198-199
Timestamp: 2025-09-06T11:00:34.348Z
Learning: In calcom/cal.com PR #23638, the maintainer ShashwatPS determined that authorization checks in the setDestinationReminder handler are "not applicable" when CodeRabbit suggested adding user-scoped WHERE clauses to prevent users from modifying other users' destination calendar reminder settings.

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts
📚 Learning: 2025-08-26T20:23:28.396Z
Learnt from: Udit-takkar
PR: calcom/cal.com#22995
File: packages/features/calAIPhone/providers/retellAI/services/AgentService.ts:83-88
Timestamp: 2025-08-26T20:23:28.396Z
Learning: In calcom/cal.com PR #22995, the workflow update handler in packages/trpc/server/routers/viewer/workflows/update.handler.ts includes workflow-level authorization via isAuthorized(userWorkflow, ctx.user.id, "workflow.update") which validates the user can update the workflow before calling updateToolsFromAgentId (per maintainer Udit-takkar).

Applied to files:

  • packages/trpc/server/routers/viewer/webhook/delete.handler.ts
  • packages/trpc/server/routers/viewer/webhook/edit.handler.ts
  • packages/trpc/server/routers/viewer/webhook/create.handler.ts
🧬 Code graph analysis (12)
packages/trpc/server/routers/viewer/webhook/list.handler.ts (1)
packages/features/pbac/services/permission-check.service.ts (1)
  • PermissionCheckService (21-366)
packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts (1)
packages/lib/server/repository/webhook.ts (1)
  • WebhookRepository (33-194)
packages/features/webhooks/components/CreateNewWebhookButton.tsx (1)
packages/platform/libraries/index.ts (1)
  • MembershipRole (34-34)
packages/trpc/server/routers/viewer/webhook/util.ts (3)
packages/features/pbac/domain/types/permission-registry.ts (1)
  • PermissionString (61-61)
packages/platform/libraries/index.ts (1)
  • MembershipRole (34-34)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (21-366)
  • hasPermission (221-239)
packages/trpc/server/routers/viewer/webhook/delete.handler.ts (2)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (21-366)
  • hasPermission (221-239)
packages/platform/libraries/index.ts (2)
  • MembershipRole (34-34)
  • TRPCError (66-66)
packages/trpc/server/routers/viewer/webhook/edit.handler.ts (2)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (21-366)
  • hasPermission (221-239)
packages/platform/libraries/index.ts (2)
  • MembershipRole (34-34)
  • TRPCError (66-66)
packages/trpc/server/routers/viewer/webhook/util.test.ts (2)
packages/features/pbac/domain/types/permission-registry.ts (1)
  • PermissionString (61-61)
packages/trpc/server/routers/viewer/webhook/util.ts (1)
  • createWebhookPbacProcedure (17-163)
packages/trpc/server/routers/viewer/webhook/create.handler.ts (1)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (21-366)
  • hasPermission (221-239)
packages/features/webhooks/components/WebhookListItem.tsx (1)
packages/ui/components/dropdown/Dropdown.tsx (6)
  • Dropdown (12-12)
  • DropdownMenuTrigger (15-26)
  • DropdownMenuContent (34-51)
  • DropdownMenuItem (63-71)
  • DropdownItem (161-181)
  • DropdownMenuSeparator (184-194)
packages/trpc/server/routers/viewer/webhook/_router.tsx (1)
packages/trpc/server/routers/viewer/webhook/util.ts (1)
  • createWebhookPbacProcedure (17-163)
packages/features/webhooks/pages/webhooks-view.tsx (3)
packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts (1)
  • WebhooksByViewer (24-33)
packages/trpc/react/trpc.ts (1)
  • RouterOutputs (143-143)
packages/features/webhooks/components/CreateNewWebhookButton.tsx (1)
  • CreateNewWebhookButton (9-33)
packages/lib/server/repository/webhook.ts (2)
packages/features/pbac/services/permission-check.service.ts (1)
  • PermissionCheckService (21-366)
packages/platform/libraries/index.ts (1)
  • MembershipRole (34-34)
🔇 Additional comments (14)
packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts (1)

36-41: LGTM: switched to PBAC-aware repository method

Good move to use getFilteredWebhooksForUser; it centralizes filtering and permission-derived metadata.

packages/trpc/server/routers/viewer/webhook/edit.handler.ts (1)

1-1: Router-level PBAC in place; no duplicate checks detected
Verified that edit (and related) mutations in packages/trpc/server/routers/viewer/webhook/_router.tsx are wrapped with createWebhookPbacProcedure("webhook.update") and no additional PBAC logic exists in handlers.

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

31-31: Approve WebhooksView prop removal
WebhooksView’s Props no longer include isAdmin and all instances (including apps/web/.../page.tsx) now pass only data.

packages/trpc/server/routers/viewer/webhook/delete.handler.ts (1)

29-43: Good: team-scoped delete permission with fallbacks

The check for webhook.delete with [ADMIN, OWNER] fallback is appropriate.

packages/features/webhooks/components/CreateNewWebhookButton.tsx (1)

27-30: PBAC gating and legacy fallbacks verified
Server‐side createWebhookPbacProcedure("webhook.create") defaults to ["ADMIN","OWNER"] (confirmed by util tests and create.handler.ts), matching the client’s fallback roles.

apps/web/public/static/locales/en/common.json (1)

3438-3442: Webhook PBAC i18n keys added — LGTM

Keys align with the registry entries and existing action labels.

packages/features/pbac/domain/types/permission-registry.ts (2)

12-12: Resource.Webhook enum added — LGTM


520-551: Webhook CRUD permission entries look correct

  • Depends on read for mutating actions.
  • No explicit scope — supports both Team and Organization as intended.
packages/features/webhooks/components/WebhookListItem.tsx (1)

92-97: Readonly badge logic — LGTM

Badge shows when edit not permitted; matches PBAC expectations.

packages/trpc/server/routers/viewer/webhook/util.ts (1)

53-68: Error semantics look good; keep FORBIDDEN messages consistent

You consistently return FORBIDDEN with Permission required: ${permission}. Good for UX and tests.

Also applies to: 95-108, 119-157

packages/trpc/server/routers/viewer/webhook/_router.tsx (1)

41-58: Fallback roles include MEMBER for get; confirm product intent

Using ["ADMIN", "OWNER", "MEMBER"] for read may be correct, but it widens legacy access when PBAC is disabled. Confirm with product/security that MEMBERs should read webhooks in legacy mode.

packages/lib/server/repository/webhook.ts (3)

1-1: Import looks good

Required for permission checks.


17-19: Metadata shape change LGTM

Downstream UI relying on readOnly should be updated; otherwise fine.

Please confirm no callers still expect metadata.readOnly.


100-113: Personal group OK; ensure user.webhooks schema is sanitized as above

Once the select is fixed, this block is fine.

Comment on lines +68 to +71
permissions={{
canEditWebhook: group?.metadata?.canModify ?? false,
canDeleteWebhook: group?.metadata?.canDelete ?? false,
}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Mapping server metadata to UI permissions — ensure backend populates these

UI derives canEdit/canDelete from group.metadata.canModify/canDelete. Please confirm list/get handlers set these via PBAC checks.

Run:


🏁 Script executed:

#!/bin/bash
rg -nP --type=ts -C3 'getByViewer\.handler|list\.handler' packages/trpc/server/routers/viewer/webhook
rg -nP --type=ts -C3 '\bmetadata\b.*(canModify|canDelete)' packages/trpc/server/routers/viewer/webhook

Length of output: 2781


🏁 Script executed:

#!/bin/bash
rg -n -C3 'metadata' packages/trpc/server/routers/viewer/webhook/*.handler.ts
rg -n -C3 'canModify|canDelete' packages/trpc/server/routers/viewer/webhook/*.handler.ts

Length of output: 1731


🏁 Script executed:

#!/bin/bash
ls -1 packages/trpc/server/routers/viewer

Length of output: 350


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' packages/trpc/server/routers/viewer/webhook/list.handler.ts

Length of output: 2417


Add canModify/canDelete metadata in list & getByViewer handlers
list.handler.ts & getByViewer.handler.ts don’t set metadata.canModify/metadata.canDelete—use PermissionCheckService to check "webhook.modify" and "webhook.delete" and include those booleans in each webhook’s metadata before returning.

🤖 Prompt for AI Agents
In packages/features/webhooks/pages/webhooks-view.tsx around lines 68 to 71, the
UI expects each webhook to have metadata.canModify and metadata.canDelete but
list.handler.ts and getByViewer.handler.ts do not populate those fields; update
both handlers to call PermissionCheckService (checking "webhook.modify" and
"webhook.delete") for each webhook and set metadata.canModify and
metadata.canDelete to the returned booleans on every webhook object before
returning the list/result so the front-end permissions prop is accurate.

Comment on lines +63 to 69
where: { id: userId },
select: {
id: true,
username: true,
avatarUrl: true,
name: true,
avatarUrl: true,
webhooks: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Do not select full webhooks via relations; drop secret at the source

  • user.webhooks: true and team.webhooks: true will include secret and fetch webhooks for teams even when canRead=false.
  • Select only safe fields and avoid prefetching team.webhooks here.

Apply this diff:

     const user = await prisma.user.findUnique({
       where: { id: userId },
       select: {
         id: true,
         username: true,
         name: true,
-        avatarUrl: true,
-        webhooks: true,
+        avatarUrl: true,
+        webhooks: {
+          where: { appId: { notIn: ["zapier", "make"] } },
+          select: {
+            id: true,
+            subscriberUrl: true,
+            payloadTemplate: true,
+            active: true,
+            eventTriggers: true,
+            teamId: true,
+            userId: true,
+            platform: true,
+            time: true,
+            timeUnit: true,
+            appId: true,
+          },
+        },
         teams: {
           where: {
             accepted: true,
           },
           select: {
             role: true,
             team: {
               select: {
                 id: true,
                 name: true,
                 slug: true,
-                logoUrl: true,
-                webhooks: true,
+                logoUrl: true,
               },
             },
           },
         },
       },
     });

Follow-up in the loop below replaces membership.team.webhooks usage.

Also applies to: 75-83

🤖 Prompt for AI Agents
In packages/lib/server/repository/webhook.ts around lines 63-69 (and similarly
lines 75-83), the code selects user.webhooks: true and team.webhooks: true which
prefetches full webhook objects including secrets and pulls team webhooks even
when canRead=false; change the select to avoid selecting full relations and
instead explicitly select only safe, non-secret webhook fields (e.g., id, name,
url, enabled, createdAt) via nested select objects like webhooks: { select: {
id: true, name: true, url: true, enabled: true, createdAt: true } } and remove
any team.webhooks: true selection; follow the follow-up note to replace any
later usage of membership.team.webhooks to reference the restricted/selective
fields or fetch team webhooks separately with proper authorization if secrets
are required.

Comment on lines +115 to +162
// Check permissions for each team
// The permission service handles PBAC when enabled and falls back to role-based permissions
for (const membership of user.teams) {
const teamId = membership.team.id;

// Check read permission (fallback: MEMBER, ADMIN, OWNER can read)
const canRead = await permissionService.checkPermission({
userId,
teamId,
permission: "webhook.read",
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});

if (!canRead) {
// User doesn't have permission to view this team's webhooks
continue;
}

const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
// Check update/delete permissions in parallel (fallback: only ADMIN, OWNER can modify)
const [canUpdate, canDelete] = await Promise.all([
permissionService.checkPermission({
userId,
teamId,
permission: "webhook.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}),
permissionService.checkPermission({
userId,
teamId,
permission: "webhook.delete",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}),
]);

webhookGroups.push({
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug
? !membership.team.parentId
? `/team`
: `${membership.team.slug}`
: null,
slug: membership.team.slug || null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
metadata: {
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
canModify: canUpdate,
canDelete,
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
};
});

webhookGroups = webhookGroups.concat(teamWebhookGroups);
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t rely on pre-fetched team.webhooks; fetch sanitized data only for teams the viewer can read

Avoid retaining or touching team.webhooks when canRead=false and ensure secrets never enter memory. Fetch per-team after permission check.

Apply this diff:

       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)
+      // 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],
         }),
       ]);
 
+      // Fetch team webhooks now that we know the user can read
+      const teamWebhooks = await prisma.webhook.findMany({
+        where: { teamId, appId: { notIn: ["zapier", "make"] } },
+        select: {
+          id: true,
+          subscriberUrl: true,
+          payloadTemplate: true,
+          active: true,
+          eventTriggers: true,
+          teamId: true,
+          userId: true,
+          platform: true,
+          time: true,
+          timeUnit: true,
+          appId: true,
+        },
+      });
+
       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),
+        webhooks: teamWebhooks,
         metadata: {
           canModify: canUpdate,
           canDelete,
         },
       });

Optional optimization: batch via getTeamIdsWithPermission + single findMany and group in-memory.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check permissions for each team
// The permission service handles PBAC when enabled and falls back to role-based permissions
for (const membership of user.teams) {
const teamId = membership.team.id;
// Check read permission (fallback: MEMBER, ADMIN, OWNER can read)
const canRead = await permissionService.checkPermission({
userId,
teamId,
permission: "webhook.read",
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!canRead) {
// User doesn't have permission to view this team's webhooks
continue;
}
const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
// Check update/delete permissions in parallel (fallback: only ADMIN, OWNER can modify)
const [canUpdate, canDelete] = await Promise.all([
permissionService.checkPermission({
userId,
teamId,
permission: "webhook.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}),
permissionService.checkPermission({
userId,
teamId,
permission: "webhook.delete",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}),
]);
webhookGroups.push({
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug
? !membership.team.parentId
? `/team`
: `${membership.team.slug}`
: null,
slug: membership.team.slug || null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
metadata: {
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
canModify: canUpdate,
canDelete,
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
};
});
webhookGroups = webhookGroups.concat(teamWebhookGroups);
});
}
// 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],
}),
]);
// Fetch team webhooks now that we know the user can read
const teamWebhooks = await prisma.webhook.findMany({
where: { teamId, appId: { notIn: ["zapier", "make"] } },
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
active: true,
eventTriggers: true,
teamId: true,
userId: true,
platform: true,
time: true,
timeUnit: true,
appId: true,
},
});
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: teamWebhooks,
metadata: {
canModify: canUpdate,
canDelete,
},
});
}

@eunjae-lee
Copy link
Contributor

@sean-brydon if

  • org level: off
  • team level: off

then still the user should be able to perform CRUD on personal-scoped workflows, right? I'm testing that scenario and not seeing "+ new" button. (but RUD is working)

setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
// TODO (SEAN): Implement Permissions here when we have event-types PR merged
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to fix this (leaving a comment for visibility)

@github-actions github-actions bot marked this pull request as draft September 16, 2025 15:01
@eunjae-lee eunjae-lee dismissed their stale review September 17, 2025 11:44

my test was wrong. it's working well.

@eunjae-lee eunjae-lee marked this pull request as ready for review September 17, 2025 11:45
Copy link
Contributor

@eunjae-lee eunjae-lee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

working well !

Copy link
Contributor

@keithwillcode keithwillcode left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving the migration from foundation side.

@keithwillcode keithwillcode merged commit a44a3e5 into main Sep 18, 2025
41 checks passed
@keithwillcode keithwillcode deleted the devin/pbac-webhooks-1757061104 branch September 18, 2025 10:37
@coderabbitai coderabbitai bot mentioned this pull request Sep 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

consumer core area: core, team members only ✨ feature New feature or request i18n area: i18n, translations ❗️ migrations contains migration files ready-for-e2e size/XXL webhooks area: webhooks, callback, webhook payload

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants