Skip to content

Commit 9d65cf8

Browse files
refactor(modules): decoupling — constants, removeSensitive, audit req, last owner (#3403)
* chore(organizations): rename middleware/ to middlewares/ for consistency (#3402) * chore(organizations): fix test import path after middleware/ → middlewares/ rename * refactor(organizations): add constants.js for membership status and role magic strings (#3400) * refactor(membership): extract validateLastOwnerProtection private helper (#3399) * refactor(users): move removeSensitive() from AuthService to UsersService (#3401) * fix(audit): remove req dependency from AuditService.log() (#3398) * fix(modules): address reviewer feedback — constants, removeSensitive util, test precision, JSDoc * fix(organizations): fail-closed RBAC guards, active-owner count filter * test(organizations): add membership controller RBAC unit tests * fix(organizations): neutral error message, MEMBERSHIP_ROLES constants in tests
1 parent 3337568 commit 9d65cf8

22 files changed

Lines changed: 404 additions & 337 deletions

modules/audit/middlewares/audit.middleware.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import AuditService from '../services/audit.service.js';
55
import logger from '../../../lib/services/logger.js';
6+
import config from '../../../config/index.js';
67

78
/**
89
* Default route prefixes to skip when auto-capturing audit events.
@@ -124,7 +125,10 @@ const createAuditMiddleware = (options = {}) => {
124125

125126
AuditService.log({
126127
action,
127-
req,
128+
userId: req.user?._id || req.user?.id,
129+
organizationId: req.organization?._id || req.organization?.id,
130+
ip: config.audit?.captureIp !== false ? (req.ip || req.connection?.remoteAddress || '') : undefined,
131+
userAgent: config.audit?.captureUserAgent !== false ? (req.headers?.['user-agent'] || '') : undefined,
128132
targetType,
129133
targetId,
130134
}).catch((err) => logger.error('audit.middleware: audit log write failed', { message: err?.message, stack: err?.stack }));

modules/audit/services/audit.service.js

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import AuditRepository from '../repositories/audit.repository.js';
1010
* @description Record an audit log entry. No-op when audit is disabled.
1111
* @param {Object} params
1212
* @param {string} params.action - Action identifier (e.g. 'auth.login', 'billing.subscribe')
13-
* @param {Object} [params.req] - Express request object (extracts userId, orgId, ip, userAgent)
13+
* @param {string} [params.userId] - ID of the acting user
14+
* @param {string} [params.organizationId] - ID of the organization context
15+
* @param {string} [params.ip] - IP address of the request
16+
* @param {string} [params.userAgent] - User-agent of the request
1417
* @param {string} [params.targetType] - Type of the target entity
1518
* @param {string} [params.targetId] - ID of the target entity
1619
* @param {Object} [params.metadata] - Additional metadata
1720
* @returns {Promise<Object|null>} The created audit log entry or null if disabled
1821
*/
19-
const log = async ({ action, req, targetType, targetId, metadata } = {}) => {
22+
const log = async ({ action, userId, organizationId, ip, userAgent, targetType, targetId, metadata } = {}) => {
2023
if (!config.audit?.enabled) return null;
2124
if (!action) return null;
2225

@@ -27,15 +30,10 @@ const log = async ({ action, req, targetType, targetId, metadata } = {}) => {
2730
metadata: metadata || {},
2831
};
2932

30-
// Extract context from request if available (coerce ObjectIds to strings)
31-
if (req) {
32-
const uid = req.user?._id || req.user?.id;
33-
const oid = req.organization?._id || req.organization?.id;
34-
if (uid) entry.userId = String(uid);
35-
if (oid) entry.orgId = String(oid);
36-
entry.ip = config.audit?.captureIp !== false ? (req.ip || req.connection?.remoteAddress || '') : '';
37-
entry.userAgent = config.audit?.captureUserAgent !== false ? (req.headers?.['user-agent'] || '') : '';
38-
}
33+
if (userId) entry.userId = String(userId);
34+
if (organizationId) entry.orgId = String(organizationId);
35+
if (ip !== undefined) entry.ip = ip || '';
36+
if (userAgent !== undefined) entry.userAgent = userAgent || '';
3937

4038
try {
4139
return await AuditRepository.create(entry);

modules/audit/tests/audit.integration.tests.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@ describe('Audit integration tests:', () => {
6868
// Create some audit entries
6969
await AuditService.log({
7070
action: 'auth.login',
71-
req: { user: { _id: adminUser.id }, headers: { 'user-agent': 'test' } },
71+
userId: adminUser.id,
72+
userAgent: 'test',
7273
targetType: 'User',
7374
targetId: adminUser.id,
7475
});
7576
await AuditService.log({
7677
action: 'auth.signup',
77-
req: { user: { _id: adminUser.id }, headers: { 'user-agent': 'test' } },
78+
userId: adminUser.id,
79+
userAgent: 'test',
7880
targetType: 'User',
7981
targetId: adminUser.id,
8082
});

modules/audit/tests/audit.middleware.unit.tests.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,11 @@ describe('Audit middleware unit tests:', () => {
148148
const call = mockLog.mock.calls[0][0];
149149
expect(call.action).toBe('auth.signin');
150150
expect(call.targetType).toBe('User');
151-
expect(call.req).toBe(req);
151+
expect(call.req).toBeUndefined();
152+
expect(call.userId).toBe('507f1f77bcf86cd799439011');
153+
expect(call.organizationId).toBe('507f1f77bcf86cd799439012');
154+
expect(call.ip).toBe('127.0.0.1');
155+
expect(call.userAgent).toBe('TestAgent/1.0');
152156
});
153157

154158
test('should log PUT mutations', () => {

modules/audit/tests/audit.service.unit.tests.js

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,13 @@ describe('AuditService unit tests:', () => {
5454
test('should log an audit entry with request context', async () => {
5555
const userId = '507f1f77bcf86cd799439011';
5656
const orgId = '507f1f77bcf86cd799439012';
57-
const req = {
58-
user: { _id: userId },
59-
organization: { _id: orgId },
60-
ip: '127.0.0.1',
61-
headers: { 'user-agent': 'TestAgent/1.0' },
62-
};
6357

6458
await AuditService.log({
6559
action: 'auth.login',
66-
req,
60+
userId,
61+
organizationId: orgId,
62+
ip: '127.0.0.1',
63+
userAgent: 'TestAgent/1.0',
6764
targetType: 'User',
6865
targetId: userId,
6966
metadata: { foo: 'bar' },
@@ -115,47 +112,37 @@ describe('AuditService unit tests:', () => {
115112
expect(mockList).toHaveBeenCalledWith({ userId }, 1, 10);
116113
});
117114

118-
// GDPR config flag tests
119-
test('should capture IP when captureIp is true (default)', async () => {
120-
mockConfig.audit = { enabled: true, ttlDays: 90, captureIp: true, captureUserAgent: true };
121-
const req = { ip: '10.0.0.1', headers: { 'user-agent': 'Bot/1.0' } };
122-
await AuditService.log({ action: 'test.ip', req });
115+
// GDPR config flag tests — ip/userAgent are now extracted by the caller
116+
test('should store ip when provided', async () => {
117+
await AuditService.log({ action: 'test.ip', ip: '10.0.0.1', userAgent: 'Bot/1.0' });
123118
expect(mockCreate).toHaveBeenCalledTimes(1);
124119
const arg = mockCreate.mock.calls[0][0];
125120
expect(arg.ip).toBe('10.0.0.1');
126121
});
127122

128-
test('should set IP to empty string when captureIp is false', async () => {
129-
mockConfig.audit = { enabled: true, ttlDays: 90, captureIp: false, captureUserAgent: true };
130-
const req = { ip: '10.0.0.1', headers: { 'user-agent': 'Bot/1.0' } };
131-
await AuditService.log({ action: 'test.ip', req });
123+
test('should omit ip when undefined is passed', async () => {
124+
await AuditService.log({ action: 'test.ip', ip: undefined, userAgent: 'Bot/1.0' });
132125
expect(mockCreate).toHaveBeenCalledTimes(1);
133126
const arg = mockCreate.mock.calls[0][0];
134-
expect(arg.ip).toBe('');
127+
expect(arg.ip).toBeUndefined();
135128
});
136129

137-
test('should capture User-Agent when captureUserAgent is true (default)', async () => {
138-
mockConfig.audit = { enabled: true, ttlDays: 90, captureIp: true, captureUserAgent: true };
139-
const req = { ip: '10.0.0.1', headers: { 'user-agent': 'Bot/1.0' } };
140-
await AuditService.log({ action: 'test.ua', req });
130+
test('should store userAgent when provided', async () => {
131+
await AuditService.log({ action: 'test.ua', ip: '10.0.0.1', userAgent: 'Bot/1.0' });
141132
expect(mockCreate).toHaveBeenCalledTimes(1);
142133
const arg = mockCreate.mock.calls[0][0];
143134
expect(arg.userAgent).toBe('Bot/1.0');
144135
});
145136

146-
test('should set User-Agent to empty string when captureUserAgent is false', async () => {
147-
mockConfig.audit = { enabled: true, ttlDays: 90, captureIp: true, captureUserAgent: false };
148-
const req = { ip: '10.0.0.1', headers: { 'user-agent': 'Bot/1.0' } };
149-
await AuditService.log({ action: 'test.ua', req });
137+
test('should omit userAgent when undefined is passed', async () => {
138+
await AuditService.log({ action: 'test.ua', ip: '10.0.0.1', userAgent: undefined });
150139
expect(mockCreate).toHaveBeenCalledTimes(1);
151140
const arg = mockCreate.mock.calls[0][0];
152-
expect(arg.userAgent).toBe('');
141+
expect(arg.userAgent).toBeUndefined();
153142
});
154143

155-
test('should default to capturing IP and User-Agent when config keys are undefined', async () => {
156-
mockConfig.audit = { enabled: true, ttlDays: 90 };
157-
const req = { ip: '192.168.1.1', headers: { 'user-agent': 'DefaultBot/2.0' } };
158-
await AuditService.log({ action: 'test.defaults', req });
144+
test('should store ip and userAgent when provided', async () => {
145+
await AuditService.log({ action: 'test.defaults', ip: '192.168.1.1', userAgent: 'DefaultBot/2.0' });
159146
expect(mockCreate).toHaveBeenCalledTimes(1);
160147
const arg = mockCreate.mock.calls[0][0];
161148
expect(arg.ip).toBe('192.168.1.1');

modules/billing/routes/billing.routes.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import passport from 'passport';
55

66
import model from '../../../lib/middlewares/model.js';
77
import policy from '../../../lib/middlewares/policy.js';
8-
import organization from '../../organizations/middleware/organizations.middleware.js';
8+
import organization from '../../organizations/middlewares/organizations.middleware.js';
99
import billingSchema from '../models/billing.subscription.schema.js';
1010
import billingPlans from '../controllers/billing.plans.controller.js';
1111
import billing from '../controllers/billing.controller.js';

modules/home/services/home.service.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import { Base64 } from 'js-base64';
88
import { promises as fs } from 'fs';
99
import mongoose from 'mongoose';
1010

11-
import AuthService from '../../auth/services/auth.service.js';
1211
import config from '../../../config/index.js';
1312
import mailer from '../../../lib/helpers/mailer/index.js';
1413
import HomeRepository from '../repositories/home.repository.js';
14+
import { removeSensitive } from '../../users/utils/sanitizeUser.js';
1515

1616
/**
1717
* @desc Check whether a config value is meaningfully set (non-empty, not a DEVKIT placeholder).
@@ -21,19 +21,20 @@ import HomeRepository from '../repositories/home.repository.js';
2121
const isSet = (value) => !!(value && typeof value === 'string' && value.trim() !== '' && !value.startsWith('DEVKIT_NODE_'));
2222

2323
/**
24-
* @desc Function to get all admin users in db
25-
* @return {Promise} All users
24+
* @desc Function to get page content from markdown file
25+
* @param {string} name - The name of the markdown file
26+
* @returns {Promise<Array>} Page content array
2627
*/
2728
const page = async (name) => {
2829
const markdown = await fs.readFile(path.resolve(`./config/markdown/${name}.md`), 'utf8');
2930
const test = await fs.stat(path.resolve(`./config/markdown/${name}.md`));
30-
return Promise.resolve([
31+
return [
3132
{
3233
title: _.startCase(name),
3334
updatedAt: test.mtime,
3435
markdown,
3536
},
36-
]);
37+
];
3738
};
3839

3940
/**
@@ -87,11 +88,11 @@ const changelogs = async () => {
8788

8889
/**
8990
* @desc Function to get all admin users in db
90-
* @return {Promise} All users
91+
* @returns {Promise<Array>} All users (sanitized)
9192
*/
9293
const team = async () => {
9394
const result = await HomeRepository.team();
94-
return Promise.resolve(result.map((user) => AuthService.removeSensitive(user)));
95+
return result.map((user) => removeSensitive(user));
9596
};
9697

9798
/**

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import errors from '../../../lib/helpers/errors.js';
55
import responses from '../../../lib/helpers/responses.js';
66
import MembershipService from '../services/organizations.membership.service.js';
7+
import { MEMBERSHIP_ROLES } from '../lib/constants.js';
78

89
/**
910
* @function list
@@ -34,7 +35,7 @@ const list = async (req, res) => {
3435
const updateRole = async (req, res) => {
3536
try {
3637
// Belt-and-suspenders: only owners can change roles (CASL blocks admins via no 'update Membership')
37-
if (req.membership && req.membership.role !== 'owner') {
38+
if (!req.membership || req.membership.role !== MEMBERSHIP_ROLES.OWNER) {
3839
return responses.error(res, 403, 'Forbidden', 'Only owners can change member roles')();
3940
}
4041
const membership = await MembershipService.updateRole(req.membershipDoc, req.body.role);
@@ -53,9 +54,13 @@ const updateRole = async (req, res) => {
5354
*/
5455
const remove = async (req, res) => {
5556
try {
56-
// Admins can only remove members, not other admins or owners
57-
if (req.membership && req.membership.role !== 'owner' && req.membershipDoc.role !== 'member') {
58-
return responses.error(res, 403, 'Forbidden', 'Only owners can remove admins or other owners')();
57+
// Only owners can remove anyone; admins can only remove members
58+
const actorRole = req.membership?.role;
59+
const targetRole = req.membershipDoc.role;
60+
const canRemove = actorRole === MEMBERSHIP_ROLES.OWNER
61+
|| (actorRole === MEMBERSHIP_ROLES.ADMIN && targetRole === MEMBERSHIP_ROLES.MEMBER);
62+
if (!canRemove) {
63+
return responses.error(res, 403, 'Forbidden', 'Insufficient permissions to remove this member')();
5964
}
6065
const result = await MembershipService.remove(req.membershipDoc);
6166
responses.success(res, 'membership deleted')({ id: req.membershipDoc.id, ...result });

modules/organizations/controllers/organizations.membershipRequest.controller.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import errors from '../../../lib/helpers/errors.js';
55
import responses from '../../../lib/helpers/responses.js';
66
import MembershipService from '../services/organizations.membership.service.js';
7+
import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../lib/constants.js';
78

89
/**
910
* @function create
@@ -33,7 +34,7 @@ const create = async (req, res) => {
3334
*/
3435
const listPending = async (req, res) => {
3536
try {
36-
if (!req.membership || req.membership.role === 'member') {
37+
if (!req.membership || req.membership.role === MEMBERSHIP_ROLES.MEMBER) {
3738
return responses.success(res, 'membership request list')([]);
3839
}
3940
const requests = await MembershipService.listPending(req.organization._id || req.organization.id);
@@ -165,7 +166,7 @@ const requestByID = async (req, res, next, id) => {
165166
const membership = await MembershipService.get(id);
166167
const organizationId = String(req.organization._id || req.organization.id);
167168
const membershipOrgId = String(membership?.organizationId?._id || membership?.organizationId);
168-
if (!membership || membership.status !== 'pending' || membershipOrgId !== organizationId) {
169+
if (!membership || membership.status !== MEMBERSHIP_STATUSES.PENDING || membershipOrgId !== organizationId) {
169170
return responses.error(res, 404, 'Not Found', 'No pending request with that identifier has been found')();
170171
}
171172
req.membershipRequest = membership;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const MEMBERSHIP_STATUSES = {
2+
ACTIVE: 'active',
3+
PENDING: 'pending',
4+
INVITED: 'invited',
5+
REJECTED: 'rejected',
6+
};
7+
8+
export const MEMBERSHIP_ROLES = {
9+
OWNER: 'owner',
10+
ADMIN: 'admin',
11+
MEMBER: 'member',
12+
};

0 commit comments

Comments
 (0)