Skip to content

Comments

refactor: use permission check service in /organizations endpoints#24099

Merged
sean-brydon merged 10 commits intomainfrom
refactor/more-pbac-replacements
Sep 29, 2025
Merged

refactor: use permission check service in /organizations endpoints#24099
sean-brydon merged 10 commits intomainfrom
refactor/more-pbac-replacements

Conversation

@hbjORbj
Copy link
Contributor

@hbjORbj hbjORbj commented Sep 26, 2025

What does this PR do?

  • Leverage using permission check service in /organizations trpc router

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • N/A - I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  • Please use the latest Vercel preview and test please 🙏.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 26, 2025

Walkthrough

This change replaces legacy admin/membership checks with PermissionCheckService-based authorization across several organization/team handlers. Handlers now call checkPermission with permissions such as eventType.update, team.create, team.delete, organization.update, and team.invite, using fallbackRoles [OWNER, ADMIN]. Relevant imports for PermissionCheckService and Prisma MembershipRole were added. deleteTeam.handler now requires ctx with user, validates team existence and parentId, and uses TRPCError for NOT_FOUND, BAD_REQUEST, and UNAUTHORIZED cases. The deleteTeam DeleteOptions type and handler signature were updated to include ctx. Core business flows (member additions, team creation, deletion sequence, publish behavior) remain unchanged.

Possibly related PRs

Pre-merge checks and finishing touches

❌ 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 clearly summarizes the main change by indicating a refactor to use the permission check service across all /organizations endpoints, which aligns with the changeset replacing direct admin checks with permission-based logic.
Description Check ✅ Passed The author’s description clearly states that the pull request replaces direct admin checks with a permission check service in the /organizations TRPC router, which directly corresponds to the changes summarized in each handler file where PermissionCheckService replaces isOrganisationAdmin logic. It also includes a self-review checklist and basic testing instructions, demonstrating it is on-topic and tied to the refactoring objectives. Therefore, the description is relevant and connected to the actual modifications.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/more-pbac-replacements

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.

@graphite-app graphite-app bot requested review from a team September 26, 2025 10:48
@hbjORbj hbjORbj marked this pull request as draft September 26, 2025 10:48
@keithwillcode keithwillcode added core area: core, team members only foundation labels Sep 26, 2025
@pull-request-size pull-request-size bot added size/M and removed size/S labels Sep 26, 2025
@vercel
Copy link

vercel bot commented Sep 26, 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 29, 2025 10:44am
cal-eu Ignored Ignored Sep 29, 2025 10:44am

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

🧹 Nitpick comments (5)
packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts (5)

76-85: PBAC check looks good; verify permission string and consider reusing a singleton.

  • Please confirm "organization.read" matches the previous admin/owner intent for this endpoint. If stricter access is desired, pick the closest permission accordingly.
  • Instantiate PermissionCheckService once at module scope to avoid per-call construction.

Apply this diff to reuse a module-scoped instance:

-  if (team.parent) {
-    const permissionCheckService = new PermissionCheckService();
+  if (team.parent) {
     isOrgAdminOrOwner = await permissionCheckService.checkPermission({
       userId: user.id,
       teamId: team.parent.id,
       permission: "organization.read",
       fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
     });
   }

Add this near the top-level (e.g., after appDataMap):

const permissionCheckService = new PermissionCheckService();

67-74: Minimize Prisma payload for membership check.
Select only what you need to align with repo guidelines and reduce I/O.

As per coding guidelines, apply:

   const isMember = await prisma.membership.findUnique({
     where: {
       userId_teamId: {
         userId: user.id,
         teamId: teamId,
       },
     },
+    select: { id: true },
   });

119-131: Eliminate N queries: fetch credentials with a single query and prefill empty arrays.
This reduces DB round-trips and ensures users with no apps still appear with [].

Apply:

-  const credentialsPromises: Promise<Credential[]>[] = [];
-  const userConnectedAppsMap: Record<number, Apps[]> = {};
-
-  for (const userId of userIds) {
-    const cred = prisma.credential.findMany({
-      where: {
-        userId,
-      },
-      select: credentialSelect,
-    });
-    credentialsPromises.push(cred);
-  }
-
-  const credentialsList = await Promise.all(credentialsPromises);
-
-  for (const credentials of credentialsList) {
-    const userId = credentials[0]?.userId;
-
-    if (userId) {
-      userConnectedAppsMap[userId] = credentials?.map((cred) => {
-        const appSlug = cred.app?.slug;
-        let appData = appDataMap.get(appSlug);
-
-        if (!appData) {
-          appData = getAppFromSlug(appSlug);
-          appDataMap.set(appSlug, appData);
-        }
-
-        const isCalendar = cred?.app?.categories?.includes("calendar") ?? false;
-        const externalId = isCalendar ? cred.destinationCalendars?.[0]?.externalId : null;
-        return {
-          name: appData?.name ?? null,
-          logo: appData?.logo ?? null,
-          app: cred.app,
-          externalId: externalId ?? null,
-        };
-      });
-    }
-  }
+  const userConnectedAppsMap: Record<number, Apps[]> = {};
+  for (const id of userIds) userConnectedAppsMap[id] = [];
+
+  const credentials = await prisma.credential.findMany({
+    where: { userId: { in: userIds } },
+    select: credentialSelect,
+  });
+
+  for (const cred of credentials) {
+    const appSlug = cred.app?.slug;
+    let appData = appSlug ? appDataMap.get(appSlug) : null;
+    if (appSlug && !appData) {
+      appData = getAppFromSlug(appSlug);
+      appDataMap.set(appSlug, appData);
+    }
+    const isCalendar = cred?.app?.categories?.includes("calendar") ?? false;
+    const externalId = isCalendar ? cred.destinationCalendars?.[0]?.externalId : null;
+    userConnectedAppsMap[cred.userId].push({
+      name: appData?.name ?? null,
+      logo: appData?.logo ?? null,
+      app: cred.app,
+      externalId: externalId ?? null,
+    });
+  }

Also applies to: 132-157


139-146: Avoid caching under an undefined slug key.
Guard against falsy slugs to prevent a single "undefined" cache bucket.

Apply:

-        let appData = appDataMap.get(appSlug);
-
-        if (!appData) {
-          appData = getAppFromSlug(appSlug);
-          appDataMap.set(appSlug, appData);
-        }
+        let appData = appSlug ? appDataMap.get(appSlug) : null;
+        if (appSlug && !appData) {
+          appData = getAppFromSlug(appSlug);
+          appDataMap.set(appSlug, appData);
+        }

Optionally also type the cache:

-const appDataMap = new Map();
+const appDataMap = new Map<string, ReturnType<typeof getAppFromSlug>>();

63-65: Use typed tRPC errors instead of generic Error.
Return proper codes (NOT_FOUND/FORBIDDEN) for better clients and telemetry.

Example:

import { TRPCError } from "@trpc/server";

if (!team) {
  throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}

if (!isMember && !isOrgAdminOrOwner) {
  throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized to access this team's connected apps" });
}

Also applies to: 87-89

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • 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 ae6b1d4 and 1f633e4.

📒 Files selected for processing (1)
  • packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts (2 hunks)
🧰 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/teams/getUserConnectedApps.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/teams/getUserConnectedApps.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/teams/getUserConnectedApps.handler.ts
🧠 Learnings (1)
📓 Common learnings
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).
🧬 Code graph analysis (1)
packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts (2)
apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts (1)
  • isOrgAdminOrOwner (38-47)
packages/features/pbac/services/permission-check.service.ts (1)
  • PermissionCheckService (19-306)
🔇 Additional comments (6)
packages/trpc/server/routers/viewer/teams/getUserConnectedApps.handler.ts (6)

2-2: LGTM: Using PermissionCheckService is the right direction.
Switching to PBAC-backed checks aligns with the ongoing refactor.


4-4: LGTM: MembershipRole enum usage is correct.
Enum-based fallback roles avoid string drift.


18-31: Credential selection avoids leaking credential.key.
Only whitelisted fields are selected; credential.key is not exposed. Good.

As per coding guidelines.


147-148: Confirm AppCategories literal.
Ensure "calendar" matches the AppCategories enum value; if an enum member exists, prefer it.

If available:

const isCalendar = cred?.app?.categories?.includes(AppCategories.CALENDAR) ?? false;

91-111: Authorization model check: can any team member view others’ connections?
Current logic allows any team member (not just admins/owners) to fetch connected apps for all members of the team. Confirm this is intended; otherwise, require isOrgAdminOrOwner.


162-162: Remove default export and use named export for getUserConnectedAppsHandler. Confirm no dynamic or path-based loader relies on the default export before applying this change.

@hbjORbj hbjORbj changed the title refactor: more pbac replacements refactor: use permission check service in more endpoints Sep 26, 2025
Comment on lines 81 to 82
teamId: team.parent.id,
permission: "organization.read",
Copy link
Contributor Author

@hbjORbj hbjORbj Sep 26, 2025

Choose a reason for hiding this comment

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

Q:

@sean-brydon permission should be team.read here because we are already in the scope of organization here (passuing parent.id to teamId arg), am I right?

@hbjORbj hbjORbj changed the title refactor: use permission check service in more endpoints refactor: use permission check service in /organizations endpoints Sep 26, 2025
@pull-request-size pull-request-size bot added size/L and removed size/M labels Sep 29, 2025
@hbjORbj hbjORbj marked this pull request as ready for review September 29, 2025 10:23
@dosubot dosubot bot added the organizations area: organizations, orgs label Sep 29, 2025
sean-brydon
sean-brydon previously approved these changes Sep 29, 2025
Copy link
Member

@sean-brydon sean-brydon left a comment

Choose a reason for hiding this comment

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

LGTM! Thanks a lot

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: 1

Caution

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

⚠️ Outside diff range comments (4)
packages/trpc/server/routers/viewer/organizations/utils.ts (2)

68-71: Bug: users partially in teams get skipped entirely.

filteredUserIds excludes a user if they’re in any one target team, preventing adding them to the remaining teams. Filter by (userId, teamId) pairs instead.

- // Filter out users who are already in teams they are being invited to
- const filteredUserIds = input.userIds.filter((userId) => {
-   return !usersInTeams.some((membership) => membership.userId === userId);
- });
+ // Fetch existing (userId, teamId) pairs and skip only those pairs
+ const existingPairs = await prisma.membership.findMany({
+   where: {
+     userId: { in: input.userIds },
+     teamId: { in: Array.from(allowedTeamIds) },
+   },
+   select: { userId: true, teamId: true },
+ });
+ const existing = new Set(existingPairs.map((m) => `${m.userId}:${m.teamId}`));
 
- const membershipData = filteredUserIds.flatMap((userId) =>
-   input.teamIds.map((teamId) => {
+ const membershipData = input.userIds.flatMap((userId) =>
+   Array.from(allowedTeamIds).flatMap((teamId) => {
+     if (existing.has(`${userId}:${teamId}`)) return [];
      const userMembership = usersInOrganization.find((membership) => membership.userId === userId);
      const accepted = userMembership && userMembership.accepted;
      return {
        createdAt: new Date(),
        userId,
        teamId,
        role: MembershipRole.MEMBER,
        accepted: accepted || false,
      } as Prisma.MembershipCreateManyInput;
    })
  );

Also applies to: 75-87


36-46: Minimize Prisma payloads with select.

Return only needed fields to reduce I/O, per repo guideline.

-const usersInOrganization = await prisma.membership.findMany({
+const usersInOrganization = await prisma.membership.findMany({
   where: {
     teamId: user.organizationId,
     user: {
       id: {
         in: input.userIds,
       },
     },
   },
-  distinct: ["userId"],
+  distinct: ["userId"],
+  select: { userId: true, accepted: true },
 });
 
-const usersInTeams = await prisma.membership.findMany({
+const usersInTeams = await prisma.membership.findMany({
   where: {
     userId: {
       in: input.userIds,
     },
-    teamId: {
-      in: input.teamIds,
-    },
+    teamId: { in: Array.from(allowedTeamIds) },
   },
-});
+  select: { userId: true, teamId: true },
+});

Also applies to: 56-66

packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts (1)

38-47: Validate ownership: event types must belong to provided teams within the org (security).

Without this, callers can add hosts to event types outside their org/teams.

-  const { eventTypeIds, teamIds, userIds } = input;
+  const { eventTypeIds, teamIds, userIds } = input;
+
+  // Ensure all teams belong to the caller's organization
+  const allowedTeams = await prisma.team.findMany({
+    where: { id: { in: teamIds }, parentId: ctx.user.organizationId },
+    select: { id: true },
+  });
+  const allowedTeamIds = new Set(allowedTeams.map((t) => t.id));
+  if (allowedTeams.length !== teamIds.length) {
+    throw new TRPCError({ code: "BAD_REQUEST", message: "One or more teams are not in this organization" });
+  }
+
+  // Ensure all event types belong to those teams
+  const allowedEventTypes = await prisma.eventType.findMany({
+    where: { id: { in: eventTypeIds }, teamId: { in: Array.from(allowedTeamIds) } },
+    select: { id: true },
+  });
+  if (allowedEventTypes.length !== eventTypeIds.length) {
+    throw new TRPCError({ code: "BAD_REQUEST", message: "One or more event types are not in the provided teams" });
+  }
packages/trpc/server/routers/viewer/organizations/publish.handler.ts (1)

40-46: Replace include with select/_count to avoid loading all members.

Per repo guideline: never use include; select only what you need. You only need the count.

-const prevTeam = await prisma.team.findUnique({
-  where: {
-    id: orgId,
-  },
-  include: { members: true },
-});
+const prevTeam = await prisma.team.findUnique({
+  where: { id: orgId },
+  select: {
+    id: true,
+    metadata: true,
+    _count: { select: { members: true } },
+  },
+});
@@
-  const checkoutSession = await purchaseTeamOrOrgSubscription({
-    teamId: prevTeam.id,
-    seatsUsed: prevTeam.members.length,
+  const checkoutSession = await purchaseTeamOrOrgSubscription({
+    teamId: prevTeam.id,
+    seatsUsed: prevTeam._count.members,

Also applies to: 55-59

🧹 Nitpick comments (14)
packages/trpc/server/routers/viewer/organizations/utils.ts (2)

89-95: Hardening: use skipDuplicates and await async updates.

  • createMany should use skipDuplicates to handle races.
  • forEach with async isn’t awaited; use Promise.all.
-await prisma.membership.createMany({
-  data: membershipData,
-});
+await prisma.membership.createMany({
+  data: membershipData,
+  skipDuplicates: true,
+});
-
-membershipData.forEach(async ({ userId, teamId }) => {
-  await updateNewTeamMemberEventTypes(userId, teamId);
-});
+await Promise.all(
+  membershipData.map(({ userId, teamId }) =>
+    updateNewTeamMemberEventTypes(userId, teamId)
+  )
+);

29-34: Return 403 (FORBIDDEN) for permission failures.

User is authenticated but lacks permission; prefer FORBIDDEN over UNAUTHORIZED for consistency with other handlers.

-  code: "UNAUTHORIZED",
+  code: "FORBIDDEN",
packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts (4)

31-36: Use 403 (FORBIDDEN) when permission check fails.

User is authenticated but not allowed.

-  code: "UNAUTHORIZED",
+  code: "FORBIDDEN",

57-61: LGTM on skipDuplicates; consider transaction for atomicity.

If you want add-to-teams and add-as-host to be atomic, wrap addMembersToTeams and createMany in a $transaction. Otherwise fine.


64-64: Prefer named exports over default exports.

Improves tree-shaking and refactors. Change at call sites accordingly.

-export default addMembersToEventTypesHandler;
+// export { addMembersToEventTypesHandler };

1-5: Import consistency for MembershipRole/prisma (minor).

Elsewhere MembershipRole often comes from @calcom/prisma/enums and prisma from named import. Consider standardizing across the PR.

packages/trpc/server/routers/viewer/organizations/publish.handler.ts (3)

33-38: Use 403 (FORBIDDEN) for permission failure.

Authenticated user lacking permission should yield FORBIDDEN.

-  code: "UNAUTHORIZED",
+  code: "FORBIDDEN",

97-101: Nit: success message reads “Team” instead of “Organization”.

Align the message with the flow.

-  message: "Team published successfully",
+  message: "Organization published successfully",

103-103: Prefer named export over default (optional).

-export default publishHandler;
+// export { publishHandler };
packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts (2)

54-60: Minor: consistent import source for MembershipRole across files.

Some files import from @calcom/prisma/client, others from @calcom/prisma/enums. Consider standardizing.


157-157: Prefer named exports (optional).

Improves tree-shaking/refactors.

-export default createTeamsHandler;
+// export { createTeamsHandler };
packages/trpc/server/routers/viewer/organizations/deleteTeam.handler.ts (3)

43-48: Use 403 (FORBIDDEN) for permission failure.

User is authenticated; lack of permission should not be 401.

-  code: "UNAUTHORIZED",
+  code: "FORBIDDEN",

50-61: Wrap deletions in a transaction to avoid partial state.

If team delete fails after memberships delete, you’ll strand the team. Use $transaction.

-// delete all memberships
-await prisma.membership.deleteMany({
-  where: { teamId: input.teamId },
-});
-
-await prisma.team.delete({
-  where: { id: input.teamId },
-});
+await prisma.$transaction([
+  prisma.membership.deleteMany({ where: { teamId: input.teamId } }),
+  prisma.team.delete({ where: { id: input.teamId } }),
+]);

64-64: Prefer named export over default (optional).

-export default deleteTeamHandler;
+// export { deleteTeamHandler };
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • 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 1f633e4 and d201db8.

📒 Files selected for processing (5)
  • packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/organizations/deleteTeam.handler.ts (1 hunks)
  • packages/trpc/server/routers/viewer/organizations/publish.handler.ts (2 hunks)
  • packages/trpc/server/routers/viewer/organizations/utils.ts (2 hunks)
🧰 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/organizations/publish.handler.ts
  • packages/trpc/server/routers/viewer/organizations/utils.ts
  • packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
  • packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts
  • packages/trpc/server/routers/viewer/organizations/deleteTeam.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/organizations/publish.handler.ts
  • packages/trpc/server/routers/viewer/organizations/utils.ts
  • packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
  • packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts
  • packages/trpc/server/routers/viewer/organizations/deleteTeam.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/organizations/publish.handler.ts
  • packages/trpc/server/routers/viewer/organizations/utils.ts
  • packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
  • packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts
  • packages/trpc/server/routers/viewer/organizations/deleteTeam.handler.ts
🧠 Learnings (2)
📚 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/organizations/publish.handler.ts
  • packages/trpc/server/routers/viewer/organizations/utils.ts
  • packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
  • packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts
  • packages/trpc/server/routers/viewer/organizations/deleteTeam.handler.ts
📚 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/organizations/utils.ts
  • packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts
🧬 Code graph analysis (5)
packages/trpc/server/routers/viewer/organizations/publish.handler.ts (2)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (19-306)
  • hasPermission (183-201)
packages/platform/libraries/index.ts (2)
  • MembershipRole (34-34)
  • TRPCError (66-66)
packages/trpc/server/routers/viewer/organizations/utils.ts (1)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (19-306)
  • hasPermission (183-201)
packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts (2)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (19-306)
  • hasPermission (183-201)
packages/platform/libraries/index.ts (1)
  • MembershipRole (34-34)
packages/trpc/server/routers/viewer/organizations/addMembersToEventTypes.handler.ts (1)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (19-306)
  • hasPermission (183-201)
packages/trpc/server/routers/viewer/organizations/deleteTeam.handler.ts (1)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (19-306)
  • hasPermission (183-201)
⏰ 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
🔇 Additional comments (1)
packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts (1)

45-52: Authorization check approved and permission string validated. All references to team.create are consistent and defined in PERMISSIONS.md.

Comment on lines 20 to 35
// Check if user has permission to invite team members in the organization
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: user.organizationId,
permission: "team.invite",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});

if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to add members to teams in this organization",
});
}

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Enforce org scoping for target teams (security).

You check permission at the org, but you never verify that every teamId in input.teamIds belongs to user.organizationId. As-is, a caller could add members to teams outside the org. Validate and constrain teamIds to the actor’s org before proceeding.

Apply a guard right after the permission check and use the validated set in subsequent queries:

   // Check if user has permission to invite team members in the organization
   const permissionCheckService = new PermissionCheckService();
   const hasPermission = await permissionCheckService.checkPermission({
     userId: user.id,
     teamId: user.organizationId,
     permission: "team.invite",
     fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
   });

   if (!hasPermission) {
     throw new TRPCError({
       code: "UNAUTHORIZED",
       message: "You are not authorized to add members to teams in this organization",
     });
   }
+
+  // Verify all target teams belong to this organization
+  const allowedTeams = await prisma.team.findMany({
+    where: { id: { in: input.teamIds }, parentId: user.organizationId },
+    select: { id: true },
+  });
+  const allowedTeamIds = new Set(allowedTeams.map((t) => t.id));
+  if (allowedTeams.length !== input.teamIds.length) {
+    throw new TRPCError({
+      code: "BAD_REQUEST",
+      message: "One or more teams do not belong to your organization",
+    });
+  }

Based on learnings.

📝 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 if user has permission to invite team members in the organization
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: user.organizationId,
permission: "team.invite",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to add members to teams in this organization",
});
}
// Check if user has permission to invite team members in the organization
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: user.organizationId,
permission: "team.invite",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to add members to teams in this organization",
});
}
// Verify all target teams belong to this organization
const allowedTeams = await prisma.team.findMany({
where: { id: { in: input.teamIds }, parentId: user.organizationId },
select: { id: true },
});
const allowedTeamIds = new Set(allowedTeams.map((t) => t.id));
if (allowedTeams.length !== input.teamIds.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more teams do not belong to your organization",
});
}
🤖 Prompt for AI Agents
In packages/trpc/server/routers/viewer/organizations/utils.ts around lines 20 to
35, you check team.invite permission at the org level but never verify that each
teamId in input.teamIds actually belongs to user.organizationId; add a guard
immediately after the permission check that queries the teams table for rows
with id IN input.teamIds AND organizationId = user.organizationId, compare the
returned IDs to input.teamIds and either filter to the validated set or throw a
TRPCError if any requested teamId is outside the org, and then use the
validatedTeamIds in all subsequent operations (invites/updates) so no
external-org team can be modified.

@sean-brydon sean-brydon merged commit 72dd970 into main Sep 29, 2025
38 checks passed
@sean-brydon sean-brydon deleted the refactor/more-pbac-replacements branch September 29, 2025 11:07
@github-actions
Copy link
Contributor

E2E results are ready!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core area: core, team members only foundation organizations area: organizations, orgs ready-for-e2e 💻 refactor size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants