Skip to content

Comments

fix: team admin/owner can't change others member role#23161

Merged
anikdhabal merged 4 commits intocalcom:mainfrom
anikdhabal:unable-to-change-role
Aug 18, 2025
Merged

fix: team admin/owner can't change others member role#23161
anikdhabal merged 4 commits intocalcom:mainfrom
anikdhabal:unable-to-change-role

Conversation

@anikdhabal
Copy link
Contributor

@anikdhabal anikdhabal commented Aug 18, 2025

What does this PR do?

Fixes an issue where an org member who is a team admin/owner couldn’t change other team members' roles

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 18, 2025

Walkthrough

IRoleManager.checkPermissionToChangeRole signature changed from (userId, organizationId) to (userId, targetId, scope: "org" | "team"). PBACRoleManager now invokes PBAC checks with teamId = targetId and permission = (scope === "team" ? "team.changeMemberRole" : "organization.changeMemberRole") and falls back to OWNER/ADMIN roles; LegacyRoleManager resolves membership using isTeamAdmin(userId, targetId) or isOrganisationAdmin(userId, targetId) based on scope. Both paths throw RoleManagementError with UNAUTHORIZED on failure and retain their existing messages. Handlers that change member roles (team/org) were updated to pass the new scope argument and removed the earlier pre-check in the team handler.

Possibly related PRs

  • feat: assign custom roles #22662: Modifies the same role-management.factory.ts (role manager API) and related team role-change logic—adds team-aware role handling and expands assign/getTeamRoles behavior.

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@graphite-app graphite-app bot requested a review from a team August 18, 2025 12:33
@keithwillcode keithwillcode added the core area: core, team members only label Aug 18, 2025
@vercel
Copy link

vercel bot commented Aug 18, 2025

@anikdhabal is attempting to deploy a commit to the cal Team on Vercel.

A member of the Team first needs to authorize it.

@dosubot dosubot bot added teams area: teams, round robin, collective, managed event-types 🐛 bug Something isn't working labels Aug 18, 2025
@graphite-app
Copy link

graphite-app bot commented Aug 18, 2025

Graphite Automations

"Add consumer team as reviewer" took an action on this PR • (08/18/25)

1 reviewer was added to this PR based on Keith Williams's automation.

"Add ready-for-e2e label" took an action on this PR • (08/18/25)

1 label was added to this PR based on Keith Williams's automation.

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

🔭 Outside diff range comments (2)
packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts (1)

109-117: Avoid Prisma include; use select with a minimal safe shape

Per repo guidelines: “only select data you need; never use include”. Returning entire team and user can leak unnecessary PII and bloat the response.

-  const updatedMembership = await prisma.membership.findUnique({
-    where: {
-      userId_teamId: { userId: input.memberId, teamId: input.teamId },
-    },
-    include: {
-      team: true,
-      user: true,
-    },
-  });
+  const updatedMembership = await prisma.membership.findUnique({
+    where: {
+      userId_teamId: { userId: input.memberId, teamId: input.teamId },
+    },
+    select: {
+      id: true,
+      role: true,
+      teamId: true,
+      userId: true,
+      // Adjust fields below to what the client actually consumes
+      team: {
+        select: { id: true, name: true, slug: true, isOrganization: true },
+      },
+      user: {
+        select: { id: true, name: true, username: true }, // avoid unnecessary PII
+      },
+    },
+  });

If the client needs additional fields, add them explicitly under select.

packages/features/pbac/services/role-management.factory.ts (1)

1-5: Missing import: isTeamAdmin is used but not imported (compile error)

LegacyRoleManager.checkPermissionToChangeRole calls isTeamAdmin without an import.

 import { FeaturesRepository } from "@calcom/features/flags/features.repository";
 import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
+import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
 import { prisma } from "@calcom/prisma";
 import { MembershipRole } from "@calcom/prisma/enums";
🧹 Nitpick comments (4)
packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts (3)

55-59: Narrow membership query: select only required fields and filter accepted members

This reduces payload, aligns with Prisma guidelines, and avoids counting pending invites in ownership/role checks.

-  const memberships = await prisma.membership.findMany({
-    where: {
-      teamId: input.teamId,
-    },
-  });
+  const memberships = await prisma.membership.findMany({
+    where: {
+      teamId: input.teamId,
+      accepted: true,
+    },
+    select: {
+      id: true,
+      userId: true,
+      role: true,
+    },
+  });

49-52: Minor: ctx.user is NonNullable; drop optional chaining

ctx.user is typed as NonNullable<TrpcSessionUser>, so ctx.user?.id is unnecessary.

-    if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
+    if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
@@
-    if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
+    if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user.id, input.teamId)))

35-35: Use team.isOrganization for PBAC permission scope

Verified that TeamRepository.findById includes the isOrganization flag. Pass that to checkPermissionToChangeRole so PBAC uses the correct permission (organization.changeMemberRole vs team.changeMemberRole).

• File: packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts
Line: 35

- await roleManager.checkPermissionToChangeRole(ctx.user.id, input.teamId, false);
+ await roleManager.checkPermissionToChangeRole(ctx.user.id, input.teamId, team.isOrganization);
packages/features/pbac/services/role-management.factory.ts (1)

52-80: Minor naming confusion in PBAC assignRole: organizationId actually represents teamId

The parameter name can mislead readers. Consider renaming the parameter (and interface param) to teamId for clarity in a follow-up refactor. No functional issue.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • 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 a8a3a93 and 018d96b.

📒 Files selected for processing (2)
  • packages/features/pbac/services/role-management.factory.ts (3 hunks)
  • packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.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/changeMemberRole.handler.ts
  • packages/features/pbac/services/role-management.factory.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/changeMemberRole.handler.ts
  • packages/features/pbac/services/role-management.factory.ts
🧠 Learnings (1)
📚 Learning: 2025-08-17T22:00:16.304Z
Learnt from: Udit-takkar
PR: calcom/cal.com#22995
File: packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts:117-126
Timestamp: 2025-08-17T22:00:16.304Z
Learning: In calcom/cal.com PR #22995, packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts, the enabled input parameter in the update endpoint is intentionally not forwarded to aiService.updateAgentConfiguration() as the enabled/disabled agent functionality is not required at the moment (per maintainer Udit-takkar). Future reviews should not flag this as missing functionality unless requirements change.

Applied to files:

  • packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts
🧬 Code Graph Analysis (1)
packages/features/pbac/services/role-management.factory.ts (3)
packages/features/pbac/services/permission-check.service.ts (1)
  • hasPermission (205-234)
packages/lib/server/queries/organisations/index.ts (1)
  • isOrganisationAdmin (7-17)
packages/lib/server/queries/teams/index.ts (1)
  • isTeamAdmin (387-407)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Detect changes
🔇 Additional comments (4)
packages/features/pbac/services/role-management.factory.ts (4)

13-13: Interface change to target-based permission check looks good

The generalized signature increases flexibility across team/org contexts.


102-110: Legacy path: logic is right; ensure parity with org/team checks

Good fallback to org vs team admin checks. Once the missing import is added, this will behave as intended.


174-181: Factory wiring LGTM

Feature-flag-based manager selection is clean and preserves existing behavior.


32-41: checkPermission call is correct for both teams and organizations
We’ve verified that PermissionCheckService.checkPermission always expects a teamId field—there is no separate organizationId parameter—so passing teamId: targetId is the intended shape even when isOrganization is true. The permission string toggle is sufficient to distinguish org-scoped checks.

Optional nitpick: consider renaming targetIdresourceId for clearer intent.

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

🧹 Nitpick comments (2)
packages/features/pbac/services/role-management.factory.ts (1)

32-41: Clarify scope semantics and guard against misuse of targetId.

The method takes a generic targetId and always passes it to PermissionCheckService as teamId, switching permission string by checkAtTeamLevel. This works if targetId is “the team’s id” for team-level checks and “the organization team’s id” for org-level checks. To avoid future misuse:

  • Add a brief JSDoc clarifying that targetId must be the team id for team checks and the organization (team) id for org checks.
  • Optionally default the flag to false in the implementation for clarity.
  • Consider renaming targetId to teamOrOrgId for self-documentation.

Example minimal tweak:

-  async checkPermissionToChangeRole(
-    userId: number,
-    targetId: number,
-    checkAtTeamLevel?: boolean
-  ): Promise<void> {
+  /**
+   * targetId: pass the team id if checkAtTeamLevel=true, otherwise the organization (team) id.
+   */
+  async checkPermissionToChangeRole(
+    userId: number,
+    targetId: number,
+    checkAtTeamLevel = false
+  ): Promise<void> {
packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts (1)

106-115: Prisma include → select per repo guidelines. Only select the fields you need.

Current code returns full team and user records. To adhere to “never use include; always use select” and minimize PII exposure, select only required fields.

Apply a tailored select. Example (adjust fields to consumer needs):

   const updatedMembership = await prisma.membership.findUnique({
     where: {
       userId_teamId: { userId: input.memberId, teamId: input.teamId },
     },
-    include: {
-      team: true,
-      user: true,
-    },
+    select: {
+      userId: true,
+      teamId: true,
+      role: true,
+      team: {
+        select: { id: true, name: true, slug: true, parentId: true, isOrganization: true },
+      },
+      user: {
+        select: { id: true, username: true, name: true, email: true },
+      },
+    },
   });

If email is not required by the caller, omit it to further reduce exposure.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 018d96b and 5f02637.

📒 Files selected for processing (2)
  • packages/features/pbac/services/role-management.factory.ts (3 hunks)
  • packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.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/changeMemberRole.handler.ts
  • packages/features/pbac/services/role-management.factory.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/changeMemberRole.handler.ts
  • packages/features/pbac/services/role-management.factory.ts
🧠 Learnings (1)
📚 Learning: 2025-08-17T22:00:16.304Z
Learnt from: Udit-takkar
PR: calcom/cal.com#22995
File: packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts:117-126
Timestamp: 2025-08-17T22:00:16.304Z
Learning: In calcom/cal.com PR #22995, packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts, the enabled input parameter in the update endpoint is intentionally not forwarded to aiService.updateAgentConfiguration() as the enabled/disabled agent functionality is not required at the moment (per maintainer Udit-takkar). Future reviews should not flag this as missing functionality unless requirements change.

Applied to files:

  • packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts
🧬 Code Graph Analysis (1)
packages/features/pbac/services/role-management.factory.ts (3)
packages/features/pbac/services/permission-check.service.ts (1)
  • hasPermission (205-234)
packages/lib/server/queries/teams/index.ts (1)
  • isTeamAdmin (387-407)
packages/lib/server/queries/organisations/index.ts (1)
  • isOrganisationAdmin (7-17)
🔇 Additional comments (4)
packages/features/pbac/services/role-management.factory.ts (1)

13-14: All call-sites use the updated signature correctly.
The organization handler omits the optional checkAtTeamLevel flag (defaults to false), the team handler passes true, and all tests invoke the two-arg form as intended. No further updates required.

packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts (3)

2-2: Import cleanup looks good.

Switching to only import isTeamOwner is consistent with the removal of the pre isTeamAdmin check.


33-41: Using the role manager’s permission check at team scope is aligned with the PR goal.

Calling checkPermissionToChangeRole(ctx.user.id, input.teamId, true) enforces team-level permissions and fixes the inability of team admins/owners to change member roles.

Please confirm this matches product expectations for org-level roles: with this change, an org owner/admin who is not a member of the subteam still cannot change that team’s members. If cross-team control by org owners is desired, we could fall back to an org-level check when the team-level check fails.


49-51: Owner gating retained — good.

Preserving the “only owners can award owner role” rule maintains current security posture while PBAC rolls out.

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

♻️ Duplicate comments (1)
packages/features/pbac/services/role-management.factory.ts (1)

3-3: Fixed missing import for isTeamAdmin — good catch

This resolves the compile-time error flagged earlier and enables the legacy team-level admin check.

🧹 Nitpick comments (1)
packages/features/pbac/services/role-management.factory.ts (1)

14-14: Nit: clarify parameter name targetId → teamOrOrgId for readability

The current name is ambiguous. Renaming improves self-documentation without changing behavior.

Apply:

-  checkPermissionToChangeRole(userId: number, targetId: number, checkAtTeamLevel?: boolean): Promise<void>;
+  checkPermissionToChangeRole(userId: number, teamOrOrgId: number, checkAtTeamLevel?: boolean): Promise<void>;
-  async checkPermissionToChangeRole(
-    userId: number,
-    targetId: number,
-    checkAtTeamLevel?: boolean
-  ): Promise<void> {
+  async checkPermissionToChangeRole(
+    userId: number,
+    teamOrOrgId: number,
+    checkAtTeamLevel?: boolean
+  ): Promise<void> {
@@
-      teamId: targetId,
+      teamId: teamOrOrgId,
-  async checkPermissionToChangeRole(
-    userId: number,
-    targetId: number,
-    checkAtTeamLevel?: boolean
-  ): Promise<void> {
+  async checkPermissionToChangeRole(
+    userId: number,
+    teamOrOrgId: number,
+    checkAtTeamLevel?: boolean
+  ): Promise<void> {
@@
-    const membership = checkAtTeamLevel
-      ? !!(await isTeamAdmin(userId, targetId))
-      : !!(await isOrganisationAdmin(userId, targetId));
+    const membership = checkAtTeamLevel
+      ? !!(await isTeamAdmin(userId, teamOrOrgId))
+      : !!(await isOrganisationAdmin(userId, teamOrOrgId));

Also applies to: 33-37, 40-41, 103-111

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5f02637 and 1658265.

📒 Files selected for processing (1)
  • packages/features/pbac/services/role-management.factory.ts (4 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.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/features/pbac/services/role-management.factory.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/features/pbac/services/role-management.factory.ts
🧬 Code Graph Analysis (1)
packages/features/pbac/services/role-management.factory.ts (3)
packages/features/pbac/services/permission-check.service.ts (1)
  • hasPermission (205-234)
packages/lib/server/queries/teams/index.ts (1)
  • isTeamAdmin (387-407)
packages/lib/server/queries/organisations/index.ts (1)
  • isOrganisationAdmin (7-17)
🔇 Additional comments (3)
packages/features/pbac/services/role-management.factory.ts (3)

103-111: Legacy path gating looks correct

Switching between isTeamAdmin and isOrganisationAdmin based on checkAtTeamLevel cleanly fixes the permission issue for legacy setups.


33-42: Ignore PBAC scope‐mapping suggestion: PermissionCheck only supports teamId
The PermissionCheck interface (packages/features/pbac/domain/models/Permission.ts) defines only teamId (no organizationId), and PermissionCheckService never accepts an organizationId field. Organization‐level checks are driven solely by the permission string ("organization.changeMemberRole"), so passing teamId: targetId in both branches is correct.

Likely an incorrect or invalid review comment.


14-14: Signature change: Add explicit scope argument
The method checkPermissionToChangeRole now takes a third boolean parameter (checkAtTeamLevel). Please confirm that no call sites are still invoking it with only two arguments. For example, you can run:

# Find any two-arg invocations that need updating
rg -nP --include '*.ts' 'checkPermissionToChangeRole\(\s*[^,]+,\s*[^,]+\)'

If this returns any results, update those calls to include the new checkAtTeamLevel argument.

Copy link
Member

@CarinaWolli CarinaWolli left a comment

Choose a reason for hiding this comment

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

Fix works, but can you take a look at eunjae's suggestion

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

🔭 Outside diff range comments (4)
packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts (3)

44-45: Typo in error message: “organizaiton” → “organization”

User-facing error text should be correct.

Apply:

-  if (!organizationId)
-    throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be a member of an organizaiton" });
+  if (!organizationId)
+    throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be a member of an organization" });

60-95: Use Prisma select instead of include; narrow fields and filter relations

Per guidelines, avoid include. Also, only fetch the fields you actually use:

  • You only need membership.id, user.profiles.username for this organization, and for children: the membership for the same user to derive teamIds.
  • Remove user.username (unused).
  • Filter profiles by organizationId to avoid reading an unrelated profile at index 0.

Apply:

-  const requestedMember = await prisma.membership.findFirst({
+  const requestedMember = await prisma.membership.findFirst({
     where: {
       userId: input.userId,
       teamId: organizationId,
       accepted: true,
     },
-    include: {
-      team: {
-        include: {
-          children: {
-            where: {
-              members: {
-                some: {
-                  userId: input.userId,
-                },
-              },
-            },
-            include: {
-              members: true,
-            },
-          },
-        },
-      },
-      user: {
-        select: {
-          username: true,
-          profiles: {
-            select: {
-              username: true,
-            },
-          },
-        },
-      },
-    },
+    select: {
+      id: true, // membership id
+      team: {
+        select: {
+          children: {
+            where: {
+              members: {
+                some: { userId: input.userId },
+              },
+            },
+            select: {
+              members: {
+                where: { userId: input.userId },
+                select: { teamId: true, userId: true },
+              },
+            },
+          },
+        },
+      },
+      user: {
+        select: {
+          profiles: {
+            where: { organizationId },
+            select: { username: true },
+          },
+        },
+      },
+    },
   });

Follow-up: With the above change, the downstream usage stays valid:

  • requestedMember.id is selected
  • requestedMember.user.profiles[0]?.username remains correct
  • requestedMember.team.children[].members contains only the target user, so the map/find still works (can even be simplified later).

174-181: Dead/unreachable condition: PBAC flag inversion likely intended

Comment says “we know pbac isn’t enabled on this instance,” but the condition currently requires PBAC to be enabled. In PBAC mode, input.role is typically a custom/default role ID string, so checkAdminOrOwner(input.role as MembershipRole) will be false and this block won’t run. In legacy mode (where the block makes sense), roleManager.isPBACEnabled is false, so the block still won’t run.

Switch to negated PBAC flag.

Apply:

-  if (checkAdminOrOwner(input.role as MembershipRole) && roleManager.isPBACEnabled) {
+  if (checkAdminOrOwner(input.role as MembershipRole) && !roleManager.isPBACEnabled) {
     const teamIds = requestedMember.team.children
       .map((sub_team) => sub_team.members.find((item) => item.userId === input.userId)?.teamId)
       .filter(Boolean) as number[]; //filter out undefined

     await applyRoleToAllTeams(input.userId, teamIds, input.role as MembershipRole);
   }
packages/features/pbac/services/__tests__/role-management.factory.test.ts (1)

104-129: Add coverage for “team” scope and assert permission slug routing

The new scope adds meaningful behavior: PBAC should check "team.changeMemberRole" and Legacy should consult isTeamAdmin. Add tests to prevent regressions.

Here’s a minimal set of tests to add:

describe("PBACRoleManager - team scope", () => {
  it("should call checkPermission with team.changeMemberRole", async () => {
    mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
    mockPermissionCheckService.checkPermission.mockResolvedValue(true);
    const manager = await factory.createRoleManager(organizationId);
    await manager.checkPermissionToChangeRole(userId, 42, "team");
    expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
      userId,
      teamId: 42,
      permission: "team.changeMemberRole",
      fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
    });
  });
});

describe("LegacyRoleManager - team scope", () => {
  it("should allow when isTeamAdmin returns truthy", async () => {
    mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
    // Mock isTeamAdmin to return truthy (object or boolean true)
    const { isTeamAdmin } = await import("@calcom/lib/server/queries/teams");
    vi.mocked(isTeamAdmin).mockResolvedValue({ id: 1 } as any);
    const manager = await factory.createRoleManager(organizationId);
    await expect(manager.checkPermissionToChangeRole(userId, 42, "team")).resolves.not.toThrow();
  });
});

Also consider asserting the exact error message for the team scope unauthorized path mirrors the org scope behavior (UNAUTHORIZED).

Also applies to: 184-213

♻️ Duplicate comments (1)
packages/features/pbac/services/role-management.factory.ts (1)

3-3: Missing import previously identified — now correctly added

Importing isTeamAdmin resolves the earlier compile error and enables team-scope checks in LegacyRoleManager.

🧹 Nitpick comments (3)
packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts (1)

163-171: Map RoleManagementError from assignRole to proper TRPC codes

assignRole (PBAC path) can throw RoleManagementError with INVALID_ROLE. Currently this would bubble as an internal error; better to convert to a client-safe TRPC error.

Apply:

-  await roleManager.assignRole(input.userId, organizationId, input.role, requestedMember.id);
+  try {
+    await roleManager.assignRole(input.userId, organizationId, input.role, requestedMember.id);
+  } catch (error) {
+    if (error instanceof RoleManagementError) {
+      // INVALID_ROLE -> BAD_REQUEST, others -> UNAUTHORIZED
+      const code = (error as RoleManagementError).code === "INVALID_ROLE" ? "BAD_REQUEST" : "UNAUTHORIZED";
+      throw new TRPCError({ code, message: error.message });
+    }
+    throw error;
+  }

Also add this import at the top if you choose to branch on the enum directly:

// add near other imports
import { RoleManagementErrorCode } from "@calcom/features/pbac/domain/errors/role-management.error";
packages/features/pbac/services/__tests__/role-management.factory.test.ts (1)

1-235: Double-check toThrow Error instance usage

These tests assert with toThrow(new RoleManagementError(...)). Some runners prefer toThrow(RoleManagementError) or toThrowErrorMatchingInlineSnapshot/Regex. If you see flakiness, switch to type or message-based assertions.

packages/features/pbac/services/role-management.factory.ts (1)

155-159: Minor readability nit: avoid comma operator in constructor

This is valid but unconventional; separate statements improve clarity.

-  private constructor() {
-    (this.featuresRepository = new FeaturesRepository(prisma)), (this.roleService = new RoleService());
-    this.permissionCheckService = new PermissionCheckService();
-  }
+  private constructor() {
+    this.featuresRepository = new FeaturesRepository(prisma);
+    this.roleService = new RoleService();
+    this.permissionCheckService = new PermissionCheckService();
+  }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1658265 and 95143bb.

📒 Files selected for processing (4)
  • packages/features/pbac/services/__tests__/role-management.factory.test.ts (2 hunks)
  • packages/features/pbac/services/role-management.factory.ts (4 hunks)
  • packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts (1 hunks)
  • packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.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/updateUser.handler.ts
  • packages/features/pbac/services/role-management.factory.ts
  • packages/features/pbac/services/__tests__/role-management.factory.test.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/updateUser.handler.ts
  • packages/features/pbac/services/role-management.factory.ts
  • packages/features/pbac/services/__tests__/role-management.factory.test.ts
🧬 Code Graph Analysis (2)
packages/features/pbac/services/role-management.factory.ts (3)
packages/features/pbac/services/permission-check.service.ts (1)
  • hasPermission (205-234)
packages/lib/server/queries/teams/index.ts (1)
  • isTeamAdmin (387-407)
packages/lib/server/queries/organisations/index.ts (1)
  • isOrganisationAdmin (7-17)
packages/features/pbac/services/__tests__/role-management.factory.test.ts (1)
packages/lib/server/queries/organisations/index.ts (1)
  • isOrganisationAdmin (7-17)
🔇 Additional comments (10)
packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts (1)

49-51: No remaining outdated calls — LGTM

  • Ran rg -nP --type=ts -C2 '\bcheckPermissionToChangeRole\s*\(\s*[^,]+,\s*[^,]+\s*\)' and found no two-argument invocations.
  • Please manually inspect any multi-line or computed call sites to ensure all usages include the new scope parameter.
packages/features/pbac/services/__tests__/role-management.factory.test.ts (5)

113-116: PBAC “org” scope test updated — LGTM

Using the third argument "org" matches the new API and validates the allow path with PBAC enabled.


121-127: PBAC unauthorized “org” scope test updated — LGTM

Test correctly reflects the new signature and expected error.


198-201: Legacy “org” scope allow path updated — LGTM

Validates OWNER path under legacy gate with the updated method signature.


206-212: Legacy unauthorized “org” scope test updated — LGTM

Signature change is reflected and expected error remains unchanged.


1-235: All checkPermissionToChangeRole calls now include the new scope argument

Verified that every invocation—both in the TRPC handlers and in the PBAC factory tests—passes three parameters (userId, targetId, and the "org"/"team" scope). No leftover two-argument calls remain.

packages/features/pbac/services/role-management.factory.ts (4)

12-15: Interface signature updated — LGTM

IRoleManager now models scope explicitly; this improves clarity vs. prior boolean flags.


99-111: Legacy membership resolution by scope — LGTM

Switches to isTeamAdmin for "team" and isOrganisationAdmin for "org". Throws correct UNAUTHORIZED message on failure, preserving legacy behavior.


119-130: Legacy assignRole updates membership.role — sanity check

This sets MembershipRole directly; in PBAC we assign custom roles via RoleService. Current split remains coherent.


33-47: Verify and centralize permission slugs

  • Confirm that the permission identifiers "team.changeMemberRole" and "organization.changeMemberRole" are defined in your PBAC permission catalog; if they’re missing, all checks will fail.
  • To avoid typos and streamline future updates, extract these strings into a shared constant or enum. For example:
// packages/features/pbac/constants/permissions.ts
export const PERMISSIONS = {
  TeamChangeMemberRole: "team.changeMemberRole" as const,
  OrgChangeMemberRole:    "organization.changeMemberRole" as const,
};

Then update your factory to use:

permission: scope === "team"
  ? PERMISSIONS.TeamChangeMemberRole
  : PERMISSIONS.OrgChangeMemberRole,

@anikdhabal anikdhabal enabled auto-merge (squash) August 18, 2025 14:28
@anikdhabal anikdhabal merged commit 7e6d94c into calcom:main Aug 18, 2025
52 of 57 checks passed
@anikdhabal anikdhabal deleted the unable-to-change-role branch August 18, 2025 14:57
@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

🐛 bug Something isn't working core area: core, team members only ready-for-e2e teams area: teams, round robin, collective, managed event-types

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants