Skip to content

Commit 2cc9781

Browse files
feat(organizations): config-gated email verification policy {strict|off} (#3919)
* feat(organizations): config-gate email-verification policy Add config.organizations.emailVerification.mode ('strict' default) and gate the existing email-verification checks in handleSignupOrganization and the domain search controller. 'strict' preserves current behavior; 'off' always auto-provisions / searches (same path as a mailer-not-configured env). emailVerified stays server-only (no input-surface change); zero data-model change. Adds unit tests for both modes. * fix(organizations): fail closed on unknown emailVerification.mode (review) Adversarial review (Copilot + CodeRabbit, security): the gate treated any non-'strict' value as 'off', so a typo or wrong casing silently bypassed verification (fail open). Bypass now requires the explicit permissive 'off' value; default / typo / casing all keep the strict gate. Adds a fail-closed unit test. Gate logic aligned in both the service and the controller. Claude-Session: https://claude.ai/code/session_011zXXYka6vU5utEGoT4frME
1 parent 2689a4d commit 2cc9781

4 files changed

Lines changed: 233 additions & 4 deletions

File tree

modules/organizations/config/organizations.development.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ const config = {
99
enabled: true, // false → B2C mode, organizations invisible
1010
autoCreate: true, // automatically create/join orgs at signup
1111
domainMatching: true, // match users to existing orgs by email domain
12+
// Email-verification policy for org provisioning/discovery.
13+
// 'strict' (default) → when the mailer is configured, an unverified user
14+
// cannot provision an org at signup nor run domain search; they must
15+
// verify their email first.
16+
// 'off' → email verification is never required for these flows; the user
17+
// is always auto-provisioned (same path as a mailer-not-configured env).
18+
// emailVerified stays server-only; this policy only gates the existing checks.
19+
emailVerification: { mode: 'strict' },
1220
roles: ['owner', 'admin', 'member'],
1321
roleDescriptions: {
1422
owner: 'Full control — manage organization settings, members, roles, and billing.',

modules/organizations/controllers/organizations.controller.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,13 @@ const organizationByPage = async (req, res, next, params) => {
184184
*/
185185
const search = async (req, res) => {
186186
try {
187-
// Block domain search for unverified users when mailer is configured
188-
if (mailer.isConfigured() && !req.user.emailVerified) {
187+
// Email-verification policy gate (config.organizations.emailVerification.mode).
188+
// FAIL CLOSED: only the explicit 'off' value lifts the gate; the default, a typo,
189+
// or wrong casing all keep the strict block, so a misconfiguration can never leak
190+
// the domain search to unverified users. 'off' → never block (same path as
191+
// mailer-not-configured).
192+
const emailVerificationOff = (config.organizations?.emailVerification?.mode ?? 'strict') === 'off';
193+
if (!emailVerificationOff && mailer.isConfigured() && !req.user.emailVerified) {
189194
return responses.success(res, 'organization search')([]);
190195
}
191196
const organizations = await OrganizationsService.searchByDomain(req.user.email);

modules/organizations/services/organizations.service.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,17 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
136136
const handleSignupOrganization = async (user) => {
137137
const orgConfig = config.organizations || {};
138138

139-
// When mailer is configured, require email verification before any org provisioning
140-
if (mailer.isConfigured() && !user.emailVerified) {
139+
// Email-verification policy gate (config.organizations.emailVerification.mode).
140+
// FAIL CLOSED: verification is bypassed ONLY for the explicit, permissive value
141+
// 'off'. Any other value — the default, a typo ('stict'), or wrong casing
142+
// ('STRICT') — keeps the strict gate, so a misconfiguration can never silently
143+
// auto-provision unverified users. 'off' → always auto-provision (same effective
144+
// path as a mailer-not-configured env). See module base config.
145+
const emailVerificationOff = (orgConfig.emailVerification?.mode ?? 'strict') === 'off';
146+
147+
// When the policy is NOT 'off' and the mailer is configured, require email
148+
// verification before any org provisioning.
149+
if (!emailVerificationOff && mailer.isConfigured() && !user.emailVerified) {
141150
return {
142151
organization: null,
143152
membership: null,
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* Unit tests — config-gated email-verification policy
3+
* (config.organizations.emailVerification.mode).
4+
*
5+
* Covers BOTH modes on the two gated surfaces (signup org provisioning + domain
6+
* search), with the mailer reported as configured and the user UNVERIFIED — the
7+
* only case the mode actually changes:
8+
* - 'strict' (default) → blocks the unverified user (no org, empty search).
9+
* - 'off' → always provisions / always searches (mailer-on path
10+
* behaves like a mailer-not-configured env).
11+
*
12+
* The default-strict + mailer-on + verified path stays byte-identical to today
13+
* and is exercised by organizations.emailVerification.unit.tests.js.
14+
*/
15+
import mongoose from 'mongoose';
16+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
17+
18+
// --- Mutable config mock (so each test can flip emailVerification.mode) ---
19+
20+
const orgConfig = {
21+
enabled: false,
22+
domainMatching: false,
23+
emailVerification: { mode: 'strict' },
24+
};
25+
const configMock = {
26+
organizations: orgConfig,
27+
cookie: { secure: false, sameSite: 'strict' },
28+
jwt: { secret: 'test-secret', expiresIn: 3600 },
29+
get: jest.fn(),
30+
};
31+
jest.unstable_mockModule('../../../config/index.js', () => ({ default: configMock }));
32+
33+
// --- Mocks ---
34+
35+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
36+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
37+
}));
38+
39+
const mockIsConfigured = jest.fn();
40+
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
41+
default: { isConfigured: mockIsConfigured, sendMail: jest.fn() },
42+
}));
43+
44+
const mockOrganizationsRepositoryCreate = jest.fn();
45+
const mockOrganizationsRepositoryList = jest.fn();
46+
const mockOrganizationsRepositoryExists = jest.fn();
47+
jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({
48+
default: {
49+
create: mockOrganizationsRepositoryCreate,
50+
list: mockOrganizationsRepositoryList,
51+
exists: mockOrganizationsRepositoryExists,
52+
findOne: jest.fn(),
53+
get: jest.fn(),
54+
},
55+
}));
56+
57+
const mockMembershipRepositoryCreate = jest.fn();
58+
const mockMembershipRepositoryFindOne = jest.fn();
59+
jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({
60+
default: {
61+
create: mockMembershipRepositoryCreate,
62+
findOne: mockMembershipRepositoryFindOne,
63+
list: jest.fn(),
64+
count: jest.fn(),
65+
},
66+
}));
67+
68+
const mockUpdateById = jest.fn();
69+
jest.unstable_mockModule('../../users/services/users.service.js', () => ({
70+
default: {
71+
getBrut: jest.fn(),
72+
updateById: mockUpdateById,
73+
findByEmail: jest.fn(),
74+
searchByNameOrEmail: jest.fn(),
75+
},
76+
}));
77+
78+
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
79+
default: { defineAbilityFor: jest.fn().mockResolvedValue({ rules: [] }) },
80+
}));
81+
82+
jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
83+
default: jest.fn().mockReturnValue([]),
84+
}));
85+
86+
jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({
87+
slugify: (str) => str.toLowerCase().replace(/\s+/g, '-'),
88+
generateOrganizationSlug: jest.fn().mockResolvedValue('test-slug'),
89+
}));
90+
91+
const mockBillingGrantOnSignup = jest.fn().mockResolvedValue(undefined);
92+
jest.unstable_mockModule('../../billing/services/billing.signupGrant.service.js', () => ({
93+
default: { grantOnSignup: mockBillingGrantOnSignup },
94+
}));
95+
96+
// --- Dynamic imports after mocks ---
97+
98+
const { default: OrganizationsService } = await import('../services/organizations.service.js');
99+
100+
describe('Email-verification policy modes:', () => {
101+
const fakeUserId = new mongoose.Types.ObjectId();
102+
103+
beforeEach(() => {
104+
jest.clearAllMocks();
105+
orgConfig.enabled = false;
106+
orgConfig.domainMatching = false;
107+
orgConfig.emailVerification = { mode: 'strict' };
108+
mockMembershipRepositoryFindOne.mockResolvedValue(null);
109+
});
110+
111+
// --- handleSignupOrganization ---
112+
113+
describe('handleSignupOrganization', () => {
114+
test("strict mode (default) blocks an unverified user when mailer is configured", async () => {
115+
orgConfig.emailVerification = { mode: 'strict' };
116+
mockIsConfigured.mockReturnValue(true);
117+
118+
const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false };
119+
const result = await OrganizationsService.handleSignupOrganization(user);
120+
121+
expect(result.organization).toBeNull();
122+
expect(result.membership).toBeNull();
123+
expect(result.emailVerificationRequired).toBe(true);
124+
expect(mockOrganizationsRepositoryCreate).not.toHaveBeenCalled();
125+
});
126+
127+
test("off mode provisions an org for an unverified user even when mailer is configured", async () => {
128+
orgConfig.emailVerification = { mode: 'off' };
129+
mockIsConfigured.mockReturnValue(true);
130+
131+
const fakeOrg = { _id: new mongoose.Types.ObjectId(), name: 'Test', toJSON: () => ({ name: 'Test' }) };
132+
const fakeMembership = { _id: new mongoose.Types.ObjectId(), role: 'owner' };
133+
mockOrganizationsRepositoryCreate.mockResolvedValue(fakeOrg);
134+
mockMembershipRepositoryCreate.mockResolvedValue(fakeMembership);
135+
mockUpdateById.mockResolvedValue({});
136+
137+
const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false };
138+
const result = await OrganizationsService.handleSignupOrganization(user);
139+
140+
expect(result.emailVerificationRequired).toBeUndefined();
141+
expect(result.organization).not.toBeNull();
142+
expect(mockOrganizationsRepositoryCreate).toHaveBeenCalled();
143+
});
144+
145+
test("unknown/typo mode fails closed (treated as strict) — blocks an unverified user", async () => {
146+
orgConfig.emailVerification = { mode: 'stict' }; // typo: not the explicit permissive 'off'
147+
mockIsConfigured.mockReturnValue(true);
148+
149+
const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false };
150+
const result = await OrganizationsService.handleSignupOrganization(user);
151+
152+
expect(result.emailVerificationRequired).toBe(true);
153+
expect(result.organization).toBeNull();
154+
expect(mockOrganizationsRepositoryCreate).not.toHaveBeenCalled();
155+
});
156+
});
157+
158+
// --- search controller gate ---
159+
160+
describe('search controller gate', () => {
161+
/**
162+
* @desc Build a minimal Express-like res object with spies.
163+
* @returns {Object} mock response
164+
*/
165+
function mockRes() {
166+
const res = {};
167+
res.status = jest.fn().mockReturnValue(res);
168+
res.json = jest.fn().mockReturnValue(res);
169+
return res;
170+
}
171+
172+
test("strict mode (default) returns an empty array for an unverified user when mailer is configured", async () => {
173+
orgConfig.emailVerification = { mode: 'strict' };
174+
const { default: controller } = await import('../controllers/organizations.controller.js');
175+
176+
mockIsConfigured.mockReturnValue(true);
177+
mockOrganizationsRepositoryList.mockResolvedValue([]);
178+
179+
const req = { user: { email: 'test@acme.com', emailVerified: false } };
180+
const res = mockRes();
181+
182+
await controller.search(req, res);
183+
184+
expect(mockOrganizationsRepositoryList).not.toHaveBeenCalled();
185+
expect(res.status).toHaveBeenCalledWith(200);
186+
expect(res.json).toHaveBeenCalledWith(
187+
expect.objectContaining({ type: 'success', data: [] }),
188+
);
189+
});
190+
191+
test("off mode runs the domain search for an unverified user even when mailer is configured", async () => {
192+
orgConfig.emailVerification = { mode: 'off' };
193+
const { default: controller } = await import('../controllers/organizations.controller.js');
194+
195+
mockIsConfigured.mockReturnValue(true);
196+
mockOrganizationsRepositoryList.mockResolvedValue([]);
197+
198+
const req = { user: { email: 'test@acme.com', emailVerified: false } };
199+
const res = mockRes();
200+
201+
await controller.search(req, res);
202+
203+
expect(mockOrganizationsRepositoryList).toHaveBeenCalled();
204+
expect(res.status).toHaveBeenCalledWith(200);
205+
});
206+
});
207+
});

0 commit comments

Comments
 (0)