Commit 3b8035b
authored
feat(invitations): edge-case hardening — two-phase claim, CI email index, cap unify (P3) (#3821)
* feat(invitations): edge-case hardening — two-phase claim, CI email index, cap unify (P3)
Security-sensitive hardening of the platform-invitation gate (#3811), building
on the P2 module + auth.eligibility seam.
E2 two-phase claim/finalize (replaces naive consume-after-create):
- add invitation.consumingAt; evolve the eligibility seam from { invite, consume }
to { invite, finalize, release }.
- closed-signup path atomically CLAIMS the invite (findOneAndUpdate token+pending+
consumingAt:null) BEFORE user create — a replay/concurrent-accept loses the race
and throws 422. Open signup resolves WITHOUT claiming (preserves the !sign.up
gating: a presented token is never burned/locked).
- finalize(userId) on full success (status:accepted+acceptedAt+acceptedUserId+
usedAt, checks the FOAU return); release() ($unset consumingAt) on any pre-
response failure (verification setup / org provisioning throw) or cap-exhausted
gate. finalize is acceptedAt-guarded (idempotent) so it also burns the unclaimed
OAuth-by-email accept.
- bypass guard: findValid AND findValidByEmail exclude consumingAt-set rows — a
claimed-but-unfinalized invite is invisible to both, so the OAuth email-resolved
path cannot re-accept it.
- LAZY stale-claim sweep (no cron — stack has no scheduler): release consumingAt
older than 15min w/ no acceptedAt, at boot + lazily before each validity read.
Crash between claim and finalize recovers.
E3 case-insensitive unique email index (users migration):
- users.email: drop inline unique, add lowercase, declare an explicit
{ email:1 } unique collation index named email_ci_unique (distinct from the
legacy email_1 so autoIndex/migration don't collide on name). Normalize email
in every users-repo exact-match query (findByEmail/get/linkProviderByEmail/remove).
- migration: abort+remediate on case-variant dup emails (no auto-merge); create
the collation index FIRST then drop email_1 (never a window without a unique
index); idempotent up(), down() no-op-warn. Schema declares the SAME index so
syncIndexes stays idempotent vs the autoIndex race.
- errors.getUniqueMessage now derives the field from err.keyPattern (index-name
agnostic) so the "Email already exists." message survives the index rename.
E4 cap unify: route signup + checkOAuthUserProfile through computeSignupCapacity
(blank cap '' => uncapped, not Number('')→0→everyone-blocked); drop the hand-rolled
inline parse. TOCTOU overshoot documented.
E5 pin-in-checker: assertInvited keeps the token-present+no-email and mismatch
rejects; dedicated tests.
E6 no auto-verify on invite: mailer-off branch no longer sets emailVerified for
invite-created accounts (token proves inviter, not signer).
E8 soft-delete revoke: add revokedAt + status (pending|accepted|revoked); revoke()
sets status:revoked instead of deleteOne; findValid excludes non-pending —
invitedBy/acceptedUserId preserved for the referral phase.
E9 already-registered guard: invitations.create rejects 422 when UserService
.findByEmail(email) exists.
Token-in-logs redaction: morgan :url token override via lib/helpers/redactUrl.js
scrubs ?inviteToken from logged URLs (dev+prod patterns).
Tests: full suite green (172 suites / 2289 tests), lint clean. OAuth E7 behavior
unchanged. No org/Vue/referral changes (other phases).
* fix(invitations): close leaked-claim window on create-throw + concurrency test (P3 review)
Code-review (Approved-with-minors) follow-up to be74956:
- Important: release the claimed invite when UserService.create itself throws
(most realistically an E11000 from the case-sensitive unique-email index when
two case-variant signups race the same invited email). Previously a create
throw left the invite consumingAt-stamped and locked until the 15-min stale
sweep — the cap / verify-failure / org-failure paths released, create did not.
Wraps create in try/catch mirroring the three existing release sites + the
same `invite && !config.sign.up` gating. Updates the finalize comment so it
is accurate (every earlier failure path now releases).
- Minor: add a real concurrency test — two parallel InvitationRepository.claim()
on the same pending token via Promise.all assert EXACTLY ONE wins (the atomic
findOneAndUpdate CAS), the genuine double-claim atomicity proof (the existing
replay test was sequential / replay-after-completion only).
- Minor: add the create-throw release regression test (verified to fail on the
pre-fix code at the consumingAt assertion).
- Minor: comment the cap:0 arm — a cap:0 deployment's rejection rides the
`!sign.up && !invite` arm (invites bypass a zero cap per computeSignupCapacity),
not capReached.
Lint clean. Unit 1848/1848, integration 431/431.
* docs(migrations): document email-CI-index + consumingAt downstream actions (P3)
* fix(invitations): harden claim CAS + neutral E9 message + test cleanup
- claim(): add usedAt:null + expiresAt:{$gt:now} to the findOneAndUpdate
filter so the CAS step is self-contained (expired or already-used invite
cannot be claimed even in the narrow window between findValid and claim)
- invitations.service: remove org-coupling from E9 error message ("add
them to the organization directly") — module is decoupled from orgs
- afterAll test cleanup: replace leftover 'user@ci.example.com' with
the actual test address 'ci-variant@example.com'
- invitations.repository unit test: update claim assertion to verify
the new usedAt + expiresAt guards in the filter
All 1848 unit tests pass.
* fix(users): harden normalizeEmail null-guard, migration PII + hasCi tighten
- users.repository: normalizeEmail returns null (not the raw input) for
non-string values; every caller (get/remove/findByEmail/linkProviderByEmail)
now guards the null and returns a safe default — prevents a non-string
value (e.g. Mongo operator object) from slipping into an exact-match filter
- migration: replace raw d.emails in the abort error with IDs/counts only
(no PII leaked in operator-facing logs/alerts)
- migration: tighten hasCi to require the exact expected shape (name AND
key AND unique AND collation locale+strength) instead of the prior OR-logic
that would accept any unique/collated email index regardless of name
All 1848 unit tests pass.1 parent d52c3a1 commit 3b8035b
21 files changed
Lines changed: 1296 additions & 139 deletions
File tree
- lib
- helpers
- tests
- services
- modules
- auth
- controllers
- services
- invitations
- lib
- models
- repositories
- services
- tests
- users
- migrations
- models
- repositories
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
7 | 25 | | |
8 | 26 | | |
9 | 27 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
9 | 19 | | |
10 | 20 | | |
11 | 21 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
22 | 23 | | |
23 | 24 | | |
24 | 25 | | |
| |||
238 | 239 | | |
239 | 240 | | |
240 | 241 | | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
241 | 246 | | |
242 | 247 | | |
243 | 248 | | |
| |||
0 commit comments