Skip to content

Comments

chore: refactor role manager factory classes#24146

Merged
sean-brydon merged 1 commit intomainfrom
chore/refactor-role-manager
Sep 30, 2025
Merged

chore: refactor role manager factory classes#24146
sean-brydon merged 1 commit intomainfrom
chore/refactor-role-manager

Conversation

@sean-brydon
Copy link
Member

What does this PR do?

PR refactors role manager factory into seperate folders for better SOC

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 29, 2025

Walkthrough

This change extracts role-management into dedicated modules. It adds IRoleManager (role-manager.interface.ts) and two implementations: LegacyRoleManager (isPBACEnabled=false) and PBACRoleManager (isPBACEnabled=true). LegacyRoleManager validates role changes with explicit rules and uses Prisma to assign roles and retrieve roles. PBACRoleManager delegates permission checks to PermissionCheckService and role queries/assignments to RoleService, supporting enum and custom role IDs. RoleManagementFactory now imports these classes and returns an instance based on a feature flag, removing in-file implementations and the former IRoleManager declaration.

Possibly related PRs

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title “chore: refactor role manager factory classes” succinctly describes the primary change by indicating that the factory classes for role management have been refactored. It uses the conventional “chore” prefix for refactoring tasks, is directly related to the changes made, and avoids unnecessary detail or noise.
Description Check ✅ Passed The description clearly states that the PR refactors the role manager factory into separate folders to improve separation of concerns, which matches the refactoring work observed in the diff. Although brief, it is on-topic and accurately reflects the intent of the changes.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/refactor-role-manager

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 a review from a team September 29, 2025 14:34
@keithwillcode keithwillcode added consumer core area: core, team members only labels Sep 29, 2025
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: 3

🧹 Nitpick comments (15)
packages/features/pbac/services/role-manager.interface.ts (1)

3-20: Make the capability flag immutable (+ small API polish).

  • Mark isPBACEnabled as readonly; it’s a fixed capability, not state.
  • Optional: introduce a RoleRef type to avoid repeating the shape.
 export interface IRoleManager {
-  isPBACEnabled: boolean;
+  readonly isPBACEnabled: boolean;
   checkPermissionToChangeRole(

If you like, also:

+export type RoleRef = Readonly<{ id: string; name: string }>;
-  getAllRoles(organizationId: number): Promise<{ id: string; name: string }[]>;
-  getTeamRoles(teamId: number): Promise<{ id: string; name: string }[]>;
+  getAllRoles(organizationId: number): Promise<RoleRef[]>;
+  getTeamRoles(teamId: number): Promise<RoleRef[]>;
packages/features/pbac/services/role-management.factory.ts (2)

16-21: Replace comma operator assignment and fix misleading comment.

The comma operator hurts readability, and the comment is inaccurate (both dependencies are used). Use clear assignments.

   private constructor() {
-    // Not used but needed for DI
-    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-    (this.featuresRepository = new FeaturesRepository(prisma)), (this.roleService = new RoleService());
-    this.permissionCheckService = new PermissionCheckService();
+    this.featuresRepository = new FeaturesRepository(prisma);
+    this.roleService = new RoleService();
+    this.permissionCheckService = new PermissionCheckService();
   }

30-36: Nit: consider naming consistency.

Method takes organizationId but calls checkIfTeamHasFeature. If “org” and “team” are the same entity, consider harmonizing parameter naming for clarity.

packages/features/pbac/services/legacy-role-manager.service.ts (6)

11-12: Capability flag should be immutable.

Make isPBACEnabled readonly to reflect a fixed mode.

-  public isPBACEnabled = false;
+  public readonly isPBACEnabled = false;

77-86: Prisma: select only what you need + use enums instead of raw strings.

  • Per repo guideline, add select to avoid fetching full rows.
  • Prefer MembershipRole.ADMIN/OWNER for type safety.

[coding_guidelines]

-      const team = await prisma.membership.findFirst({
+      const team = await prisma.membership.findFirst({
         where: {
           userId,
           teamId: targetId,
           accepted: true,
-          OR: [{ role: "ADMIN" }, { role: "OWNER" }],
+          OR: [{ role: MembershipRole.ADMIN }, { role: MembershipRole.OWNER }],
         },
+        select: { id: true },
       });

98-107: Prisma: avoid full-row fetch; select minimal fields.

You only use userId and role downstream. Select just those to reduce payload and align with guidelines.

[coding_guidelines]

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

Note: This changes the shape; update validateRoleChange signature accordingly (see next comment).


13-19: Type narrow validateRoleChange input to match selected fields.

After adding select in findMany, adjust the parameter type to what you actually use.

-    memberships: Membership[]
+    memberships: Array<Pick<Membership, "userId" | "role">>

1-9: File naming convention (new .service.ts files).

Repo guideline discourages new .service.ts suffixes. Since this file is newly introduced, consider naming without the suffix (e.g., legacy-role-manager.ts). Apply consistently if you decide to align.

[coding_guidelines]


34-66: Behavioral nit: self “no-op” updates.

The “admins cannot change themselves to a higher role” check also blocks an ADMIN setting their role to ADMIN (no-op). If no-op updates are allowed elsewhere, consider explicitly allowing newRole === myMembership.role.

-      newRole !== MembershipRole.MEMBER
+      newRole !== MembershipRole.MEMBER &&
+      newRole !== myMembership.role
packages/features/pbac/services/pbac-role-manager.service.ts (6)

1-1: File naming: avoid new “.service.ts” suffix

For new files we avoid .service.ts suffixes; prefer pbac-role-manager.ts. If keeping the current name for consistency, consider adding a follow-up to align naming across the folder.

As per coding guidelines.


49-61: Tighten type checks; remove as any; simplify role resolution

Use explicit key/value sets to distinguish enum keys from default role IDs and avoid as any. Also early-return once a default role resolves.

Apply:

-    // Check if role is one of the default MembershipRole enum values
-    const isDefaultRole = role in DEFAULT_ROLE_IDS;
-
-    // Also check if the role is a default role ID value
-    const isDefaultRoleId = Object.values(DEFAULT_ROLE_IDS).includes(role as any);
-
-    if (isDefaultRole) {
-      // Handle enum values like MembershipRole.ADMIN
-      await this.roleService.assignRoleToMember(DEFAULT_ROLE_IDS[role as MembershipRole], membershipId);
-    } else if (isDefaultRoleId) {
-      // Handle default role IDs like "admin_role"
-      await this.roleService.assignRoleToMember(role as string, membershipId);
-    } else {
+    const defaultRoleKeys = Object.keys(DEFAULT_ROLE_IDS) as MembershipRole[];
+    const defaultRoleIds = Object.values(DEFAULT_ROLE_IDS) as string[];
+
+    if (defaultRoleKeys.includes(role as MembershipRole)) {
+      await this.roleService.assignRoleToMember(DEFAULT_ROLE_IDS[role as MembershipRole], membershipId);
+      return;
+    }
+    if (defaultRoleIds.includes(role as string)) {
+      await this.roleService.assignRoleToMember(role as string, membershipId);
+      return;
+    }
+    else {
       // Handle custom roles
       const roleExists = await this.roleService.roleBelongsToTeam(role as string, organizationId);
       if (!roleExists) {
         throw new RoleManagementError(
           "You do not have access to this role",
           RoleManagementErrorCode.INVALID_ROLE
         );
       }
       await this.roleService.assignRoleToMember(role as string, membershipId);
     }

43-48: Parameter naming is confusing (“organizationId” is used as a teamId)

Here and in getAllRoles, the param named organizationId is passed to team-scoped methods. Consider renaming locally to teamId (and documenting that for PBAC, the org is represented by its org teamId) to reduce confusion.


74-88: DRY up role mapping

getAllRoles and getTeamRoles duplicate the same map. Extract a small helper to map roles to {id,name} to keep both methods consistent.

Example:

private toPublic(roles: { id:string; name:string }[]) {
  return roles.map(({id,name}) => ({ id, name }));
}

21-26: Redundant no-unused-vars disables

Args are already prefixed with _. If your ESLint config respects leading underscores, the per-arg disables can be removed. If not, keep them; otherwise delete for cleaner code.


17-48: Enforce permission guard in assignRole for defense in depth
Although both callers (changeMemberRole.handler.ts and updateUser.handler.ts) invoke checkPermissionToChangeRole before assignRole, the PBAC manager’s assignRole method itself doesn’t perform any permission check. Embed an explicit call to checkPermissionToChangeRole inside assignRole—or codify the caller contract and assert it via unit tests—to avoid relying solely on upstream callers.

📜 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 e6b2116 and 8da8678.

📒 Files selected for processing (4)
  • packages/features/pbac/services/legacy-role-manager.service.ts (1 hunks)
  • packages/features/pbac/services/pbac-role-manager.service.ts (1 hunks)
  • packages/features/pbac/services/role-management.factory.ts (1 hunks)
  • packages/features/pbac/services/role-manager.interface.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{service,repository}.ts

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

Avoid dot-suffixes like .service.ts or .repository.ts for new files; reserve .test.ts, .spec.ts, .types.ts for their specific purposes

Files:

  • packages/features/pbac/services/pbac-role-manager.service.ts
  • packages/features/pbac/services/legacy-role-manager.service.ts
**/*.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/pbac-role-manager.service.ts
  • packages/features/pbac/services/role-manager.interface.ts
  • packages/features/pbac/services/legacy-role-manager.service.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/features/pbac/services/pbac-role-manager.service.ts
  • packages/features/pbac/services/role-manager.interface.ts
  • packages/features/pbac/services/legacy-role-manager.service.ts
  • packages/features/pbac/services/role-management.factory.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/features/pbac/services/pbac-role-manager.service.ts
  • packages/features/pbac/services/role-manager.interface.ts
  • packages/features/pbac/services/legacy-role-manager.service.ts
  • packages/features/pbac/services/role-management.factory.ts
🧬 Code graph analysis (3)
packages/features/pbac/services/pbac-role-manager.service.ts (5)
packages/features/pbac/services/role-manager.interface.ts (1)
  • IRoleManager (3-20)
packages/features/pbac/services/role.service.ts (1)
  • RoleService (12-112)
packages/features/pbac/services/permission-check.service.ts (2)
  • PermissionCheckService (19-306)
  • hasPermission (183-201)
packages/features/pbac/domain/errors/role-management.error.ts (1)
  • RoleManagementError (1-6)
packages/features/pbac/lib/constants.ts (1)
  • DEFAULT_ROLE_IDS (16-20)
packages/features/pbac/services/role-manager.interface.ts (1)
packages/platform/libraries/index.ts (1)
  • MembershipRole (34-34)
packages/features/pbac/services/legacy-role-manager.service.ts (4)
packages/features/pbac/services/role-manager.interface.ts (1)
  • IRoleManager (3-20)
packages/features/pbac/domain/errors/role-management.error.ts (1)
  • RoleManagementError (1-6)
packages/features/ee/teams/lib/queries.ts (1)
  • isTeamOwner (388-397)
packages/lib/server/queries/organisations/index.ts (1)
  • isOrganisationAdmin (7-17)
🔇 Additional comments (1)
packages/features/pbac/services/pbac-role-manager.service.ts (1)

17-41: Verify organization permission key and teamId usage

  • Confirm organization.changeMemberRole is supported by PermissionMapper/PERMISSION_REGISTRY (tests cover team.changeMemberRole; registry defines ChangeMemberRole).
  • Ensure passing teamId: targetId (orgId) for scope="org" invokes the correct org‐level check in PermissionCheckService.

Comment on lines +87 to +89
hasPermission = !!(await isOrganisationAdmin(userId, targetId));
}

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

🧩 Analysis chain

Double-check org-scope acceptance semantics.

isOrganisationAdmin (external) in the provided snippet doesn’t filter accepted: true. If that’s accurate, a pending admin invite could pass this check. Either ensure that helper enforces accepted, or mirror the accepted: true constraint here.


🏁 Script executed:

#!/bin/bash
# Verify isOrganisationAdmin enforces accepted=true
rg -n -C3 --type=ts '\bisOrganisationAdmin\s*\(' packages/lib/server/queries/organisations | sed -n '1,120p'

Length of output: 628


🏁 Script executed:

#!/bin/bash
# Show implementation of isOrganisationAdmin around the findFirst call
rg -n -C5 'membership.findFirst' packages/lib/server/queries/organisations/index.ts

Length of output: 675


🏁 Script executed:

#!/bin/bash
# Locate Prisma schema files
rg -l 'datasource' -g '*.prisma'

Length of output: 82


🏁 Script executed:

#!/bin/bash
# Extract Membership model from Prisma schema to verify acceptance field
rg -n -C5 'model Membership' packages/prisma/schema.prisma

Length of output: 310


Enforce accepted=true in isOrganisationAdmin
isOrganisationAdmin currently omits the accepted filter, so pending invites (accepted: false) with ADMIN/OWNER role would pass. Add accepted: true to the where clause in packages/lib/server/queries/organisations/index.ts to restrict it to accepted memberships.

🤖 Prompt for AI Agents
In packages/features/pbac/services/legacy-role-manager.service.ts around lines
87-89, the call to isOrganisationAdmin can currently return true for pending
invites because the underlying query omits an accepted filter; edit
packages/lib/server/queries/organisations/index.ts and add accepted: true to the
membership where clause used by isOrganisationAdmin so only accepted memberships
(not pending invites) with ADMIN/OWNER roles satisfy the check.

Comment on lines +110 to +129
async assignRole(
userId: number,
organizationId: number,
role: MembershipRole | string,
// Used in other implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_membershipId: number
): Promise<void> {
await prisma.membership.update({
where: {
userId_teamId: {
userId,
teamId: organizationId,
},
},
data: {
role: role as MembershipRole,
},
});
}
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 | 🟠 Major

Guard against custom roles in legacy mode before updating DB.

assignRole blindly casts role to MembershipRole. If a custom PBAC role string is passed while PBAC is disabled, this will break at runtime. Validate and throw a domain error instead.

   async assignRole(
     userId: number,
     organizationId: number,
     role: MembershipRole | string,
     // Used in other implementation
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     _membershipId: number
   ): Promise<void> {
+    // Legacy mode only accepts classic membership roles
+    if (!Object.values(MembershipRole).includes(role as MembershipRole)) {
+      throw new RoleManagementError(
+        "Custom roles are not supported in legacy mode",
+        RoleManagementErrorCode.UNAUTHORIZED
+      );
+    }
     await prisma.membership.update({
       where: {
         userId_teamId: {
           userId,
           teamId: organizationId,
         },
       },
       data: {
         role: role as MembershipRole,
       },
     });
   }
📝 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
async assignRole(
userId: number,
organizationId: number,
role: MembershipRole | string,
// Used in other implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_membershipId: number
): Promise<void> {
await prisma.membership.update({
where: {
userId_teamId: {
userId,
teamId: organizationId,
},
},
data: {
role: role as MembershipRole,
},
});
}
async assignRole(
userId: number,
organizationId: number,
role: MembershipRole | string,
// Used in other implementation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_membershipId: number
): Promise<void> {
// Legacy mode only accepts classic membership roles
if (!Object.values(MembershipRole).includes(role as MembershipRole)) {
throw new RoleManagementError(
"Custom roles are not supported in legacy mode",
RoleManagementErrorCode.UNAUTHORIZED
);
}
await prisma.membership.update({
where: {
userId_teamId: {
userId,
teamId: organizationId,
},
},
data: {
role: role as MembershipRole,
},
});
}
🤖 Prompt for AI Agents
In packages/features/pbac/services/legacy-role-manager.service.ts around lines
110 to 129, assignRole currently casts the incoming role to MembershipRole
unconditionally; validate the incoming role first and reject custom PBAC role
strings when PBAC is disabled by checking whether the provided role is a valid
MembershipRole value (e.g., compare against Object.values(MembershipRole) or the
enum members) and if it is not, throw a domain error (use the app's DomainError
type or a clearly named error) instead of proceeding; only after validation cast
to MembershipRole and perform the prisma.membership.update.

Comment on lines +43 to +72
async assignRole(
_userId: number,
organizationId: number,
role: MembershipRole | string,
membershipId: number
): Promise<void> {
// Check if role is one of the default MembershipRole enum values
const isDefaultRole = role in DEFAULT_ROLE_IDS;

// Also check if the role is a default role ID value
const isDefaultRoleId = Object.values(DEFAULT_ROLE_IDS).includes(role as any);

if (isDefaultRole) {
// Handle enum values like MembershipRole.ADMIN
await this.roleService.assignRoleToMember(DEFAULT_ROLE_IDS[role as MembershipRole], membershipId);
} else if (isDefaultRoleId) {
// Handle default role IDs like "admin_role"
await this.roleService.assignRoleToMember(role as string, membershipId);
} else {
// Handle custom roles
const roleExists = await this.roleService.roleBelongsToTeam(role as string, organizationId);
if (!roleExists) {
throw new RoleManagementError(
"You do not have access to this role",
RoleManagementErrorCode.INVALID_ROLE
);
}
await this.roleService.assignRoleToMember(role as string, membershipId);
}
}
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 | 🟠 Major

Membership-to-target validation missing (risk: cross-tenant assignment)

assignRole never verifies that membershipId belongs to the target team/org (organizationId here). If a caller supplies a membership from another team, this could assign a default or custom role across boundaries. Add a guard that asserts the membership is within the target team (or the org tree, as intended) before any assignment.

Apply this change locally (call-site here), and add a helper in RoleService:

   async assignRole(
     _userId: number,
     organizationId: number,
     role: MembershipRole | string,
     membershipId: number
   ): Promise<void> {
+    // Ensure membership belongs to the target team/org before assigning any role
+    const isMemberInTarget = await this.roleService.membershipBelongsToTeamOrOrg(membershipId, organizationId);
+    if (!isMemberInTarget) {
+      throw new RoleManagementError(
+        "Membership does not belong to target organization/team",
+        RoleManagementErrorCode.UNAUTHORIZED
+      );
+    }

Add this method to RoleService (example):

// packages/features/pbac/services/role.service.ts
async membershipBelongsToTeamOrOrg(membershipId: number, teamOrOrgId: number) {
  const membership = await db.membership.findUnique({
    where: { id: membershipId },
    select: { teamId: true, team: { select: { parentId: true } } }, // select-only, no include
  });
  if (!membership) return false;
  const { teamId, team } = membership;
  return teamId === teamOrOrgId || team?.parentId === teamOrOrgId;
}

I can wire this through and update unit tests if you want a follow-up PR. As per coding guidelines (Prisma: use select).

Copy link
Contributor

@volnei volnei left a comment

Choose a reason for hiding this comment

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

It's good to remove all // eslint-disable-next-line @typescript-eslint/no-unused-vars

Other than that looks good

@github-actions
Copy link
Contributor

E2E results are ready!

@sean-brydon sean-brydon merged commit ccd7fdf into main Sep 30, 2025
101 of 106 checks passed
@sean-brydon sean-brydon deleted the chore/refactor-role-manager branch September 30, 2025 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants