Skip to content

Commit 34e2adc

Browse files
fix(organizations): extend global-admin bypass to updateRole + isGlobalAdmin helper (#3512)
* fix(organizations): extend global-admin bypass to updateRole + isGlobalAdmin helper Completes the platform-admin bypass started in #3509 which missed updateRole. A global admin with no membership on the target org used to get a 403 on PUT /api/organizations/:orgId/memberships/:memberId, blocking moderation use-cases like transferring ownership. Also extracts the repeated `Array.isArray(req.user?.roles) && r.includes('admin')` expression into `lib/helpers/isGlobalAdmin.js` — single source of truth for the three controller call-sites (remove-member, remove-org, updateRole). Policies still inline the check; a broader refactor is out of scope here. Closes #3510 * refactor(organizations): rename admin→isPlatformAdmin + clarify 403 message + test.each Address review feedback on #3512: - Rename local `admin` → `isPlatformAdmin` in all 3 controller call-sites to avoid confusion with the org-level `MEMBERSHIP_ROLES.ADMIN` used nearby. - Update the updateRole 403 message to reflect the actual rule ("Only owners or global admins can change member roles"). - Refactor the two updateRole rejection regression tests with `test.each`. * refactor(helpers): use function declaration for isGlobalAdmin to silence Codacy
1 parent 383f555 commit 34e2adc

6 files changed

Lines changed: 139 additions & 7 deletions

File tree

MIGRATIONS.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,36 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## Organizations: global admin bypass extended to updateRole + `isGlobalAdmin` helper (2026-04-24)
8+
9+
Completes the platform-admin bypass started in #3509, and centralizes the repeated `Array.isArray(req.user?.roles) && req.user.roles.includes('admin')` check into a shared helper.
10+
11+
### What changed
12+
13+
- New helper `lib/helpers/isGlobalAdmin.js` — single source of truth for the global admin check used by moderation guards.
14+
- `modules/organizations/controllers/organizations.membership.controller.js``updateRole` now admits global admins who are not members of the target org (required to transfer ownership during moderation). `remove` now uses the shared helper.
15+
- `modules/organizations/controllers/organizations.controller.js``remove` now uses the shared helper (no behavior change).
16+
17+
### Why
18+
19+
`updateRole` had exactly the same buggy pattern that `remove` used to have before #3509: `if (!req.membership || req.membership.role !== OWNER)` rejected global admins with `req.membership === undefined` when they were not a member of the target org. The inline comment even said "Belt-and-suspenders: only owners (CASL blocks admins via no 'update Membership')" — the intent never anticipated platform admins. Same class of bug, same fix shape.
20+
21+
While at it, the duplicated `isGlobalAdmin` expression across three call-sites was extracted into a helper. Policies (`organizations.policy.js`, `users.policy.js`, etc.) still inline the check for now — migrating them is out of scope here (wider refactor, different test surface).
22+
23+
### Non-breaking
24+
25+
- No contract changes for regular users / owners / non-global admins.
26+
- New capability: a user with `roles: ['admin']` can `PUT /api/organizations/:orgId/memberships/:memberId` without needing a membership on the target org.
27+
- Belt-and-suspenders guard is preserved: the handler still blocks non-owner, non-admin org roles regardless of CASL.
28+
29+
### Action for downstream
30+
31+
1. `/update-stack` pulls the change.
32+
2. No env var changes.
33+
3. No Mongo migration.
34+
35+
---
36+
737
## Auth signout endpoint (2026-04-23)
838

939
New `POST /api/auth/signout` endpoint that clears the httpOnly `TOKEN` cookie on the client.

lib/helpers/isGlobalAdmin.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* @desc True when the authenticated user has the global 'admin' role.
3+
* Use sparingly — prefer CASL abilities for per-resource checks. This
4+
* helper is only for belt-and-suspenders guards inside controllers that
5+
* need to bypass org-scoped membership checks (moderation endpoints).
6+
* @param {Object} user - req.user object (may be undefined)
7+
* @returns {boolean}
8+
*/
9+
function isGlobalAdmin(user) {
10+
if (!user || !Array.isArray(user.roles)) return false;
11+
return user.roles.includes('admin');
12+
}
13+
14+
export default isGlobalAdmin;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Unit tests for isGlobalAdmin helper.
3+
*/
4+
import { describe, it, expect } from '@jest/globals';
5+
import isGlobalAdmin from '../isGlobalAdmin.js';
6+
7+
describe('isGlobalAdmin', () => {
8+
it('should return false for undefined user', () => {
9+
expect(isGlobalAdmin(undefined)).toBe(false);
10+
});
11+
12+
it('should return false for null user', () => {
13+
expect(isGlobalAdmin(null)).toBe(false);
14+
});
15+
16+
it('should return false when user has no roles property', () => {
17+
expect(isGlobalAdmin({ _id: 'u1' })).toBe(false);
18+
});
19+
20+
it('should return false when user roles is not an array', () => {
21+
expect(isGlobalAdmin({ roles: 'admin' })).toBe(false);
22+
});
23+
24+
it('should return false when user roles is empty', () => {
25+
expect(isGlobalAdmin({ roles: [] })).toBe(false);
26+
});
27+
28+
it('should return false when roles contains only user', () => {
29+
expect(isGlobalAdmin({ roles: ['user'] })).toBe(false);
30+
});
31+
32+
it('should return false when roles contains lookalikes but not admin', () => {
33+
expect(isGlobalAdmin({ roles: ['user', 'superadmin', 'Admin'] })).toBe(false);
34+
});
35+
36+
it('should return true when roles includes admin alongside user', () => {
37+
expect(isGlobalAdmin({ roles: ['user', 'admin'] })).toBe(true);
38+
});
39+
40+
it('should return true when roles is exactly [admin]', () => {
41+
expect(isGlobalAdmin({ roles: ['admin'] })).toBe(true);
42+
});
43+
});

modules/organizations/controllers/organizations.controller.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import jwt from 'jsonwebtoken';
55
import errors from '../../../lib/helpers/errors.js';
66
import responses from '../../../lib/helpers/responses.js';
7+
import isGlobalAdmin from '../../../lib/helpers/isGlobalAdmin.js';
78
import config from '../../../config/index.js';
89
import mailer from '../../../lib/helpers/mailer/index.js';
910
import policy from '../../../lib/middlewares/policy.js';
@@ -107,9 +108,10 @@ const remove = async (req, res) => {
107108
// UX protection: prevent a regular user from deleting their own last organization.
108109
// Global platform admins bypass this entirely (moderation); a member of multiple orgs
109110
// is also safe to delete the current one since they keep at least one membership.
110-
const isGlobalAdmin = Array.isArray(req.user?.roles) && req.user.roles.includes('admin');
111+
// Note: `isPlatformAdmin` is the global/platform role, distinct from org-level admin.
112+
const isPlatformAdmin = isGlobalAdmin(req.user);
111113
const isMemberOfTarget = !!req.membership;
112-
if (!isGlobalAdmin && isMemberOfTarget) {
114+
if (!isPlatformAdmin && isMemberOfTarget) {
113115
const userMemberships = await MembershipService.listByUser(req.user._id || req.user.id);
114116
if (userMemberships.length <= 1) {
115117
return responses.error(res, 422, 'Unprocessable Entity', 'You cannot delete your last organization')();

modules/organizations/controllers/organizations.membership.controller.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import errors from '../../../lib/helpers/errors.js';
55
import responses from '../../../lib/helpers/responses.js';
6+
import isGlobalAdmin from '../../../lib/helpers/isGlobalAdmin.js';
67
import MembershipService from '../services/organizations.membership.service.js';
78
import { MEMBERSHIP_ROLES } from '../lib/constants.js';
89

@@ -34,9 +35,14 @@ const list = async (req, res) => {
3435
*/
3536
const updateRole = async (req, res) => {
3637
try {
37-
// Belt-and-suspenders: only owners can change roles (CASL blocks admins via no 'update Membership')
38-
if (!req.membership || req.membership.role !== MEMBERSHIP_ROLES.OWNER) {
39-
return responses.error(res, 403, 'Forbidden', 'Only owners can change member roles')();
38+
// Belt-and-suspenders: only org owners can change roles (CASL blocks non-owner org
39+
// roles via no 'update Membership'). Global platform admins bypass the membership
40+
// requirement for moderation — notably to transfer ownership on a third-party org.
41+
// Note: `isPlatformAdmin` is the global/platform role (`user.roles.includes('admin')`),
42+
// distinct from the org-level `MEMBERSHIP_ROLES.ADMIN`.
43+
const isPlatformAdmin = isGlobalAdmin(req.user);
44+
if (!isPlatformAdmin && (!req.membership || req.membership.role !== MEMBERSHIP_ROLES.OWNER)) {
45+
return responses.error(res, 403, 'Forbidden', 'Only owners or global admins can change member roles')();
4046
}
4147
const membership = await MembershipService.updateRole(req.membershipDoc, req.body.role);
4248
responses.success(res, 'membership updated')(membership);
@@ -56,10 +62,12 @@ const remove = async (req, res) => {
5662
try {
5763
// Only owners can remove anyone; admins can only remove members.
5864
// Global platform admins bypass org-level RBAC for moderation needs.
59-
const isGlobalAdmin = Array.isArray(req.user?.roles) && req.user.roles.includes('admin');
65+
// Note: `isPlatformAdmin` is the global/platform role, distinct from the
66+
// org-level `MEMBERSHIP_ROLES.ADMIN` referenced below.
67+
const isPlatformAdmin = isGlobalAdmin(req.user);
6068
const actorRole = req.membership?.role;
6169
const targetRole = req.membershipDoc.role;
62-
const canRemove = isGlobalAdmin
70+
const canRemove = isPlatformAdmin
6371
|| actorRole === MEMBERSHIP_ROLES.OWNER
6472
|| (actorRole === MEMBERSHIP_ROLES.ADMIN && targetRole === MEMBERSHIP_ROLES.MEMBER);
6573
if (!canRemove) {

modules/organizations/tests/organizations.membership.controller.unit.tests.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,41 @@ describe('Membership controller unit tests:', () => {
8989
expect(mockUpdateRole).toHaveBeenCalledTimes(1);
9090
expect(res.status).not.toHaveBeenCalledWith(403);
9191
});
92+
93+
test('should allow global admin with no org membership to change a role', async () => {
94+
const updatedMembership = { id: 'mem1', role: MEMBERSHIP_ROLES.OWNER };
95+
mockUpdateRole.mockResolvedValue(updatedMembership);
96+
97+
const req = mockReq({
98+
user: { _id: 'adm', roles: ['user', 'admin'] },
99+
membership: undefined,
100+
body: { role: MEMBERSHIP_ROLES.OWNER },
101+
});
102+
const res = mockRes();
103+
104+
await membershipController.updateRole(req, res);
105+
106+
expect(mockUpdateRole).toHaveBeenCalledTimes(1);
107+
expect(mockUpdateRole).toHaveBeenCalledWith(req.membershipDoc, MEMBERSHIP_ROLES.OWNER);
108+
expect(res.status).not.toHaveBeenCalledWith(403);
109+
});
110+
111+
test.each([
112+
['non-admin non-owner (member)', { role: MEMBERSHIP_ROLES.MEMBER }, MEMBERSHIP_ROLES.ADMIN],
113+
['org-level admin (not global)', { role: MEMBERSHIP_ROLES.ADMIN }, MEMBERSHIP_ROLES.OWNER],
114+
])('should still reject a %s trying to change a role', async (_label, membership, targetRole) => {
115+
const req = mockReq({
116+
user: { _id: 'u1', roles: ['user'] },
117+
membership,
118+
body: { role: targetRole },
119+
});
120+
const res = mockRes();
121+
122+
await membershipController.updateRole(req, res);
123+
124+
expect(res.status).toHaveBeenCalledWith(403);
125+
expect(mockUpdateRole).not.toHaveBeenCalled();
126+
});
92127
});
93128

94129
describe('remove', () => {

0 commit comments

Comments
 (0)