Skip to content

Commit c658417

Browse files
fix(auth,orgs,uploads,users): address CodeRabbit review findings
- Add JSDoc to rateLimiter passthrough and Proxy handler - Rollback user account if email verification init fails (auth signup) - Return standard error envelope for 401 invalid credentials - Rate-limit resend-verification endpoint - Add JSDoc to organizations route registrar - Make org creation atomic with rollback in crud service - Sanitize domain-match orgs before returning from signup flow - Complete cleanupUser helper JSDoc in org tests - Scope upload reads/deletes to user's organization via CASL - Narrow guest CASL access from UserAccount to UserStats - Validate upsert filter keys on every row in users push() - Add runValidators to updateById, findByIdAndUpdatePopulated, updateMany - Update auth tests for new 401 error envelope format
1 parent b590f5f commit c658417

12 files changed

Lines changed: 98 additions & 40 deletions

File tree

lib/middlewares/policy.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ const USER_ROUTE_SUBJECTS = {
174174
'/api/users/avatar': 'UserAccount',
175175
'/api/users/data': 'UserAccount',
176176
'/api/users/data/mail': 'UserAccount',
177-
'/api/users/stats': 'UserAccount',
177+
'/api/users/stats': 'UserStats',
178178
'/api/users': 'UserSelf',
179179
};
180180

lib/middlewares/rateLimiter.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,22 @@ import rateLimit from 'express-rate-limit';
77

88
import config from '../../config/index.js';
99

10+
/**
11+
* @desc No-op middleware used when a rate-limit profile is not configured.
12+
* @param {import('express').Request} req - Express request object
13+
* @param {import('express').Response} res - Express response object
14+
* @param {import('express').NextFunction} next - Express next function
15+
* @returns {void}
16+
*/
1017
const passthrough = (req, res, next) => next();
1118

19+
/**
20+
* @desc Proxy that lazily creates rate-limit middleware for each named profile.
21+
* Returns the configured limiter or a passthrough when the profile is missing.
22+
* @param {Object} target - Internal cache of created limiters
23+
* @param {string|symbol} name - The rate-limit profile name to look up
24+
* @returns {Function} Express middleware (rate limiter or passthrough)
25+
*/
1226
const limiters = new Proxy({}, {
1327
get(target, name) {
1428
if (name in target) return target[name];

modules/auth/controllers/auth.controller.js

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,22 +64,27 @@ const signup = async (req, res) => {
6464
const safeBody = { ...req.body, roles: ['user'] };
6565
const user = await UserService.create(safeBody);
6666

67-
// Handle email verification
68-
if (isMailerConfigured()) {
69-
// Generate verification token and persist it
70-
const verificationToken = crypto.randomBytes(20).toString('hex');
71-
const brutUser = await UserService.getBrut({ id: user.id });
72-
await UserService.update(brutUser, {
73-
emailVerificationToken: verificationToken,
74-
emailVerificationExpires: Date.now() + 24 * 3600000, // 24 hours
75-
}, 'recover');
76-
// Send verification email (best-effort, do not block signup)
77-
sendVerificationEmail(user, verificationToken).catch(() => {});
78-
} else {
79-
// No mailer configured — auto-verify so dev/test are not blocked
80-
const brutUser = await UserService.getBrut({ id: user.id });
81-
await UserService.update(brutUser, { emailVerified: true }, 'recover');
82-
user.emailVerified = true;
67+
// Handle email verification — rollback user on failure to avoid orphaned accounts
68+
try {
69+
if (isMailerConfigured()) {
70+
// Generate verification token and persist it
71+
const verificationToken = crypto.randomBytes(20).toString('hex');
72+
const brutUser = await UserService.getBrut({ id: user.id });
73+
await UserService.update(brutUser, {
74+
emailVerificationToken: verificationToken,
75+
emailVerificationExpires: Date.now() + 24 * 3600000, // 24 hours
76+
}, 'recover');
77+
// Send verification email (best-effort, do not block signup)
78+
sendVerificationEmail(user, verificationToken).catch(() => {});
79+
} else {
80+
// No mailer configured — auto-verify so dev/test are not blocked
81+
const brutUser = await UserService.getBrut({ id: user.id });
82+
await UserService.update(brutUser, { emailVerified: true }, 'recover');
83+
user.emailVerified = true;
84+
}
85+
} catch (verifyErr) {
86+
try { await UserService.remove(user); } catch (_cleanupErr) { /* best-effort */ }
87+
throw verifyErr;
8388
}
8489

8590
// Handle organization provisioning based on config
@@ -144,7 +149,7 @@ const signinAuthenticate = (req, res, next) => {
144149
return responses.error(res, 500, 'Internal Server Error', errors.getMessage(err))(err);
145150
}
146151
if (!user) {
147-
return res.status(401).send('Unauthorized');
152+
return responses.error(res, 401, 'Unauthorized', info?.message || 'Unauthorized')();
148153
}
149154
req.user = user;
150155
return next();

modules/auth/routes/auth.routes.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ export default (app) => {
3636

3737
// Email verification
3838
app.route('/api/auth/verify-email/:token').post(authLimiter, auth.verifyEmail);
39-
app.route('/api/auth/resend-verification').post(passport.authenticate('jwt', { session: false }), auth.resendVerification);
39+
app.route('/api/auth/resend-verification').post(
40+
authLimiter,
41+
passport.authenticate('jwt', { session: false }),
42+
auth.resendVerification,
43+
);
4044

4145
// Jwt reset token
4246
app.route('/api/auth/token').get(passport.authenticate('jwt', { session: false }), auth.token);

modules/auth/tests/auth.integration.tests.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe('Auth integration tests:', () => {
211211
password: 'W@os.jsI$Aw3$0m3',
212212
})
213213
.expect(401);
214-
expect(result.error.text).toBe('Unauthorized');
214+
expect(result.body.message).toBe('Unauthorized');
215215
} catch (err) {
216216
console.log(err);
217217
expect(err).toBeFalsy();
@@ -249,7 +249,7 @@ describe('Auth integration tests:', () => {
249249
password: 'WrongPassword!123',
250250
})
251251
.expect(401);
252-
expect(result.error.text).toBe('Unauthorized');
252+
expect(result.body.message).toBe('Unauthorized');
253253
} catch (err) {
254254
console.log(err);
255255
expect(err).toBeFalsy();

modules/organizations/routes/organizations.routes.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import organizations from '../controllers/organizations.controller.js';
99
import organizationsSchema from '../models/organizations.schema.js';
1010

1111
/**
12-
* Routes
12+
* @desc Register organization CRUD and membership routes on the Express application.
13+
* @param {Object} app - Express application instance
14+
* @returns {void}
1315
*/
1416
export default (app) => {
1517
// Organization CRUD

modules/organizations/services/organizations.crud.service.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,22 @@ const create = async (body, user) => {
102102
throw err;
103103
}
104104

105-
// Create owner membership for the creator
106-
await MembershipRepository.create({
107-
userId: user.id || user._id,
108-
organizationId: result._id,
109-
role: 'owner',
110-
});
105+
// Create owner membership and set current org — rollback on failure
106+
let membership;
107+
try {
108+
membership = await MembershipRepository.create({
109+
userId: user.id || user._id,
110+
organizationId: result._id,
111+
role: 'owner',
112+
});
111113

112-
// Set as user's current organization
113-
await UserService.updateById(user.id || user._id, { currentOrganization: result._id });
114+
await UserService.updateById(user.id || user._id, { currentOrganization: result._id });
115+
} catch (err) {
116+
// Rollback partially created artifacts
117+
if (membership) await MembershipRepository.deleteMany({ _id: membership._id }).catch(() => {});
118+
await OrganizationsRepository.remove(result).catch(() => {});
119+
throw err;
120+
}
114121

115122
return result;
116123
};

modules/organizations/services/organizations.service.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ import MembershipService from './organizations.membership.service.js';
1010
import UserService from '../../users/services/users.service.js';
1111
import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js';
1212

13+
/**
14+
* @desc Strip sensitive fields from an organization document before returning to public flows.
15+
* @param {Object} org - Organization document (Mongoose or plain).
16+
* @returns {Object} Safe projection without createdBy or plan.
17+
*/
18+
const sanitizeOrg = (org) => {
19+
const obj = org.toJSON ? org.toJSON() : { ...org };
20+
delete obj.createdBy;
21+
delete obj.plan;
22+
return obj;
23+
};
24+
1325
/**
1426
* Extract the domain part from an email address.
1527
* @param {string} email - A valid email address.
@@ -158,8 +170,8 @@ const handleSignupOrganization = async (user) => {
158170
const existingOrgs = await OrganizationsRepository.list({ domain });
159171
if (existingOrgs.length > 0) {
160172
// Create a pending join request — admin must approve
161-
const organization = existingOrgs[0];
162-
await MembershipService.createJoinRequest(user.id || user._id, organization._id);
173+
const organization = sanitizeOrg(existingOrgs[0]);
174+
await MembershipService.createJoinRequest(user.id || user._id, existingOrgs[0]._id);
163175
const ability = await policy.defineAbilityFor(user, null);
164176
return {
165177
organization,
@@ -202,7 +214,7 @@ const handleSignupOrganization = async (user) => {
202214
if (orgConfig.domainMatching && !isPublic) {
203215
const existingOrgs = await OrganizationsRepository.list({ domain });
204216
if (existingOrgs.length > 0) {
205-
suggestedOrganization = existingOrgs[0];
217+
suggestedOrganization = sanitizeOrg(existingOrgs[0]);
206218
}
207219
}
208220

modules/organizations/tests/organizations.integration.tests.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('Organizations integration tests:', () => {
2525
/**
2626
* @description Clean up a user and their associated organizations/memberships.
2727
* @param {Object} user - The user object to clean up.
28+
* @returns {Promise<void>}
2829
*/
2930
const cleanupUser = async (user) => {
3031
if (!user) return;

modules/uploads/policies/uploads.policy.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@ export function uploadAbilities(user, membership, { can }) {
1616
can('manage', 'all');
1717
return;
1818
}
19-
can('read', 'Upload');
20-
can('delete', 'Upload', { 'metadata.user': String(user._id) });
19+
if (membership && membership.organizationId) {
20+
const orgId = String(membership.organizationId._id || membership.organizationId);
21+
can('read', 'Upload', { 'metadata.organizationId': orgId });
22+
can('delete', 'Upload', { 'metadata.organizationId': orgId, 'metadata.user': String(user._id) });
23+
} else {
24+
// Users without a membership can only access their own uploads
25+
can('read', 'Upload', { 'metadata.user': String(user._id) });
26+
can('delete', 'Upload', { 'metadata.user': String(user._id) });
27+
}
2128
}
2229

2330
/**

0 commit comments

Comments
 (0)