Skip to content

feat(organizations): org.addMember(userId) + consent flow + source discriminator (P5a)#3823

Merged
PierreBrisorgueil merged 2 commits into
masterfrom
feat/3813-org-addmember-consent
Jun 10, 2026
Merged

feat(organizations): org.addMember(userId) + consent flow + source discriminator (P5a)#3823
PierreBrisorgueil merged 2 commits into
masterfrom
feat/3813-org-addmember-consent

Conversation

@PierreBrisorgueil

Copy link
Copy Markdown
Contributor

Phase 5a — invitations ↔ org decouple (epic #3808)

Adds the "(2) add an existing user to my org" half of the new 2-step flow, consent-first: an owner adds a user → a PENDING membership the invited user must accept. An owner-add can NEVER become ACTIVE without the invitee's consent.

Consent design

  • source: 'join_request' | 'owner_add' discriminator on membership (NO default + a pre('validate') hook rejecting a sourceless PENDING — uses the lowercase MEMBERSHIP_STATUSES.PENDING constant, not the inert 'PENDING' literal).
  • addMember(orgId, userId, role, addedBy){ status: PENDING (explicit — model defaults to active), source: 'owner_add' }; duplicate-guarded (any existing membership rejects), last-owner-safe, role-escalation gated (only owner/global-admin grants owner/admin).
  • acceptMembership(membershipId, acceptingUserId) → PENDING→ACTIVE ONLY when source === 'owner_add' AND userId === acceptingUserId (String-coerced ObjectId compare + a self-defending if (!acceptingUserId) return null guard). The owner can't accept on the invitee's behalf.
  • Owner-add is invisible to every owner-approval pathisOwnerApprovable(source) predicate (single source of truth) + joinRequestSourceFilter on all join-request listings. Two ACTIVE-mutation paths exist (approveRequest, acceptMembership); both gated; no third path (verified incl. updateRole can't back-door activate).

E16 audit + E17 ordering

Every PENDING query reviewed (organizations + auth + billing): join-request surfaces → join_request only; createJoinRequest single-pending rule source-scoped (owner_add ⊥ join_request, both directions tested); auth payload pendingRequests unchanged (still the user's join-requests); member-count stays ACTIVE-only; billing has no coupling (confirmed). E17 defensive read ($or:[{source:'join_request'},{source:{$exists:false}}]) keeps legacy sourceless PENDING visible pre-backfill.

New endpoints (for P5b)

  • POST /api/organizations/:organizationId/members (owner/admin) — add-member.
  • PUT /api/membership-requests/:membershipId/accept (auth-only, consent gate in service — not under /members CASL so a not-yet-member isn't blocked; param binding can't cross-wire requestByID).
  • GET /api/organizations/:organizationId/members/search?email=exact-email lookup (owner/admin gated, GDPR: no fuzzy enumeration).
  • GET /api/membership-requests/mine/pending — the user's own owner_add pendings.

Migration

…140000-backfill-membership-source.js (after P4's …130000): sets source:'join_request' on legacy PENDING (raw driver, idempotent, no-overwrite). MIGRATIONS.md documents the E17 ordering (P9 runs backfill on Trawl BEFORE the source-filtering code deploys).

Verification

  • Lint clean; 2345+ tests green (consent matrix: invitee accepts / owner can't / join_request can't / fresh-add-never-active / duplicate-rejected / both-direction single-pending / self-defending null-guard / exact-email authz / migration idempotency).
  • Independent spec-compliance review ✅ (confirmed NO path to a non-consented ACTIVE membership) + adversarial code-quality review (probed filter-coverage, null coercion, hook-bypass on update paths, TOCTOU vs the partial-unique index — all hold; 0 critical/important; hardening minors folded).

Notes

  • Pairs with P5b (Vue add-member UI + pending list, #4281) — cross-stack verify-qa runs on the P5b side.
  • addMember TOCTOU duplicate-check is backstopped by the (userId, organizationId) partial-unique index (worst case: clean 422, never a double-pending row).

Closes #3813. Part of #3808.

Add a consent-safe owner-add flow replacing the deleted org email-invite:
an owner/admin adds an existing user, creating a PENDING owner_add membership
the INVITED USER must accept — the owner can never approve it.

- model: new `source` enum {join_request, owner_add} with NO default + a
  pre('validate') guard that throws if a PENDING row has no source (a forgotten
  source must fail loudly, never become an owner-approvable join request);
  +optional addedBy (audit). Hook throws (no next arg) — the function(next)
  signature was invoked with no next under doc.validate() at runtime.
- constants: PENDING_SOURCES.
- service: addMember (status set EXPLICITLY to PENDING, rejects any existing
  membership for (user,org), last-owner-safe by construction) +
  acceptMembership (flips PENDING->ACTIVE only for a source:owner_add membership
  whose userId is the caller). createJoinRequest single-pending rule now
  source-scoped to join_request (owner_add and join_request are orthogonal,
  both directions). +listPendingOwnerAddsByUser, +findUserByExactEmail.
- E16 audit: approval surface (listPending, listPendingByUser, requestByID
  approve/reject gate) scoped to source:join_request with an E17
  $exists:false legacy fallback so an owner_add is invisible to the owner.
  Member-count aggregation stays ACTIVE-only. billing: no coupling (confirmed).
  auth pendingRequests shape unchanged (still the user's own join requests).
- routes: POST /organizations/:id/members (owner/admin add),
  GET /organizations/:id/members/search?email= (exact-email lookup, owner/admin,
  GDPR no fuzzy enumeration), GET /membership-requests/mine/pending (owner_add
  invitations), PUT /membership-requests/:membershipId/accept (invited user,
  auth-only, consent gate in service).
- migration 20260610140000: backfill source=join_request on existing PENDING
  rows (idempotent, raw driver). +MIGRATIONS.md (E17 ordering note for P9).
- tests: consent service/model/controller + migration (+23/+6/+9/+4 new);
  full repo suite 2345 green, lint clean.
…able predicate

Phase-5a review hardening (Approved-with-minors), 3 fixes:

- acceptMembership: add self-defending early guard (!acceptingUserId -> null)
  so the consent identity-compare never leans on the auth-only route /
  sentinel-collides on String(undefined). + unit test.
- isOwnerApprovable(source) predicate co-located with PENDING_SOURCES as the
  single source of truth for consent invariant #1; controller requestByID now
  calls it instead of an inline literal. joinRequestSourceFilter keeps the $or
  Mongo query (DB layer) with a cross-reference comment to the doc-level twin.
- findUserByExactEmail JSDoc: clarify exact-after-normalization (lowercased/
  trimmed, matches the CI-unique-email storage), not byte-exact.

No behaviour change. Full suite green (unit 1891, integration 444), lint clean.
Copilot AI review requested due to automatic review settings June 10, 2026 19:31
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@PierreBrisorgueil, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 20 minutes and 35 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5cfadadb-6984-46a1-a6c9-6f9ef4125688

📥 Commits

Reviewing files that changed from the base of the PR and between 532f535 and ce43ad8.

📒 Files selected for processing (15)
  • MIGRATIONS.md
  • modules/organizations/controllers/organizations.membership.controller.js
  • modules/organizations/controllers/organizations.membershipRequest.controller.js
  • modules/organizations/lib/constants.js
  • modules/organizations/migrations/20260610140000-backfill-membership-source.js
  • modules/organizations/models/organizations.membership.model.mongoose.js
  • modules/organizations/models/organizations.membership.schema.js
  • modules/organizations/routes/organizations.membership.routes.js
  • modules/organizations/routes/organizations.membershipRequest.routes.js
  • modules/organizations/services/organizations.membership.service.js
  • modules/organizations/tests/organizations.backfillMembershipSource.migration.integration.tests.js
  • modules/organizations/tests/organizations.membership.consent.unit.tests.js
  • modules/organizations/tests/organizations.membership.controller.unit.tests.js
  • modules/organizations/tests/organizations.membership.model.consent.integration.tests.js
  • modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3813-org-addmember-consent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Phase 5a of the invitations↔org decouple work by adding a consent-first “owner adds an existing user” flow for organization membership, using a source discriminator to prevent any non-consented membership from becoming ACTIVE via owner-approval paths.

Changes:

  • Added membership.source (join_request vs owner_add) with a model-level pre-validate guard, plus an E17 migration + temporary legacy read fallback.
  • Introduced the owner-add consent flow: addMember(...) creates a PENDING owner_add, and acceptMembership(...) allows only the invitee to activate it.
  • Added new endpoints for add-member, exact-email user lookup, invitee acceptance, and “my pending owner-add invitations”, with comprehensive unit/integration coverage.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
modules/organizations/lib/constants.js Adds PENDING_SOURCES and isOwnerApprovable() predicate for consent gating.
modules/organizations/models/organizations.membership.model.mongoose.js Adds source + addedBy fields and enforces “PENDING requires source” via pre-validate hook.
modules/organizations/services/organizations.membership.service.js Scopes join-request surfaces, adds addMember, acceptMembership, findUserByExactEmail, and owner-add pending listing.
modules/organizations/controllers/organizations.membershipRequest.controller.js Adds “my pending owner-adds” listing + accept endpoint; gates approve/reject param middleware via isOwnerApprovable.
modules/organizations/controllers/organizations.membership.controller.js Adds org-level add-member endpoint + exact-email lookup endpoint with explicit role gating.
modules/organizations/routes/organizations.membership.routes.js Adds POST /members and GET /members/search routes with validation.
modules/organizations/routes/organizations.membershipRequest.routes.js Adds GET /membership-requests/mine/pending and PUT /membership-requests/:id/accept auth-only routes.
modules/organizations/models/organizations.membership.schema.js Adds Zod schema for add-member payload validation.
modules/organizations/migrations/20260610140000-backfill-membership-source.js Backfills source:'join_request' on legacy PENDING rows (idempotent).
modules/organizations/tests/organizations.membership.consent.unit.tests.js Unit tests for consent invariants, source scoping, and exact-email lookup.
modules/organizations/tests/organizations.membershipRequest.controller.unit.tests.js Unit tests ensuring owner-add PENDING is invisible to owner approval gate, and accept/listMinePending behavior.
modules/organizations/tests/organizations.membership.controller.unit.tests.js Extends controller unit tests for add-member and exact-email lookup authorization.
modules/organizations/tests/organizations.membership.model.consent.integration.tests.js Integration tests for model pre-validate consent guard + default status behavior.
modules/organizations/tests/organizations.backfillMembershipSource.migration.integration.tests.js Integration tests validating migration behavior and idempotency via raw driver.
MIGRATIONS.md Documents the consent split, new endpoints, and E17 migration ordering requirement.

Comment on lines +45 to +50
* 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;
@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.59091% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.80%. Comparing base (532f535) to head (ce43ad8).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3823      +/-   ##
==========================================
+ Coverage   91.55%   91.80%   +0.25%     
==========================================
  Files         159      159              
  Lines        5104     5187      +83     
  Branches     1624     1652      +28     
==========================================
+ Hits         4673     4762      +89     
+ Misses        339      337       -2     
+ Partials       92       88       -4     
Flag Coverage Δ
integration 59.45% <20.45%> (-0.66%) ⬇️
unit 72.41% <92.04%> (+0.60%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 532f535...ce43ad8. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@PierreBrisorgueil PierreBrisorgueil merged commit 609771f into master Jun 10, 2026
9 checks passed
@PierreBrisorgueil PierreBrisorgueil deleted the feat/3813-org-addmember-consent branch June 10, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ org.addMember(userId) + consent (Node, P5a)

2 participants