Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ Breaking changes and upgrade notes for downstream projects.

---

## Organizations: global admin bypass extended to updateRole + `isGlobalAdmin` helper (2026-04-24)

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.

### What changed

- New helper `lib/helpers/isGlobalAdmin.js` — single source of truth for the global admin check used by moderation guards.
- `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.
- `modules/organizations/controllers/organizations.controller.js` — `remove` now uses the shared helper (no behavior change).

### Why

`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.

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).

### Non-breaking

- No contract changes for regular users / owners / non-global admins.
- New capability: a user with `roles: ['admin']` can `PUT /api/organizations/:orgId/memberships/:memberId` without needing a membership on the target org.
- Belt-and-suspenders guard is preserved: the handler still blocks non-owner, non-admin org roles regardless of CASL.

### Action for downstream

1. `/update-stack` pulls the change.
2. No env var changes.
3. No Mongo migration.

---

## Auth signout endpoint (2026-04-23)

New `POST /api/auth/signout` endpoint that clears the httpOnly `TOKEN` cookie on the client.
Expand Down
11 changes: 11 additions & 0 deletions lib/helpers/isGlobalAdmin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* @desc True when the authenticated user has the global 'admin' role.
* Use sparingly — prefer CASL abilities for per-resource checks. This
* helper is only for belt-and-suspenders guards inside controllers that
* need to bypass org-scoped membership checks (moderation endpoints).
* @param {Object} user - req.user object (may be undefined)
* @returns {boolean}
*/
const isGlobalAdmin = (user) => Array.isArray(user?.roles) && user.roles.includes('admin');

Check warning on line 9 in lib/helpers/isGlobalAdmin.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

lib/helpers/isGlobalAdmin.js#L9

Non-serializable expression must be wrapped with $(...)

export default isGlobalAdmin;
Comment thread
PierreBrisorgueil marked this conversation as resolved.
43 changes: 43 additions & 0 deletions lib/helpers/tests/isGlobalAdmin.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Unit tests for isGlobalAdmin helper.
*/
import { describe, it, expect } from '@jest/globals';
import isGlobalAdmin from '../isGlobalAdmin.js';

describe('isGlobalAdmin', () => {
it('should return false for undefined user', () => {
expect(isGlobalAdmin(undefined)).toBe(false);
});

it('should return false for null user', () => {
expect(isGlobalAdmin(null)).toBe(false);
});

it('should return false when user has no roles property', () => {
expect(isGlobalAdmin({ _id: 'u1' })).toBe(false);
});

it('should return false when user roles is not an array', () => {
expect(isGlobalAdmin({ roles: 'admin' })).toBe(false);
});

it('should return false when user roles is empty', () => {
expect(isGlobalAdmin({ roles: [] })).toBe(false);
});

it('should return false when roles contains only user', () => {
expect(isGlobalAdmin({ roles: ['user'] })).toBe(false);
});

it('should return false when roles contains lookalikes but not admin', () => {
expect(isGlobalAdmin({ roles: ['user', 'superadmin', 'Admin'] })).toBe(false);
});

it('should return true when roles includes admin alongside user', () => {
expect(isGlobalAdmin({ roles: ['user', 'admin'] })).toBe(true);
});

it('should return true when roles is exactly [admin]', () => {
expect(isGlobalAdmin({ roles: ['admin'] })).toBe(true);
});
});
5 changes: 3 additions & 2 deletions modules/organizations/controllers/organizations.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import jwt from 'jsonwebtoken';
import errors from '../../../lib/helpers/errors.js';
import responses from '../../../lib/helpers/responses.js';
import isGlobalAdmin from '../../../lib/helpers/isGlobalAdmin.js';
import config from '../../../config/index.js';
import mailer from '../../../lib/helpers/mailer/index.js';
import policy from '../../../lib/middlewares/policy.js';
Expand Down Expand Up @@ -107,9 +108,9 @@ const remove = async (req, res) => {
// UX protection: prevent a regular user from deleting their own last organization.
// Global platform admins bypass this entirely (moderation); a member of multiple orgs
// is also safe to delete the current one since they keep at least one membership.
const isGlobalAdmin = Array.isArray(req.user?.roles) && req.user.roles.includes('admin');
const admin = isGlobalAdmin(req.user);
const isMemberOfTarget = !!req.membership;
if (!isGlobalAdmin && isMemberOfTarget) {
if (!admin && isMemberOfTarget) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
const userMemberships = await MembershipService.listByUser(req.user._id || req.user.id);
if (userMemberships.length <= 1) {
return responses.error(res, 422, 'Unprocessable Entity', 'You cannot delete your last organization')();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import errors from '../../../lib/helpers/errors.js';
import responses from '../../../lib/helpers/responses.js';
import isGlobalAdmin from '../../../lib/helpers/isGlobalAdmin.js';
import MembershipService from '../services/organizations.membership.service.js';
import { MEMBERSHIP_ROLES } from '../lib/constants.js';

Expand Down Expand Up @@ -34,8 +35,11 @@ const list = async (req, res) => {
*/
const updateRole = async (req, res) => {
try {
// Belt-and-suspenders: only owners can change roles (CASL blocks admins via no 'update Membership')
if (!req.membership || req.membership.role !== MEMBERSHIP_ROLES.OWNER) {
// Belt-and-suspenders: only org owners can change roles (CASL blocks non-owner org
// roles via no 'update Membership'). Global platform admins bypass the membership
// requirement for moderation — notably to transfer ownership on a third-party org.
const admin = isGlobalAdmin(req.user);
if (!admin && (!req.membership || req.membership.role !== MEMBERSHIP_ROLES.OWNER)) {
return responses.error(res, 403, 'Forbidden', 'Only owners can change member roles')();
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
}
const membership = await MembershipService.updateRole(req.membershipDoc, req.body.role);
Expand All @@ -56,10 +60,10 @@ const remove = async (req, res) => {
try {
// Only owners can remove anyone; admins can only remove members.
// Global platform admins bypass org-level RBAC for moderation needs.
const isGlobalAdmin = Array.isArray(req.user?.roles) && req.user.roles.includes('admin');
const admin = isGlobalAdmin(req.user);
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
const actorRole = req.membership?.role;
const targetRole = req.membershipDoc.role;
const canRemove = isGlobalAdmin
const canRemove = admin
|| actorRole === MEMBERSHIP_ROLES.OWNER
Comment thread
PierreBrisorgueil marked this conversation as resolved.
|| (actorRole === MEMBERSHIP_ROLES.ADMIN && targetRole === MEMBERSHIP_ROLES.MEMBER);
if (!canRemove) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,52 @@ describe('Membership controller unit tests:', () => {
expect(mockUpdateRole).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalledWith(403);
});

test('should allow global admin with no org membership to change a role', async () => {
const updatedMembership = { id: 'mem1', role: MEMBERSHIP_ROLES.OWNER };
mockUpdateRole.mockResolvedValue(updatedMembership);

const req = mockReq({
user: { _id: 'adm', roles: ['user', 'admin'] },
membership: undefined,
body: { role: MEMBERSHIP_ROLES.OWNER },
});
const res = mockRes();

await membershipController.updateRole(req, res);

expect(mockUpdateRole).toHaveBeenCalledTimes(1);
expect(mockUpdateRole).toHaveBeenCalledWith(req.membershipDoc, MEMBERSHIP_ROLES.OWNER);
expect(res.status).not.toHaveBeenCalledWith(403);
});

test('should still reject non-admin non-owner even with explicit role member', async () => {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
const req = mockReq({
user: { _id: 'u1', roles: ['user'] },
membership: { role: MEMBERSHIP_ROLES.MEMBER },
body: { role: MEMBERSHIP_ROLES.ADMIN },
});
const res = mockRes();

await membershipController.updateRole(req, res);

expect(res.status).toHaveBeenCalledWith(403);
expect(mockUpdateRole).not.toHaveBeenCalled();
});

test('should still reject org-level admin (not global) trying to change a role', async () => {
const req = mockReq({
user: { _id: 'u1', roles: ['user'] },
membership: { role: MEMBERSHIP_ROLES.ADMIN },
body: { role: MEMBERSHIP_ROLES.OWNER },
});
const res = mockRes();

await membershipController.updateRole(req, res);

expect(res.status).toHaveBeenCalledWith(403);
expect(mockUpdateRole).not.toHaveBeenCalled();
});
});

describe('remove', () => {
Expand Down
Loading