Skip to content

Comments

fix: Always enforce one owner with pbac #24144

Merged
sean-brydon merged 6 commits intomainfrom
fix/pbac-always-enforce-one-owner
Oct 1, 2025
Merged

fix: Always enforce one owner with pbac #24144
sean-brydon merged 6 commits intomainfrom
fix/pbac-always-enforce-one-owner

Conversation

@sean-brydon
Copy link
Member

What does this PR do?

Ensure we always have one owner with PBAC - making it sure nobody can ever brick a team

How should this be tested?

Login as admin of an org with pbac enabled
try to make only owner a admin

Checklist

  • I haven't read the contributing guide
  • My code doesn't follow the style guidelines of this project
  • I haven't commented my code, particularly in hard-to-understand areas
  • I haven't checked if my changes generate no new warnings

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 29, 2025

Walkthrough

Adds owner-preservation checks to PBACRoleManager: imports prisma, introduces public isPBACEnabled flag, and a private validateNotLastOwner(organizationId, membershipId, newRole) that fetches membership, determines owner status (including customRoleId), counts owners, and throws UNAUTHORIZED if demoting the last owner. assignRole now calls validateNotLastOwner before proceeding. Also changed the role-management factory import to import IRoleManager as a runtime symbol. Tests for PBACRoleManager.assignRole were expanded to cover single-owner, legacy-owner, multi-owner, owner-to-owner, non-owner, and membership-not-found scenarios.

Possibly related PRs

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The pull request title “fix: Always enforce one owner with pbac” clearly and concisely summarizes the primary change of enforcing a single owner under PBAC and directly reflects the modifications to role-management logic without extraneous details.
Description Check ✅ Passed The pull request description outlines the purpose of preventing a team from losing its only owner when PBAC is enabled, describes how to test the change, and aligns closely with the code changes introduced.
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 fix/pbac-always-enforce-one-owner

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
🧪 Early access (Sonnet 4.5): enabled

We are currently testing the Sonnet 4.5 model, which is expected to improve code review quality. However, this model may lead to increased noise levels in the review comments. Please disable the early access features if the noise level causes any inconvenience.

Note:

  • Public repositories are always opted into early access features.
  • You can enable or disable early access features from the CodeRabbit UI or by updating the CodeRabbit configuration file.

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 13:48
@keithwillcode keithwillcode added consumer core area: core, team members only labels Sep 29, 2025
@dosubot dosubot bot added the 🐛 bug Something isn't working label 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: 2

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

72-75: Prisma guideline: select only needed fields.

Per repo guidelines, use select for Prisma queries. The proposed diff above already scopes fields to { teamId, role, customRoleId }.


126-131: Harden enum/key checks; avoid in on objects and any casts.

Use own‑property checks and precise typing for default role ID detection.

-const isDefaultRole = role in DEFAULT_ROLE_IDS;
+const isDefaultRole = Object.prototype.hasOwnProperty.call(
+  DEFAULT_ROLE_IDS,
+  role as string
+);
@@
-const isDefaultRoleId = Object.values(DEFAULT_ROLE_IDS).includes(role as any);
+const isDefaultRoleId = (Object.values(DEFAULT_ROLE_IDS) as string[]).includes(
+  role as string
+);

Also applies to: 128-130, 131-147


108-114: Align terminology: “team” vs “organization”.

Elsewhere (legacy manager) errors say “team”. Consider standardizing the message here for consistency.

packages/features/pbac/services/__tests__/role-management.factory.test.ts (3)

207-233: Ownership‑enforcement tests: good; add one more assertion.

  • Nice coverage for last‑owner demotion and multi‑owner allowance.
  • In the “owner to owner” test, assert that prisma.membership.count was not called to prove the early return.
 await expect(
   manager.assignRole(userId, organizationId, MembershipRole.OWNER, membershipId)
 ).resolves.not.toThrow();
 
 expect(mockRoleService.assignRoleToMember).toHaveBeenCalledWith(
   DEFAULT_ROLE_IDS[MembershipRole.OWNER],
   membershipId
 );
+expect(prisma.membership.count).not.toHaveBeenCalled();

Also applies to: 235-261, 263-290


350-360: Strengthen error assertions.

toThrow(new RoleManagementError(...)) can be brittle. Prefer checking the error type and message/code separately.

-await expect(...).rejects.toThrow(
-  new RoleManagementError("Membership not found", RoleManagementErrorCode.UNAUTHORIZED)
-);
+await expect(...).rejects.toMatchObject({
+  name: "RoleManagementError",
+  message: "Membership not found",
+  code: RoleManagementErrorCode.UNAUTHORIZED,
+});

132-155: Add a test for cross-team membership IDs.

Given the potential for a membershipId from a different team, add a test where findUnique returns teamIdorganizationId and expect UNAUTHORIZED.

I can add this case if helpful.

Also applies to: 159-171, 186-198, 207-233, 235-261, 263-290, 292-319, 321-349

📜 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 b00061d and 4763c04.

📒 Files selected for processing (2)
  • packages/features/pbac/services/__tests__/role-management.factory.test.ts (4 hunks)
  • packages/features/pbac/services/role-management.factory.ts (1 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/features/pbac/services/__tests__/role-management.factory.test.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/__tests__/role-management.factory.test.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/__tests__/role-management.factory.test.ts
  • packages/features/pbac/services/role-management.factory.ts
🧬 Code graph analysis (2)
packages/features/pbac/services/__tests__/role-management.factory.test.ts (3)
packages/platform/libraries/index.ts (1)
  • MembershipRole (34-34)
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-management.factory.ts (3)
packages/platform/libraries/index.ts (1)
  • MembershipRole (34-34)
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)
⏰ 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 (2)
packages/features/pbac/services/__tests__/role-management.factory.test.ts (2)

22-24: Mocks and singleton reset look good.

Resetting mocks and reinitializing the singleton per test prevents bleed‑through. LGTM.

Also applies to: 45-82


134-154: Default and custom role assignment paths: solid coverage.

Good use of non‑owner membership to bypass owner validation and verify default/custom role flows.

Also applies to: 156-180, 182-205

Comment on lines 72 to 79
const currentMembership = await prisma.membership.findUnique({
where: { id: membershipId },
});

if (!currentMembership) {
throw new RoleManagementError("Membership not found", RoleManagementErrorCode.UNAUTHORIZED);
}

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

Block cross-team role changes; add select to Prisma call.

findUnique returns the full row and does not verify the membership belongs to the organizationId (team). An attacker could pass a membershipId from another team; the owner-count check would run against the wrong team, risking unauthorized changes. Add a select and assert teamId matches organizationId.

-    const currentMembership = await prisma.membership.findUnique({
-      where: { id: membershipId },
-    });
+    const currentMembership = await prisma.membership.findUnique({
+      where: { id: membershipId },
+      select: { teamId: true, role: true, customRoleId: true },
+    });
@@
-    if (!currentMembership) {
+    if (!currentMembership) {
       throw new RoleManagementError("Membership not found", RoleManagementErrorCode.UNAUTHORIZED);
     }
+
+    // Ensure the membership belongs to this team/organization
+    if (currentMembership.teamId !== organizationId) {
+      throw new RoleManagementError(
+        "Membership does not belong to this team",
+        RoleManagementErrorCode.UNAUTHORIZED
+      );
+    }

Also applies to: 80-89

Comment on lines 98 to 106
// Count total owners in the organization/team
const ownerCount = await prisma.membership.count({
where: {
teamId: organizationId,
accepted: true,
OR: [{ customRoleId: DEFAULT_ROLE_IDS[MembershipRole.OWNER] }, { role: MembershipRole.OWNER }],
},
});

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

Eliminate TOCTOU: enforce “at least one owner” atomically.

Counting owners and then updating outside a transaction is vulnerable to races (two concurrent demotions can leave zero owners). Wrap the validation and assignment in a single DB transaction and perform the owner-count check inside it. Consider passing a transaction client into RoleService.

Example shape:

await prisma.$transaction(async (tx) => {
  await this.validateNotLastOwner(organizationId, membershipId, role, tx); // accept tx client
  // use tx-aware RoleService: assignRoleToMember(roleId, membershipId, tx)
});

Minimal API change: let validateNotLastOwner(..., db = prisma) use db.membership.*, and overload RoleService.assignRoleToMember to accept an optional tx client. I can draft this change if you want.

Also applies to: 108-114, 122-124

🤖 Prompt for AI Agents
In packages/features/pbac/services/role-management.factory.ts around lines
98–106 (and similarly for 108–114 and 122–124), the owner-count check and role
assignment are done outside a transaction (TOCTOU risk); change
validateNotLastOwner to accept an optional db client parameter (defaulting to
prisma) and make RoleService.assignRoleToMember accept an optional tx client,
then wrap the validate + assignment in a single prisma.$transaction so the
membership.count and the role update execute atomically using the same tx
client; update call sites to pass the tx into both validation and assignment.

}
}

private async validateNotLastOwner(
Copy link
Contributor

Choose a reason for hiding this comment

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

Random: Just noticed this file is called .factory but contains 2 managers and a factory. We should split out into separate files.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah let me do this in a follow up!

Copy link
Member Author

Choose a reason for hiding this comment

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

@vercel
Copy link

vercel bot commented Sep 29, 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 30, 2025 8:59am
cal-eu Ignored Ignored Sep 30, 2025 8:59am

@github-actions
Copy link
Contributor

github-actions bot commented Sep 29, 2025

E2E results are ready!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

📜 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 4763c04 and 3a766fa.

📒 Files selected for processing (2)
  • packages/features/pbac/services/pbac-role-manager.service.ts (3 hunks)
  • packages/features/pbac/services/role-management.factory.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/features/pbac/services/role-management.factory.ts
🧰 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
**/*.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
**/*.{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
**/*.{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
🧬 Code graph analysis (1)
packages/features/pbac/services/pbac-role-manager.service.ts (2)
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)
⏰ 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). (8)
  • GitHub Check: Production builds / Build Docs
  • GitHub Check: Production builds / Build Web App
  • GitHub Check: Production builds / Build Atoms
  • GitHub Check: Production builds / Build API v1
  • GitHub Check: Linters / lint
  • GitHub Check: Production builds / Build API v2
  • GitHub Check: Tests / Unit
  • GitHub Check: Type check / check-types
🔇 Additional comments (3)
packages/features/pbac/services/pbac-role-manager.service.ts (3)

1-1: LGTM!

The prisma import is necessary for the new owner validation logic.


7-7: LGTM!

The type-only import is appropriate since IRoleManager is only used for type annotations in this file.


96-96: LGTM!

The validation is correctly placed before the role assignment logic, ensuring that the last owner protection is enforced. However, note the race condition concern raised separately for the validateNotLastOwner method.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
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/pbac-role-manager.service.ts (1)

40-94: Race condition remains: validation and assignment are not atomic.

The TOCTOU vulnerability identified in the past review is still present. Between validating the owner count (line 79) and executing the role assignment in assignRole (lines 113/116/126), concurrent requests could demote other owners, potentially leaving the organization without any owners.

Consider wrapping the validation and assignment in a database transaction to ensure atomicity:

await prisma.$transaction(async (tx) => {
  // Perform validation using tx
  // Perform role assignment using tx
});

Alternatively, implement optimistic locking by re-validating the owner count immediately before committing the role change.

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

108-109: Consider improving type safety instead of disabling the lint rule.

The as any cast works but weakens type safety. Consider refactoring to use a type guard or a more explicit check.

Apply this diff to improve type safety:

-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const isDefaultRoleId = Object.values(DEFAULT_ROLE_IDS).includes(role as any);
+    const defaultRoleIdValues = Object.values(DEFAULT_ROLE_IDS) as string[];
+    const isDefaultRoleId = typeof role === 'string' && defaultRoleIdValues.includes(role);
📜 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 3a766fa and 7854d4b.

📒 Files selected for processing (1)
  • packages/features/pbac/services/pbac-role-manager.service.ts (3 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
**/*.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
**/*.{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
**/*.{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
🧬 Code graph analysis (1)
packages/features/pbac/services/pbac-role-manager.service.ts (2)
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)
🔇 Additional comments (5)
packages/features/pbac/services/pbac-role-manager.service.ts (5)

1-1: LGTM! Clean import additions.

The prisma import is necessary for the new validation logic, and using import type for IRoleManager is the correct TypeScript practice for type-only imports.

Also applies to: 7-7


11-11: LGTM! Clear feature flag.

The isPBACEnabled flag clearly identifies this manager as using PBAC, which aligns with the PR objective.


46-54: Select clause now addresses the coding guideline.

The Prisma query now uses select to fetch only the required fields (id, customRoleId, role, teamId), which aligns with the coding guidelines.


102-102: LGTM! Validation properly integrated.

The validateNotLastOwner call is correctly placed before the role assignment logic, ensuring that the last owner cannot be demoted (subject to the TOCTOU issue noted separately).


79-85: Remove organizationId/teamId mapping concern
organizationId corresponds to Team.id, which represents both teams and organizations, so using it as teamId in this query is correct.

Likely an incorrect or invalid review comment.

@sean-brydon sean-brydon merged commit e3742db into main Oct 1, 2025
40 checks passed
@sean-brydon sean-brydon deleted the fix/pbac-always-enforce-one-owner branch October 1, 2025 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working consumer core area: core, team members only ready-for-e2e size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants