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
21 changes: 21 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ Breaking changes and upgrade notes for downstream projects.

---

## org.addMember + membership consent split (2026-06-10)

Phase 5a of the invitations↔org decouple epic (#3813). Replaces the deleted org email-invite with a **consent-safe add-member flow**: an owner/admin adds an *existing* user, creating a **PENDING `owner_add`** membership that the **invited user** must accept — the owner can NEVER approve it (consent invariant).

### What changed (this repo)

- **Membership model** (`organizations.membership.model.mongoose.js`) — new `source` enum field `{ 'join_request', 'owner_add' }` **with NO default** + a `pre('validate')` hook that throws if a PENDING row has no `source` (a forgotten source must fail loudly, never silently become an owner-approvable join request). New optional `addedBy` (ObjectId, audit-only).
- **Constants** — new `PENDING_SOURCES = { JOIN_REQUEST:'join_request', OWNER_ADD:'owner_add' }`.
- **Service** (`organizations.membership.service.js`) — `addMember(orgId, userId, role, addedBy)` creates a PENDING owner_add (status set EXPLICITLY — the schema defaults status to `'active'`); rejects if ANY membership already exists for (user, org); last-owner-safe. `acceptMembership(id, userId)` flips PENDING→ACTIVE **only** for a `source:'owner_add'` membership whose `userId` is the caller (sets `currentOrganization` if unset). `createJoinRequest`'s single-pending-global rule is now **source-scoped to join_request** (a pending owner_add no longer blocks a join request, and vice-versa). New `listPendingOwnerAddsByUser`.
- **Approval surface scoped to join_request** — `listPending`, `listPendingByUser`, and the `requestByID` approve/reject gate now match `source:'join_request'` (with an E17 `source $exists:false` legacy fallback) so an owner_add is **invisible** to the owner-approval surface. The auth-payload `pendingRequests` is unchanged in shape — still the user's own join requests.
- **Routes** — `POST /api/organizations/:organizationId/members` (owner/admin; CASL `create Membership`) adds a member; `GET /api/organizations/:organizationId/members/search?email=` (owner/admin) looks up a user by **exact email** (GDPR: no fuzzy directory enumeration); `GET /api/membership-requests/mine/pending` lists the user's pending owner_add invitations; `PUT /api/membership-requests/:membershipId/accept` lets the **invited user** accept (auth-only; consent gate in the service, no org-CASL).
- **Migration** `modules/organizations/migrations/20260610140000-backfill-membership-source.js`: sets `source:'join_request'` on all existing PENDING memberships (they were all join requests pre-change). Idempotent (filter requires `source` absent). Raw collection driver (house style).

### Action required for downstream projects (`/update-project`)

1. Module/model/migration changes are devkit-owned → arrive via `/update-stack` (`--theirs`).
2. **Migration ORDERING (E17 — critical):** the backfill `20260610140000` MUST run BEFORE the source-filtering code deploys, so no pre-existing join request is hidden from the approval list. The migration runs at boot before `listen()`; on Trawl this is sequenced in epic Phase 9 (#3815). The service/controller carry a temporary `source $exists:false` fallback so legacy rows stay visible even if the code lands first; that fallback is removed in a follow-up once every environment's backfill is confirmed.
3. No platform-invitation (`sign.cap` / `?inviteToken=`) behavior changes. Vue add-member UI + pending-invitation list land in Vue #4281.

---

## Remove organization email-invite feature (2026-06-10)

Phase 4 of the invitations↔org decouple epic (#3812). The organization's **own** email-invite flow is **deleted** — distinct from the platform `invitations` module (single-use signup-token gate), which is unchanged. This is the owner-invites-an-email-to-join-their-org flow that lived on the membership doc as `status:'invited'` + `inviteToken` / `invitedEmail` / `inviteExpiresAt`. The 2-step "invite to platform, then add the resulting user as a member" flow replaces it (`org.addMember` lands in a later phase / #3813).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,77 @@ const list = async (req, res) => {
}
};

/**
* @function findUserByEmail
* @description Endpoint for an org owner/admin to look up a single user by EXACT
* email, to add them as a member (P5b affordance). GDPR/privacy: deliberately
* exact-match only (no fuzzy name enumeration of the user directory) and gated by
* the same owner/admin CASL `create Membership` ability the add-member route uses.
* Returns minimal identity (id, displayName, email) for a match, or null.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {void}
*/
const findUserByEmail = async (req, res) => {
try {
// CASL on the GET /members surface grants `read Membership` to plain members too,
// which is too broad for a user-directory lookup. Restrict to owner/admin (or
// global admin) explicitly — only roles that can actually add a member.
const isPlatformAdmin = isGlobalAdmin(req.user);
const actorRole = req.membership?.role;
const canEnumerate = isPlatformAdmin
|| actorRole === MEMBERSHIP_ROLES.OWNER
|| actorRole === MEMBERSHIP_ROLES.ADMIN;
if (!canEnumerate) {
return responses.error(res, 403, 'Forbidden', 'Only owners or admins can look up users')();
}

const email = req.query.email;
if (!email || typeof email !== 'string') {
return responses.error(res, 422, 'Unprocessable Entity', 'An email query parameter is required')();
}
const match = await MembershipService.findUserByExactEmail(email);
responses.success(res, 'user lookup')(match);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};

/**
* @function addMember
* @description Endpoint for an org owner/admin to add a user as a member. Creates a
* PENDING owner_add membership the INVITED USER must accept (consent — invariant #1).
* Role gate mirrors updateRole: only OWNERS may grant owner/admin; admins may only
* add plain members. CASL (`create Membership`) on the /members POST route already
* restricts this to org owners/admins (+ global admins).
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {void}
*/
const addMember = async (req, res) => {
try {
const { userId, role } = req.body;
const requestedRole = role || MEMBERSHIP_ROLES.MEMBER;

// Elevated-role guard: only owners (or global admins) may invite an owner/admin.
const isPlatformAdmin = isGlobalAdmin(req.user);
const actorIsOwner = req.membership?.role === MEMBERSHIP_ROLES.OWNER;
if (requestedRole !== MEMBERSHIP_ROLES.MEMBER && !isPlatformAdmin && !actorIsOwner) {
return responses.error(res, 403, 'Forbidden', 'Only owners can add a member with an elevated role')();
}

const membership = await MembershipService.addMember(
req.organization._id || req.organization.id,
userId,
requestedRole,
req.user._id || req.user.id,
);
responses.success(res, 'membership invitation created')(membership);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};

/**
* @function updateRole
* @description Endpoint to change the role of a member in an organization.
Expand Down Expand Up @@ -110,6 +181,8 @@ const memberByID = async (req, res, next, id) => {

export default {
list,
findUserByEmail,
addMember,
updateRole,
remove,
memberByID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import errors from '../../../lib/helpers/errors.js';
import responses from '../../../lib/helpers/responses.js';
import MembershipService from '../services/organizations.membership.service.js';
import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../lib/constants.js';
import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES, isOwnerApprovable } from '../lib/constants.js';

/**
* @function create
Expand Down Expand Up @@ -81,7 +81,9 @@ const reject = async (req, res) => {

/**
* @function listMine
* @description Endpoint to list the authenticated user's own pending requests.
* @description Endpoint to list the authenticated user's own pending JOIN REQUESTS
* (requests they initiated, awaiting an owner's approval). Owner_add invitations
* to accept are a separate surface — see listMinePending.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>}
Expand All @@ -95,6 +97,52 @@ const listMine = async (req, res) => {
}
};

/**
* @function listMinePending
* @description Endpoint to list the authenticated user's pending OWNER_ADD
* invitations — memberships an owner/admin created for them that they must ACCEPT
* to activate. Feeds the Vue "pending invitations to accept" list (P5b).
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>}
*/
const listMinePending = async (req, res) => {
try {
const invitations = await MembershipService.listPendingOwnerAddsByUser(req.user._id || req.user.id);
responses.success(res, 'membership invitation list')(invitations);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};

/**
* @function accept
* @description Endpoint for the INVITED USER to accept a pending owner_add membership.
* Authentication is required (passport); the consent gate lives in the service —
* acceptMembership activates ONLY when the membership is a PENDING owner_add AND
* belongs to the authenticated caller. A mismatch (wrong user, a join_request, an
* already-active or unknown membership) returns null → 404, never leaking which.
* No org membership / CASL check: the invitee is by definition NOT yet a member,
* so this route is intentionally outside the /members + /requests CASL surfaces.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>}
*/
const accept = async (req, res) => {
try {
const membership = await MembershipService.acceptMembership(
req.params.membershipId,
req.user._id || req.user.id,
);
if (!membership) {
return responses.error(res, 404, 'Not Found', 'No pending invitation with that identifier has been found')();
}
responses.success(res, 'membership invitation accepted')(membership);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};

/**
* @function requestByID
* @description Middleware to fetch a pending membership by its ID.
Expand All @@ -109,7 +157,12 @@ const requestByID = async (req, res, next, id) => {
const membership = await MembershipService.get(id);
const organizationId = String(req.organization._id || req.organization.id);
const membershipOrgId = String(membership?.organizationId?._id || membership?.organizationId);
if (!membership || membership.status !== MEMBERSHIP_STATUSES.PENDING || membershipOrgId !== organizationId) {
// CONSENT GATE (invariant #1): the owner-approval surface (approve/reject) must
// ONLY ever see JOIN REQUESTS. An owner_add awaits the INVITED USER's consent —
// it must be invisible here, else an owner could approve it and bypass consent.
// `isOwnerApprovable` is the shared source of truth (covers join_request + the
// E17 legacy no-source case); see its doc-level twin note on joinRequestSourceFilter.
if (!membership || membership.status !== MEMBERSHIP_STATUSES.PENDING || !isOwnerApprovable(membership.source) || membershipOrgId !== organizationId) {
return responses.error(res, 404, 'Not Found', 'No pending request with that identifier has been found')();
}
req.membershipRequest = membership;
Expand All @@ -125,5 +178,7 @@ export default {
approve,
reject,
listMine,
listMinePending,
accept,
requestByID,
};
40 changes: 40 additions & 0 deletions modules/organizations/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,43 @@ export const MEMBERSHIP_ROLES = {
ADMIN: 'admin',
MEMBER: 'member',
};

/**
* Source discriminator for PENDING memberships — the consent split.
*
* A PENDING membership can exist for two distinct reasons, and the two MUST stay
* separable so the owner-approval surface never sees (and so never auto-approves)
* a membership the invited user has not consented to:
* - JOIN_REQUEST: the user asked to join (owner approves → ACTIVE). The owner is
* the one who consents; this is the legacy/default join flow.
* - OWNER_ADD: an owner/admin added a user (the INVITED USER accepts → ACTIVE).
* The owner must NEVER be able to approve this — only the invited user can,
* via acceptMembership. An owner_add appearing in the approval list would be a
* consent bypass.
*
* Stored on `membership.source`. There is intentionally NO schema default — a
* forgotten `source` on a PENDING row must fail loudly (pre('validate') guard)
* rather than silently default to a join_request the owner could approve.
*/
export const PENDING_SOURCES = {
JOIN_REQUEST: 'join_request',
OWNER_ADD: 'owner_add',
};

/**
* @function isOwnerApprovable
* @description Single source of truth for the consent invariant #1: a PENDING
* membership is owner-approvable (appears in the owner's join-request / approve
* surface) ONLY when it is NOT an owner_add. An owner_add requires the INVITED
* user's consent via acceptMembership, never the owner's approval — letting one
* into the approval surface would be a consent bypass.
*
* The doc-level twin of the DB-layer `joinRequestSourceFilter` `$or` query in
* `organizations.membership.service.js`; the two MUST stay in lock-step. A Mongo
* filter can't be a predicate, so the query stays separate — keep them aligned.
* E17: a legacy PENDING with no `source` (pre-backfill, all join_requests) is
* owner-approvable — `!== OWNER_ADD` covers both join_request and absent.
* @param {String} [source] - The membership's PENDING source discriminator.
* @returns {Boolean} True if an owner/admin may approve this PENDING membership.
*/
export const isOwnerApprovable = (source) => source !== PENDING_SOURCES.OWNER_ADD;
Comment on lines +45 to +50
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Module dependencies
*/
import mongoose from 'mongoose';

import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js';

/**
* Migration: backfill `source` on existing PENDING memberships.
*
* The consent split (P5a) adds a `source` discriminator to PENDING memberships:
* - 'join_request' — the user asked to join (owner approves → ACTIVE);
* - 'owner_add' — an owner added the user (the invited user accepts → ACTIVE).
*
* Every PENDING membership that existed BEFORE this change was a join request (the
* owner_add flow did not exist), so they are all backfilled to 'join_request'.
*
* Why this matters (E17 migration ordering): the new code reads the owner-approval
* surface as `source: 'join_request' OR source absent` so legacy rows stay visible
* until this backfill runs. After this migration, every PENDING row carries an
* explicit source and the `$exists:false` fallback in the service/controller can be
* removed (follow-up). P9 MUST run this migration on Trawl BEFORE deploying any code
* that filters owner_adds out of the approval surface, so no join request is ever
* hidden.
*
* `source` IS declared on the schema now, so a model `updateMany` `$set` would NOT
* be stripped by strict mode. We nonetheless use the RAW collection driver to match
* the house migration style and to be robust regardless of model-registration state
* at migration time. ACTIVE memberships are intentionally left untouched (source is
* only meaningful while PENDING).
*
* Idempotent: the filter requires `source` ABSENT, so a second run matches nothing.
* @returns {Promise<void>} Resolves when the backfill has completed.
*/
export async function up() {
// Model `Membership` → default collection `memberships`.
const db = mongoose.connection.db;
const memberships = db.collection('memberships');

const result = await memberships.updateMany(
{ status: MEMBERSHIP_STATUSES.PENDING, source: { $exists: false } },
{ $set: { source: PENDING_SOURCES.JOIN_REQUEST } },
);

const modified = result?.modifiedCount ?? 0;
console.info(`[migration] backfill-membership-source: set source='join_request' on ${modified} pending membership(s)`);
}

/**
* Down migration: unset `source` on the rows this backfill set (PENDING
* join_requests). ACTIVE rows and owner_adds are untouched. Best-effort reversal.
* @returns {Promise<void>} Resolves when the unset has completed.
*/
export async function down() {
const db = mongoose.connection.db;
const memberships = db.collection('memberships');
await memberships.updateMany(
{ status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.JOIN_REQUEST },
{ $unset: { source: '' } },
);
console.warn('[migration] backfill-membership-source DOWN: unset source on pending join_requests');
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Module dependencies
*/
import mongoose from 'mongoose';
import { MEMBERSHIP_STATUSES, PENDING_SOURCES } from '../lib/constants.js';

const Schema = mongoose.Schema;

Expand Down Expand Up @@ -30,12 +31,43 @@ const MembershipMongoose = new Schema(
enum: ['active', 'pending'],
default: 'active',
},
// Consent discriminator for PENDING memberships. Intentionally NO default:
// a forgotten `source` on a PENDING row must FAIL (pre-validate guard below),
// never silently default to 'join_request' (which an owner could approve →
// consent bypass). See PENDING_SOURCES in lib/constants.js.
source: {
type: String,
enum: Object.values(PENDING_SOURCES),
},
// Provenance: the owner/admin who created an owner_add invitation (audit only).
addedBy: {
type: Schema.ObjectId,
ref: 'User',
default: null,
},
},
{
timestamps: true,
},
);

/**
* Consent guard: a PENDING membership MUST declare an explicit source.
* Without this, an owner_add that forgot to set `source` would be
* indistinguishable from a join_request and could be approved by the owner,
* bypassing the invited user's consent. Use the constant (status is stored
* lowercase as MEMBERSHIP_STATUSES.PENDING — a `=== 'PENDING'` check is inert).
*
* Implemented as a no-arg sync hook that throws: mongoose rejects the validate()
* promise on a thrown pre-hook error. (A `function(next)` signature is brittle here
* — mongoose's arity detection can invoke it with no `next` under doc.validate().)
*/
MembershipMongoose.pre('validate', function enforcePendingSource() {
if (this.status === MEMBERSHIP_STATUSES.PENDING && !this.source) {
throw new Error('PENDING membership requires explicit source');
}
});

/**
* Compound unique index to prevent duplicate memberships
*/
Expand Down
11 changes: 11 additions & 0 deletions modules/organizations/models/organizations.membership.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ const MembershipUpdate = z.object({
role: z.enum(['owner', 'admin', 'member']),
});

/**
* Owner/admin add-member payload. `userId` is the target user's id (required —
* the user must already exist; resolve from an email via the /members/search
* lookup first). `role` is the role to grant on acceptance (defaults to member).
*/
const MembershipAdd = z.object({
userId: z.string().min(1),
role: z.enum(['owner', 'admin', 'member']).optional(),
});

export default {
MembershipUpdate,
MembershipAdd,
};
Loading
Loading