Skip to content

Commit 247ba7b

Browse files
fix(organizations): allow global admin to bypass org-level guards (#3509)
Two controller guards were blocking platform admins from moderating organizations they don't belong to: - remove-member refused because req.membership was undefined, so the actor-role check fell through to a 403 regardless of the global role. - delete-org always called listByUser(req.user) and bailed with 422 "cannot delete your last organization" for any admin with zero or one own memberships. Short-circuit both to "allow" when req.user.roles includes "admin". The delete-org UX protection is preserved for regular members (only skipped when the actor is not in the target org, or is a global admin), so a normal user deleting their own last org still sees the 422. Tests: extend organizations.membership.controller.unit to cover admin bypass with membership=undefined and admin removing an owner. Add organizations.controller.unit for the delete-org paths (regular last org rejected, multi-org member allowed, admin with zero/one memberships allowed). Closes #3505
1 parent c93a577 commit 247ba7b

4 files changed

Lines changed: 168 additions & 6 deletions

File tree

modules/organizations/controllers/organizations.controller.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,16 @@ const update = async (req, res) => {
104104
*/
105105
const remove = async (req, res) => {
106106
try {
107-
// Prevent deleting the user's last organization
108-
const userMemberships = await MembershipService.listByUser(req.user._id || req.user.id);
109-
if (userMemberships.length <= 1) {
110-
return responses.error(res, 422, 'Unprocessable Entity', 'You cannot delete your last organization')();
107+
// UX protection: prevent a regular user from deleting their own last organization.
108+
// Global platform admins bypass this entirely (moderation); a member of multiple orgs
109+
// 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+
const isMemberOfTarget = !!req.membership;
112+
if (!isGlobalAdmin && isMemberOfTarget) {
113+
const userMemberships = await MembershipService.listByUser(req.user._id || req.user.id);
114+
if (userMemberships.length <= 1) {
115+
return responses.error(res, 422, 'Unprocessable Entity', 'You cannot delete your last organization')();
116+
}
111117
}
112118
const result = await OrganizationsService.remove(req.organization);
113119
responses.success(res, 'organization deleted')({ id: req.organization.id, ...result });

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@ const updateRole = async (req, res) => {
5454
*/
5555
const remove = async (req, res) => {
5656
try {
57-
// Only owners can remove anyone; admins can only remove members
57+
// Only owners can remove anyone; admins can only remove members.
58+
// Global platform admins bypass org-level RBAC for moderation needs.
59+
const isGlobalAdmin = Array.isArray(req.user?.roles) && req.user.roles.includes('admin');
5860
const actorRole = req.membership?.role;
5961
const targetRole = req.membershipDoc.role;
60-
const canRemove = actorRole === MEMBERSHIP_ROLES.OWNER
62+
const canRemove = isGlobalAdmin
63+
|| actorRole === MEMBERSHIP_ROLES.OWNER
6164
|| (actorRole === MEMBERSHIP_ROLES.ADMIN && targetRole === MEMBERSHIP_ROLES.MEMBER);
6265
if (!canRemove) {
6366
return responses.error(res, 403, 'Forbidden', 'Insufficient permissions to remove this member')();
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
6+
const mockCrudRemove = jest.fn();
7+
const mockListByUser = jest.fn();
8+
9+
jest.unstable_mockModule('../services/organizations.crud.service.js', () => ({
10+
default: {
11+
remove: mockCrudRemove,
12+
},
13+
}));
14+
15+
jest.unstable_mockModule('../services/organizations.membership.service.js', () => ({
16+
default: {
17+
listByUser: mockListByUser,
18+
},
19+
}));
20+
21+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
22+
default: {
23+
groupIdentify: jest.fn(),
24+
},
25+
}));
26+
27+
const { default: organizationsController } = await import('../controllers/organizations.controller.js');
28+
29+
/**
30+
* Unit tests for the organizations controller remove handler.
31+
*/
32+
describe('Organizations controller unit tests:', () => {
33+
/**
34+
* @desc Build a minimal Express-like req object
35+
* @param {Object} overrides
36+
* @returns {Object} mock request
37+
*/
38+
function mockReq(overrides = {}) {
39+
return {
40+
user: { _id: 'u1', id: 'u1', roles: ['user'] },
41+
organization: { _id: 'org1', id: 'org1' },
42+
membership: { role: 'owner' },
43+
...overrides,
44+
};
45+
}
46+
47+
/**
48+
* @desc Build a minimal Express-like res object with spies
49+
* @returns {Object} mock response
50+
*/
51+
function mockRes() {
52+
const res = {};
53+
res.status = jest.fn().mockReturnValue(res);
54+
res.json = jest.fn().mockReturnValue(res);
55+
return res;
56+
}
57+
58+
beforeEach(() => {
59+
jest.clearAllMocks();
60+
});
61+
62+
describe('remove', () => {
63+
test('should reject when a regular member tries to delete their own last organization', async () => {
64+
mockListByUser.mockResolvedValue([{ _id: 'mem1' }]);
65+
const req = mockReq();
66+
const res = mockRes();
67+
68+
await organizationsController.remove(req, res);
69+
70+
expect(mockListByUser).toHaveBeenCalledWith('u1');
71+
expect(res.status).toHaveBeenCalledWith(422);
72+
expect(mockCrudRemove).not.toHaveBeenCalled();
73+
});
74+
75+
test('should allow a regular member to delete when they belong to several organizations', async () => {
76+
mockListByUser.mockResolvedValue([{ _id: 'mem1' }, { _id: 'mem2' }]);
77+
mockCrudRemove.mockResolvedValue({ success: true });
78+
79+
const req = mockReq();
80+
const res = mockRes();
81+
82+
await organizationsController.remove(req, res);
83+
84+
expect(mockListByUser).toHaveBeenCalledTimes(1);
85+
expect(mockCrudRemove).toHaveBeenCalledTimes(1);
86+
expect(res.status).not.toHaveBeenCalledWith(422);
87+
});
88+
89+
test('should allow a global admin with zero memberships to delete any organization', async () => {
90+
mockCrudRemove.mockResolvedValue({ success: true });
91+
const req = mockReq({
92+
user: { _id: 'adm', id: 'adm', roles: ['user', 'admin'] },
93+
membership: undefined,
94+
});
95+
const res = mockRes();
96+
97+
await organizationsController.remove(req, res);
98+
99+
expect(mockListByUser).not.toHaveBeenCalled();
100+
expect(mockCrudRemove).toHaveBeenCalledTimes(1);
101+
expect(res.status).not.toHaveBeenCalledWith(422);
102+
});
103+
104+
test('should allow a global admin who is a member of the target org with only one membership', async () => {
105+
mockCrudRemove.mockResolvedValue({ success: true });
106+
const req = mockReq({
107+
user: { _id: 'adm', id: 'adm', roles: ['admin'] },
108+
membership: { role: 'owner' },
109+
});
110+
const res = mockRes();
111+
112+
await organizationsController.remove(req, res);
113+
114+
// Admin skips the last-org UX check entirely.
115+
expect(mockListByUser).not.toHaveBeenCalled();
116+
expect(mockCrudRemove).toHaveBeenCalledTimes(1);
117+
expect(res.status).not.toHaveBeenCalledWith(422);
118+
});
119+
});
120+
});

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('Membership controller unit tests:', () => {
3333
return {
3434
query: {},
3535
body: {},
36+
user: { _id: 'u1', roles: ['user'] },
3637
organization: { _id: 'org1' },
3738
membership: { role: MEMBERSHIP_ROLES.OWNER },
3839
membershipDoc: { id: 'mem1', role: MEMBERSHIP_ROLES.MEMBER, organizationId: 'org1' },
@@ -154,5 +155,37 @@ describe('Membership controller unit tests:', () => {
154155
expect(mockRemove).toHaveBeenCalledTimes(1);
155156
expect(res.status).not.toHaveBeenCalledWith(403);
156157
});
158+
159+
test('should allow global admin with no org membership to remove any member', async () => {
160+
mockRemove.mockResolvedValue({ success: true });
161+
162+
const req = mockReq({
163+
user: { _id: 'adm', roles: ['user', 'admin'] },
164+
membership: undefined,
165+
membershipDoc: { id: 'mem2', role: MEMBERSHIP_ROLES.OWNER },
166+
});
167+
const res = mockRes();
168+
169+
await membershipController.remove(req, res);
170+
171+
expect(mockRemove).toHaveBeenCalledTimes(1);
172+
expect(res.status).not.toHaveBeenCalledWith(403);
173+
});
174+
175+
test('should allow global admin to remove an owner even without being in the org', async () => {
176+
mockRemove.mockResolvedValue({ success: true });
177+
178+
const req = mockReq({
179+
user: { _id: 'adm', roles: ['admin'] },
180+
membership: undefined,
181+
membershipDoc: { id: 'mem2', role: MEMBERSHIP_ROLES.OWNER },
182+
});
183+
const res = mockRes();
184+
185+
await membershipController.remove(req, res);
186+
187+
expect(mockRemove).toHaveBeenCalledTimes(1);
188+
expect(res.status).not.toHaveBeenCalledWith(403);
189+
});
157190
});
158191
});

0 commit comments

Comments
 (0)