fix(scim): dedupe users by userName (lowercased) and return scimType=uniqueness on 409#4067
fix(scim): dedupe users by userName (lowercased) and return scimType=uniqueness on 409#4067
Conversation
Per RFC 7643 §4.1.1, the SCIM userName attribute carries uniqueness=server and caseExact=false; emails[].value carries uniqueness=none. Users were indexed and searched by email, which is the wrong attribute: using a uniqueness=none field as the unique key produces false 409 conflicts whenever userName and emails[].value diverge. That is spec-compliant IdP behavior, not an edge case — userName can change independently of email (rename), and IdPs may ship userName in different casing across requests (caseExact=false). Index on (raw.userName ?? email).toLowerCase() in Users.create() and lowercase the lookup in Users.search(). The lowercase normalization is required by caseExact=false on userName. Existing records keyed under the legacy raw casing remain in the store and are not migrated by this change; subsequent re-provisions produce one transitional record under the new lowercased index, after which the record is stable.
…ess on 409 Two SCIM-spec deviations in DirectoryUsers.create() / respondWithError: 1) DirectoryUsers.create() searches the users store by userAttributes.email (= emails[0].value || userName). Per RFC 7643 §4.1.1 the canonical unique attribute on User is userName (uniqueness=server); emails[].value is uniqueness=none. The fix pivots dedupe to body.userName so the server-side uniqueness check matches the spec-defined unique attribute. 2) The 409 response body did not include scimType. RFC 7644 §3.3 / §3.12 require scimType="uniqueness" on uniqueness conflicts so the IdP can recognize the conflict and switch to PATCH on the existing resource. Without it, IdPs that loop on bare 409s (Microsoft Entra in particular) retry POST indefinitely. Dedupe on body.userName (which Users.search now lowercases internally) and add scimType="uniqueness" to the 409 body. respondWithError signature is widened to accept an optional scimType passthrough.
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
…p tests - Users.update() now passes directoryIdUsername and directoryId indexes to put(), preventing stale index entries when userName changes via PUT or PATCH. Previously only create() maintained indexes. - DirectoryUsers.create() validates body.userName presence and returns 400 instead of crashing with a TypeError on undefined. - Changed ?? to || for userName fallback so empty-string userNames don't collapse into a single index bucket. - Added 6 deterministic tests for the userName-based dedup contract: scimType=uniqueness on 409, same-userName-different-email 409, different-userName-same-email 201 (LEV-956 case), case-insensitive match, PUT-rename-then-POST stale-index fix, missing-userName 400. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
DirectoryUsers.create()deduplicates POST /Users onuserAttributes.email, andUsersindexes/searches records by raw email. Per RFC 7643 §4.1.1, this is the wrong attribute:emails[].valuecarriesuniqueness=none; the canonical unique attribute on User isuserName(uniqueness=server). Using a non-unique attribute as the unique key produces false 409 conflicts. The 409 response also omitsscimType, which RFC 7644 §3.12 requires for uniqueness conflicts.Changes:
(raw.userName ?? email).toLowerCase()instead of email; lowercase the search inputbody.userNameinstead ofuserAttributes.email; returnscimType: "uniqueness"on 409What does this PR do?
Fixes two SCIM 2.0 spec deviations in
DirectoryUsers.create()andUsersthat produce false 409 conflicts and infinite IdP retry loops.Problem Details
Per RFC 7643 §4.1.1:
uniquenesscaseExactuserNameserverfalseemails[].valuenonePolis was indexing User records by
emailand deduping POSTs onuserAttributes.email. Usingemails[].value(uniqueness=none) as the unique index produces false 409 conflicts wheneveruserNameandemails[].valuediverge — which is spec-compliant IdP behavior, not an edge case:userNamecan change independently ofemail(renames, login changes).userNameiscaseExact=false; IdPs may ship the same logical userName in different casing across requests.emails[].valueis not required to track changes touserName, and the spec doesn't promise it will.Additionally, RFC 7644 §3.3 and §3.12 require
scimType="uniqueness"on 409 responses for uniqueness conflicts so the IdP can switch to PATCH on the existing resource. The current implementation omits this field. IdPs that depend on it (Microsoft Entra in particular) retry POST indefinitely on legitimate already-exists conditions.Real-World Impact
A POST /Users with a renamed
userNamebut the sameemails[0].valueis rejected as a duplicate against the prior record:Stored:
userName=alice@example.com,emails[0].value=alice@example.comRequest:
POST /UserswithuserName=alice2@example.com,emails[0].value=alice@example.comCurrent (incorrect) response: HTTP 409
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "detail": "User already exists" }The IdP can't interpret the bare 409 (no
scimType) and retries POST indefinitely. The legitimately-renamed user is permanently locked out.Expected response (after this fix): HTTP 201 Created — the lookup is now by
userName=alice2@example.com, finds no match, and creates the new resource. When a true duplicate is detected, the 409 includesscimType="uniqueness"so the IdP knows to switch to PATCH.Solution
Users.ts(create):Users.ts(search):DirectoryUsers.ts(create):respondWithErrorsignature is widened to accept an optionalscimTypepassthrough.Type of change
How should this be tested?
Test Case 1 — Rename with shared email:
Test Case 2 — Same userName, different casing:
Test Case 3 — Bona fide duplicate:
e2e/api/scim/v2.0/users.spec.ts— happy to add if maintainers preferMigration / compatibility
Existing records stay keyed under their original casing; this change does not migrate the store. The next re-provision under the same canonical
userNameproduces one transitional record under the new lowercased index, after which the record is stable. Operators wanting to clean up orphan records can do so via a one-off script — out of scope for this PR.Checklist:
Additional Notes
userNameis the canonical unique attribute on User.