fix: use membership role as source-of-truth for organization roles#1988
fix: use membership role as source-of-truth for organization roles#1988rafaumeu wants to merge 2 commits into
Conversation
…apSoftware#1641) The getEffectiveOrganizationRole function previously used organizations.ownerId as the primary source for determining the 'owner' role, with memberRole as a fallback. This caused two problems: 1. When a user was the organization owner (ownerId match) but their membership record had a different role (e.g. 'member'), the ownerId check would incorrectly override their actual membership role. 2. When a user's membership record had role='owner' but they weren't the ownerId, the function would force their role to 'member', silently stripping their owner privileges. This fix makes memberRole from organization_members the primary source of truth, with ownerId only used as a fallback when no membership record exists. Also updates isMemberOwner in MembersCard and isOrganizationOwnerTarget to use membership role instead of ownerId comparison. Closes CapSoftware#1641
| // Prefer membership role over ownerId (see #1641). | ||
| if (targetRole === "owner") return true; | ||
| // Fallback for legacy/missing membership records. | ||
| return !!targetUserId && !!ownerId && targetUserId === ownerId; |
There was a problem hiding this comment.
When targetRole is already known to be "member" or "admin", this fallback can still classify the target as owner because their id matches stale ownerId. In that drift state, canChangeOrganizationMemberRole and canRemoveOrganizationMember reject changes to a non-owner member, so the member can become stuck even though membership role is now the source of truth.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/permissions/roles.ts
Line: 143
Comment:
**Stale OwnerId Still Wins**
When `targetRole` is already known to be `"member"` or `"admin"`, this fallback can still classify the target as owner because their id matches stale `ownerId`. In that drift state, `canChangeOrganizationMemberRole` and `canRemoveOrganizationMember` reject changes to a non-owner member, so the member can become stuck even though membership role is now the source of truth.
**Context Used:** AGENTS.md ([source](https://app.greptile.com/cap/github/capsoftware/cap/-/custom-context?memory=27801409-c24c-4476-9c6c-180f1ef0a7f2))
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in f7d0dfd — isOrganizationOwnerTarget now only falls back to ownerId when targetRole is null/undefined. When targetRole is a known non-owner role ("member"/"admin"), it returns false immediately. Same logic applied to MembersCard.isMemberOwner — falls back to ownerId only when no membership record exists.
| const isMemberOwner = (id: string) => { | ||
| return id === activeOrganization?.organization.ownerId; | ||
| const member = activeOrganization?.members.find((m) => m.userId === id); | ||
| return member?.role === "owner"; |
There was a problem hiding this comment.
Legacy Owner Row Loses Protection
When an organization owner is represented only by organization.ownerId or has a stale/missing membership role, this returns false while the shared permission helpers still treat that user as owner through their fallback. The row then shows editable role, Pro-seat, and remove controls that the server rejects, so legacy owner rows become misleading and inconsistent.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx
Line: 209
Comment:
**Legacy Owner Row Loses Protection**
When an organization owner is represented only by `organization.ownerId` or has a stale/missing membership role, this returns false while the shared permission helpers still treat that user as owner through their fallback. The row then shows editable role, Pro-seat, and remove controls that the server rejects, so legacy owner rows become misleading and inconsistent.
**Context Used:** AGENTS.md ([source](https://app.greptile.com/cap/github/capsoftware/cap/-/custom-context?memory=27801409-c24c-4476-9c6c-180f1ef0a7f2))
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in f7d0dfd — isMemberOwner now falls back to ownerId comparison only when no membership record is found in activeOrganization.members. When the membership record exists, it always uses `member.role === 'owner'" as the source of truth.
| // Prefer membership role over ownerId (see #1641). | ||
| if (targetRole === "owner") return true; | ||
| // Fallback for legacy/missing membership records. | ||
| return !!targetUserId && !!ownerId && targetUserId === ownerId; |
There was a problem hiding this comment.
Minor edge case: if targetRole is already known to be "member"/"admin", the current ownerId fallback can still classify the target as owner if ownerId is stale. You can restrict the fallback to only when targetRole is missing.
| // Prefer membership role over ownerId (see #1641). | |
| if (targetRole === "owner") return true; | |
| // Fallback for legacy/missing membership records. | |
| return !!targetUserId && !!ownerId && targetUserId === ownerId; | |
| // Prefer membership role over ownerId (see #1641). | |
| if (targetRole === "owner") return true; | |
| if (targetRole) return false; | |
| // Fallback for legacy/missing membership records. | |
| return !!targetUserId && !!ownerId && targetUserId === ownerId; |
There was a problem hiding this comment.
Applied your suggestion in f7d0dfd. if (targetRole) return false; now short-circuits when targetRole is a known non-owner role before reaching the ownerId fallback.
| @@ -205,7 +205,8 @@ export const MembersCard = ({ setIsInviteDialogOpen }: MembersCardProps) => { | |||
| }; | |||
|
|
|||
| const isMemberOwner = (id: string) => { | |||
There was a problem hiding this comment.
If activeOrganization.members can ever be missing the owner membership row (legacy/migration state), this drops the ownerId fallback and can show non-owner UI for the owner. If you still want a legacy fallback without reintroducing the stale-ownerId override, only fall back when no membership record exists.
| const isMemberOwner = (id: string) => { | |
| return member ? member.role === "owner" : id === activeOrganization?.organization.ownerId; |
There was a problem hiding this comment.
Applied your suggestion in f7d0dfd. isMemberOwner now falls back to ownerId only when no membership record is found: member ? member.role === 'owner' : id === activeOrganization?.organization.ownerId
…embership - isOrganizationOwnerTarget: only use ownerId fallback when targetRole is null/undefined (not when it's a known non-owner role) - MembersCard.isMemberOwner: fall back to ownerId comparison when no membership record exists (legacy/migration state) Co-authored-by: tembo (suggestion)
Summary
Fixes #1641
The
getEffectiveOrganizationRolefunction previously usedorganizations.ownerIdas the primary source for determining the 'owner' role. This caused two problems:ownerIdbut their membership record had a different role (e.g.member), the function would incorrectly returnowner.role='owner'but they weren't theownerId, the function would returnmember, silently stripping owner privileges.Changes
apps/web/lib/permissions/roles.ts:getEffectiveOrganizationRolenow usesmemberRolefromorganization_membersas source-of-truth, withownerIdonly as a fallback when no membership record exists. Same fix applied toisOrganizationOwnerTarget.apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx:isMemberOwnernow checksmember.role === 'owner'instead of comparing withorganization.ownerId.Validation
pnpm tsc -b)biome lint)How to test
The reverse scenario (user with membership role='owner' but different ownerId) is also handled correctly.
Greptile Summary
This PR makes organization membership role the source of truth for owner checks. The main changes are:
member.role.Confidence Score: 4/5
The changed permission target check needs a fix before merging.
ownerId.apps/web/lib/permissions/roles.ts; apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx
Security Review
A permission helper can still treat a non-owner membership as owner when stale
ownerIdmatches the target user, blocking legitimate member-management actions.Important Files Changed
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix: use membership role as source-of-tr..." | Re-trigger Greptile
Context used: