Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
41 changes: 41 additions & 0 deletions backend/docs/org-permissions-test-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Org Permissions Test Matrix

## Route Authorization

- `GET /org-roles/:orgId/members`
- non-member -> `403`
- member without `manage_members` -> `403`
- manager/owner -> `200`
- `POST /org-roles/:orgId/members/:userId/role`
- non-member -> `403`
- manager assigning above own order -> `403`
- assigning `owner` without transfer -> `403`
- valid manager assignment -> `200`
- `GET /org-roles/:orgId/roles/:roleName/members`
- non-member -> `403`
- member without `manage_members` -> `403`
- manager/owner -> `200`
- `GET /org/:orgId/forms`
- member without `manage_members` -> `403`
- manager/owner -> `200`

## Ownership Invariants

- transfer ownership endpoint updates:
- `Org.owner` to target user
- target `OrgMember` includes `roles: ['owner', ...]`
- previous owner loses `owner` role
- `owner` role cannot be assigned through general role assignment endpoint

## Multi-Role Compatibility

- existing records with only `role` are backfilled to `roles: [role]`
- permission checks resolve from union of `roles[]`
- invite acceptance copies `roles[]` and sets legacy `role` to first element

## Frontend Regression Checks

- `RoleManager` shows owner role as visible immutable system role
- member role assignment modal supports selecting multiple roles
- role options above actor hierarchy are hidden/disabled
- application approval includes selected role payload
46 changes: 28 additions & 18 deletions backend/middlewares/orgPermissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,33 @@ function requireOrgPermission(permission, orgParam = 'orgId') {
return next();
}

const org = await Org.findById(orgId);
if (!org) {
return res.status(404).json({
success: false,
message: 'Organization not found'
});
}

const member = await OrgMember.findOne({
org_id: orgId,
user_id: req.user.userId,
status: 'active'
});

if (!member) {
const isRecordOwner = String(org.owner) === String(req.user.userId);
if (!member && !isRecordOwner) {
console.log('Denied, You are not a member of this organization');
return res.status(403).json({
success: false,
message: 'You are not a member of this organization'
});
}

// Get the organization to check permissions
const org = await Org.findById(orgId);
if (!org) {
return res.status(404).json({
success: false,
message: 'Organization not found'
});
if (isRecordOwner) {
req.orgMember = member || { role: 'owner', roles: ['owner'] };
req.org = org;
return next();
}

const hasPermission = await member.hasPermissionWithOrg(permission, org);
Expand Down Expand Up @@ -112,27 +118,31 @@ function requireAnyOrgPermission(permissions, orgParam = 'orgId') {
return next();
}

const org = await Org.findById(orgId);
if (!org) {
return res.status(404).json({
success: false,
message: 'Organization not found'
});
}

const member = await OrgMember.findOne({
org_id: orgId,
user_id: req.user.userId,
status: 'active'
});

if (!member) {
const isRecordOwner = String(org.owner) === String(req.user.userId);
if (!member && !isRecordOwner) {
console.log('Denied, You are not a member of this organization');
return res.status(403).json({
success: false,
message: 'You are not a member of this organization'
});
}

// Get the organization to check permissions
const org = await Org.findById(orgId);
if (!org) {
return res.status(404).json({
success: false,
message: 'Organization not found'
});
if (isRecordOwner) {
req.orgMember = member || { role: 'owner', roles: ['owner'] };
req.org = org;
return next();
}

// Check if user has any of the required permissions
Expand Down
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"test:integration": "NODE_ENV=test jest --runInBand --testPathPatterns=tests/integration",
"test:routes": "NODE_ENV=test jest --runInBand --testPathPatterns=tests/route-outcomes",
"test:coverage": "NODE_ENV=test jest --runInBand --coverage",
"setup-saml": "node scripts/setupSAML.js"
"setup-saml": "node scripts/setupSAML.js",
"migrate:org-member-roles": "node scripts/backfillOrgMemberRoles.js"
},
"devDependencies": {
"jest": "^30.3.0",
Expand Down
62 changes: 62 additions & 0 deletions backend/routes/adminRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,68 @@ router.post('/admin/migrate-classroom-building-refs', verifyToken, requireAdmin,
}
});

/**
* POST /admin/migrate-org-owner-roles
* Ensure every org owner has an active OrgMember record with owner role.
* Also repairs existing owner memberships that drifted away from owner role.
*/
router.post('/admin/migrate-org-owner-roles', verifyToken, requireAdmin, async (req, res) => {
try {
const getModels = require('../services/getModelService');
const { Org, OrgMember } = getModels(req, 'Org', 'OrgMember');

const orgs = await Org.find({}, { _id: 1, owner: 1 }).lean();
let createdOwnerMemberships = 0;
let repairedOwnerMemberships = 0;

for (const org of orgs) {
if (!org?.owner) continue;
const ownerId = org.owner;
const ownerMembership = await OrgMember.findOne({ org_id: org._id, user_id: ownerId });

if (!ownerMembership) {
await OrgMember.create({
org_id: org._id,
user_id: ownerId,
role: 'owner',
roles: ['owner'],
status: 'active',
assignedBy: ownerId
});
createdOwnerMemberships += 1;
continue;
}

const rolesArray = Array.isArray(ownerMembership.roles) ? ownerMembership.roles : [];
const hasOwnerRoleField = ownerMembership.role === 'owner';
const hasOwnerInRolesArray = rolesArray.includes('owner');
const isActive = ownerMembership.status === 'active';
if (!hasOwnerRoleField || !hasOwnerInRolesArray || !isActive) {
const repairedRoles = [
'owner',
...(rolesArray.filter((roleName) => roleName && roleName !== 'owner'))
];
ownerMembership.role = 'owner';
ownerMembership.roles = repairedRoles;
ownerMembership.status = 'active';
await ownerMembership.save();
repairedOwnerMemberships += 1;
}
}

const data = {
orgsScanned: orgs.length,
createdOwnerMemberships,
repairedOwnerMemberships
};
console.log('POST /admin/migrate-org-owner-roles completed:', data);
res.json({ success: true, data });
} catch (err) {
console.error('POST /admin/migrate-org-owner-roles failed:', err);
res.status(500).json({ success: false, message: err.message });
}
});

router.get('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) => {
if (!req.user.platformRoles?.includes('platform_admin')) {
return res.status(403).json({ success: false, message: 'Platform admin required.' });
Expand Down
51 changes: 48 additions & 3 deletions backend/routes/orgEventManagementRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,51 @@ router.post('/:orgId/events/from-template/:templateId', verifyToken, requireEven
}
});

// Update single event
router.delete('/:orgId/events/:eventId', verifyToken, requireEventManagement('orgId'), async (req, res) => {
const { Event, EventAgenda, EventRole, EventRoleAssignment, EventJob } = getModels(
req,
'Event',
'EventAgenda',
'EventRole',
'EventRoleAssignment',
'EventJob'
);
const { orgId, eventId } = req.params;

try {
const event = await Event.findOne(buildScopedEventQuery(orgId, eventId));
if (!event) {
return res.status(404).json({
success: false,
message: 'Event not found'
});
}

// Hard delete for now; keep this route as the single transition point for future soft deletion.
const cleanupOps = [];
if (EventAgenda?.deleteMany) cleanupOps.push(EventAgenda.deleteMany({ eventId: event._id }));
if (EventRole?.deleteMany) cleanupOps.push(EventRole.deleteMany({ eventId: event._id }));
if (EventRoleAssignment?.deleteMany) cleanupOps.push(EventRoleAssignment.deleteMany({ eventId: event._id }));
if (EventJob?.deleteMany) cleanupOps.push(EventJob.deleteMany({ eventId: event._id }));

await Promise.all(cleanupOps);
await Event.deleteOne({ _id: event._id });

return res.status(200).json({
success: true,
message: 'Event cancelled and deleted successfully'
});
} catch (error) {
console.error('Error cancelling event:', error);
return res.status(500).json({
success: false,
message: 'Failed to cancel event',
error: error.message
});
}
});

// Update single event
router.put('/:orgId/events/:eventId', verifyToken, requireEventManagement('orgId'), async (req, res) => {
const { Event, EventAgenda } = getModels(req, 'Event', 'EventAgenda');
Expand Down Expand Up @@ -2641,7 +2686,7 @@ router.post('/:orgId/events/:eventId/volunteer-signups', verifyToken, async (req
const { VolunteerSignup, EventJob } = getModels(req, 'VolunteerSignup', 'EventJob');
const { orgId, eventId } = req.params;
const { roleId, shiftStart, shiftEnd, breakRequest, availability } = req.body;
const memberId = req.user._id;
const memberId = req.user.userId;

try {
// Check if already signed up
Expand Down Expand Up @@ -3049,7 +3094,7 @@ router.post('/:orgId/events/:eventId/equipment/:equipmentId/checkin', verifyToke
router.post('/:orgId/equipment/:equipmentId/member-checkout', verifyToken, requireOrgPermission('manage_equipment', 'orgId'), async (req, res) => {
const { OrgEquipment } = getModels(req, 'OrgEquipment');
const { orgId, equipmentId } = req.params;
const memberId = req.user._id;
const memberId = req.user.userId;

try {
const equipment = await OrgEquipment.findOne({
Expand Down Expand Up @@ -3095,7 +3140,7 @@ router.post('/:orgId/equipment/:equipmentId/member-checkout', verifyToken, requi
router.post('/:orgId/equipment/:equipmentId/member-checkin', verifyToken, requireOrgPermission('manage_equipment', 'orgId'), async (req, res) => {
const { OrgEquipment } = getModels(req, 'OrgEquipment');
const { orgId, equipmentId } = req.params;
const memberId = req.user._id;
const memberId = req.user.userId;

try {
const equipment = await OrgEquipment.findOne({
Expand Down
4 changes: 2 additions & 2 deletions backend/routes/orgInviteRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ router.post('/decline-by-token', verifyToken, async (req, res) => {
router.post('/:orgId/invite', verifyToken, requireMemberManagement('orgId'), async (req, res) => {
try {
const { orgId } = req.params;
const { email, role = 'member' } = req.body;
const { email, role = 'member', roles = [] } = req.body;

if (!email || !String(email).trim()) {
return res.status(400).json({
Expand All @@ -150,7 +150,7 @@ router.post('/:orgId/invite', verifyToken, requireMemberManagement('orgId'), asy
});
}

const result = await orgInviteService.createInvite(req, orgId, email.trim(), role);
const result = await orgInviteService.createInvite(req, orgId, email.trim(), roles.length > 0 ? roles : role);

res.status(201).json({
success: true,
Expand Down
Loading