From ad7de96b37cab6997d87812db95f13afdf1c09f9 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 28 Jun 2026 13:23:31 +0200 Subject: [PATCH 1/2] 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. --- .../organizations.development.config.js | 8 + .../controllers/organizations.controller.js | 7 +- .../services/organizations.service.js | 11 +- ...ons.emailVerification.policy.unit.tests.js | 195 ++++++++++++++++++ 4 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js diff --git a/modules/organizations/config/organizations.development.config.js b/modules/organizations/config/organizations.development.config.js index 7a1f2807f..636cfecd2 100644 --- a/modules/organizations/config/organizations.development.config.js +++ b/modules/organizations/config/organizations.development.config.js @@ -9,6 +9,14 @@ const config = { enabled: true, // false → B2C mode, organizations invisible autoCreate: true, // automatically create/join orgs at signup domainMatching: true, // match users to existing orgs by email domain + // Email-verification policy for org provisioning/discovery. + // 'strict' (default) → when the mailer is configured, an unverified user + // cannot provision an org at signup nor run domain search; they must + // verify their email first. + // 'off' → email verification is never required for these flows; the user + // is always auto-provisioned (same path as a mailer-not-configured env). + // emailVerified stays server-only; this policy only gates the existing checks. + emailVerification: { mode: 'strict' }, roles: ['owner', 'admin', 'member'], roleDescriptions: { owner: 'Full control — manage organization settings, members, roles, and billing.', diff --git a/modules/organizations/controllers/organizations.controller.js b/modules/organizations/controllers/organizations.controller.js index 2864bec3a..5422cef25 100644 --- a/modules/organizations/controllers/organizations.controller.js +++ b/modules/organizations/controllers/organizations.controller.js @@ -184,8 +184,11 @@ const organizationByPage = async (req, res, next, params) => { */ const search = async (req, res) => { try { - // Block domain search for unverified users when mailer is configured - if (mailer.isConfigured() && !req.user.emailVerified) { + // Email-verification policy gate (config.organizations.emailVerification.mode). + // 'strict' (default) → block domain search for unverified users when the mailer is + // configured. 'off' → never block on verification (same path as mailer-not-configured). + const emailVerificationStrict = (config.organizations?.emailVerification?.mode ?? 'strict') === 'strict'; + if (emailVerificationStrict && mailer.isConfigured() && !req.user.emailVerified) { return responses.success(res, 'organization search')([]); } const organizations = await OrganizationsService.searchByDomain(req.user.email); diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 9384396e5..79571bd69 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -136,8 +136,15 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat const handleSignupOrganization = async (user) => { const orgConfig = config.organizations || {}; - // When mailer is configured, require email verification before any org provisioning - if (mailer.isConfigured() && !user.emailVerified) { + // Email-verification policy gate (config.organizations.emailVerification.mode). + // 'strict' (default) → require a verified email before provisioning when the mailer + // is configured. 'off' → never require verification here; always auto-provision + // (same effective path as a mailer-not-configured env). See module base config. + const emailVerificationStrict = (orgConfig.emailVerification?.mode ?? 'strict') === 'strict'; + + // When the policy is strict and the mailer is configured, require email verification + // before any org provisioning. + if (emailVerificationStrict && mailer.isConfigured() && !user.emailVerified) { return { organization: null, membership: null, diff --git a/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js new file mode 100644 index 000000000..c586f2f83 --- /dev/null +++ b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js @@ -0,0 +1,195 @@ +/** + * Unit tests — config-gated email-verification policy + * (config.organizations.emailVerification.mode). + * + * Covers BOTH modes on the two gated surfaces (signup org provisioning + domain + * search), with the mailer reported as configured and the user UNVERIFIED — the + * only case the mode actually changes: + * - 'strict' (default) → blocks the unverified user (no org, empty search). + * - 'off' → always provisions / always searches (mailer-on path + * behaves like a mailer-not-configured env). + * + * The default-strict + mailer-on + verified path stays byte-identical to today + * and is exercised by organizations.emailVerification.unit.tests.js. + */ +import mongoose from 'mongoose'; +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +// --- Mutable config mock (so each test can flip emailVerification.mode) --- + +const orgConfig = { + enabled: false, + domainMatching: false, + emailVerification: { mode: 'strict' }, +}; +const configMock = { + organizations: orgConfig, + cookie: { secure: false, sameSite: 'strict' }, + jwt: { secret: 'test-secret', expiresIn: 3600 }, + get: jest.fn(), +}; +jest.unstable_mockModule('../../../config/index.js', () => ({ default: configMock })); + +// --- Mocks --- + +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockIsConfigured = jest.fn(); +jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: mockIsConfigured, sendMail: jest.fn() }, +})); + +const mockOrganizationsRepositoryCreate = jest.fn(); +const mockOrganizationsRepositoryList = jest.fn(); +const mockOrganizationsRepositoryExists = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ + default: { + create: mockOrganizationsRepositoryCreate, + list: mockOrganizationsRepositoryList, + exists: mockOrganizationsRepositoryExists, + findOne: jest.fn(), + get: jest.fn(), + }, +})); + +const mockMembershipRepositoryCreate = jest.fn(); +const mockMembershipRepositoryFindOne = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ + default: { + create: mockMembershipRepositoryCreate, + findOne: mockMembershipRepositoryFindOne, + list: jest.fn(), + count: jest.fn(), + }, +})); + +const mockUpdateById = jest.fn(); +jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: { + getBrut: jest.fn(), + updateById: mockUpdateById, + findByEmail: jest.fn(), + searchByNameOrEmail: jest.fn(), + }, +})); + +jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({ rules: [] }) }, +})); + +jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), +})); + +jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({ + slugify: (str) => str.toLowerCase().replace(/\s+/g, '-'), + generateOrganizationSlug: jest.fn().mockResolvedValue('test-slug'), +})); + +const mockBillingGrantOnSignup = jest.fn().mockResolvedValue(undefined); +jest.unstable_mockModule('../../billing/services/billing.signupGrant.service.js', () => ({ + default: { grantOnSignup: mockBillingGrantOnSignup }, +})); + +// --- Dynamic imports after mocks --- + +const { default: OrganizationsService } = await import('../services/organizations.service.js'); + +describe('Email-verification policy modes:', () => { + const fakeUserId = new mongoose.Types.ObjectId(); + + beforeEach(() => { + jest.clearAllMocks(); + orgConfig.enabled = false; + orgConfig.domainMatching = false; + orgConfig.emailVerification = { mode: 'strict' }; + mockMembershipRepositoryFindOne.mockResolvedValue(null); + }); + + // --- handleSignupOrganization --- + + describe('handleSignupOrganization', () => { + test("strict mode (default) blocks an unverified user when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'strict' }; + mockIsConfigured.mockReturnValue(true); + + const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false }; + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).toBeNull(); + expect(result.membership).toBeNull(); + expect(result.emailVerificationRequired).toBe(true); + expect(mockOrganizationsRepositoryCreate).not.toHaveBeenCalled(); + }); + + test("off mode provisions an org for an unverified user even when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'off' }; + mockIsConfigured.mockReturnValue(true); + + const fakeOrg = { _id: new mongoose.Types.ObjectId(), name: 'Test', toJSON: () => ({ name: 'Test' }) }; + const fakeMembership = { _id: new mongoose.Types.ObjectId(), role: 'owner' }; + mockOrganizationsRepositoryCreate.mockResolvedValue(fakeOrg); + mockMembershipRepositoryCreate.mockResolvedValue(fakeMembership); + mockUpdateById.mockResolvedValue({}); + + const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false }; + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.emailVerificationRequired).toBeUndefined(); + expect(result.organization).not.toBeNull(); + expect(mockOrganizationsRepositoryCreate).toHaveBeenCalled(); + }); + }); + + // --- search controller gate --- + + describe('search controller gate', () => { + /** + * @desc Build a minimal Express-like res object with spies. + * @returns {Object} mock response + */ + function mockRes() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; + } + + test("strict mode (default) returns an empty array for an unverified user when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'strict' }; + const { default: controller } = await import('../controllers/organizations.controller.js'); + + mockIsConfigured.mockReturnValue(true); + mockOrganizationsRepositoryList.mockResolvedValue([]); + + const req = { user: { email: 'test@acme.com', emailVerified: false } }; + const res = mockRes(); + + await controller.search(req, res); + + expect(mockOrganizationsRepositoryList).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success', data: [] }), + ); + }); + + test("off mode runs the domain search for an unverified user even when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'off' }; + const { default: controller } = await import('../controllers/organizations.controller.js'); + + mockIsConfigured.mockReturnValue(true); + mockOrganizationsRepositoryList.mockResolvedValue([]); + + const req = { user: { email: 'test@acme.com', emailVerified: false } }; + const res = mockRes(); + + await controller.search(req, res); + + expect(mockOrganizationsRepositoryList).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + }); +}); From a7714c23cc91b29b11c91a263be300bafb5005ec Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 28 Jun 2026 14:22:30 +0200 Subject: [PATCH 2/2] 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 --- .../controllers/organizations.controller.js | 10 ++++++---- .../services/organizations.service.js | 16 +++++++++------- ...ations.emailVerification.policy.unit.tests.js | 12 ++++++++++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/modules/organizations/controllers/organizations.controller.js b/modules/organizations/controllers/organizations.controller.js index 5422cef25..3e6b70158 100644 --- a/modules/organizations/controllers/organizations.controller.js +++ b/modules/organizations/controllers/organizations.controller.js @@ -185,10 +185,12 @@ const organizationByPage = async (req, res, next, params) => { const search = async (req, res) => { try { // Email-verification policy gate (config.organizations.emailVerification.mode). - // 'strict' (default) → block domain search for unverified users when the mailer is - // configured. 'off' → never block on verification (same path as mailer-not-configured). - const emailVerificationStrict = (config.organizations?.emailVerification?.mode ?? 'strict') === 'strict'; - if (emailVerificationStrict && mailer.isConfigured() && !req.user.emailVerified) { + // FAIL CLOSED: only the explicit 'off' value lifts the gate; the default, a typo, + // or wrong casing all keep the strict block, so a misconfiguration can never leak + // the domain search to unverified users. 'off' → never block (same path as + // mailer-not-configured). + const emailVerificationOff = (config.organizations?.emailVerification?.mode ?? 'strict') === 'off'; + if (!emailVerificationOff && mailer.isConfigured() && !req.user.emailVerified) { return responses.success(res, 'organization search')([]); } const organizations = await OrganizationsService.searchByDomain(req.user.email); diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 79571bd69..a1d3a5790 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -137,14 +137,16 @@ const handleSignupOrganization = async (user) => { const orgConfig = config.organizations || {}; // Email-verification policy gate (config.organizations.emailVerification.mode). - // 'strict' (default) → require a verified email before provisioning when the mailer - // is configured. 'off' → never require verification here; always auto-provision - // (same effective path as a mailer-not-configured env). See module base config. - const emailVerificationStrict = (orgConfig.emailVerification?.mode ?? 'strict') === 'strict'; + // FAIL CLOSED: verification is bypassed ONLY for the explicit, permissive value + // 'off'. Any other value — the default, a typo ('stict'), or wrong casing + // ('STRICT') — keeps the strict gate, so a misconfiguration can never silently + // auto-provision unverified users. 'off' → always auto-provision (same effective + // path as a mailer-not-configured env). See module base config. + const emailVerificationOff = (orgConfig.emailVerification?.mode ?? 'strict') === 'off'; - // When the policy is strict and the mailer is configured, require email verification - // before any org provisioning. - if (emailVerificationStrict && mailer.isConfigured() && !user.emailVerified) { + // When the policy is NOT 'off' and the mailer is configured, require email + // verification before any org provisioning. + if (!emailVerificationOff && mailer.isConfigured() && !user.emailVerified) { return { organization: null, membership: null, diff --git a/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js index c586f2f83..a86200afd 100644 --- a/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js +++ b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js @@ -141,6 +141,18 @@ describe('Email-verification policy modes:', () => { expect(result.organization).not.toBeNull(); expect(mockOrganizationsRepositoryCreate).toHaveBeenCalled(); }); + + test("unknown/typo mode fails closed (treated as strict) — blocks an unverified user", async () => { + orgConfig.emailVerification = { mode: 'stict' }; // typo: not the explicit permissive 'off' + mockIsConfigured.mockReturnValue(true); + + const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false }; + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.emailVerificationRequired).toBe(true); + expect(result.organization).toBeNull(); + expect(mockOrganizationsRepositoryCreate).not.toHaveBeenCalled(); + }); }); // --- search controller gate ---