Populate members in instance SCIM Groups API

What does this MR do and why?

The instance-level SCIM API (/api/scim/v2/application/Groups) exposed a /Groups resource whose members array was hardcoded to []. Adding a member via PATCH /Groups/:id (op: add, path: members) works and grants the linked GitLab group entitlement, but reading the group back always returned no members. IdP connectors such as SailPoint could therefore never reconcile or verify the entitlement associations they created.

This MR populates members on GET /Groups and GET /Groups/:id, resolved from the scim_group_memberships table to each user's instance-level SCIM identity.

Each member is returned per RFC 7643 §4.2:

{ "value": "<user SCIM id>", "display": "<user display name>", "type": "User" }

where value is the user's SCIM id (ScimIdentity#extern_uid), matching the id returned by the /Users endpoints and round-tripping with the PATCH members operation. value is independent of the SAML login NameID format.

Implementation notes

  • Membership resolution lives in a model class method, Authn::ScimGroupMembership.members_by_scim_group_uid, which runs a bounded number of queries regardless of the number of groups.
  • The API endpoint preloads members once per page and passes them to the entity via the scim_members option, so the serializer runs no queries and there is no N+1 (covered by a QueryRecorder test).
  • Members are not fetched when excludedAttributes=members is requested.

Screenshots or screen recordings

N/A (internal API response change; see updated docs and specs.)

How to set up and validate locally

On an instance with the instance_level_scim license and instance SAML configured:

  1. Create a SAML group link, then POST /Groups to associate a scim_group_uid.
  2. POST /Users to provision a user, and note the returned id.
  3. PATCH /Groups/:id with op: add, path: members, value: [{ "value": "<user id>" }].
  4. GET /Groups/:id and GET /Groups and confirm members now contains the user as { "value": "<user id>", "display": "<name>", "type": "User" }.
  5. Confirm GET /Groups?excludedAttributes=members omits members.

MR acceptance checklist

  • Tests added (entity, model, request specs including an N+1 QueryRecorder guard).
  • Documentation updated (internal API reference).

Closes #604758 (closed). Related to #452118 and #604760 (closed).

Request for Help: gitlab-com/request-for-help#5019 (Zendesk 730757).

Database review

This MR adds one new read query in Authn::ScimGroupMembership.members_by_scim_group_uid, used to populate members on the SCIM GET /Groups and GET /Groups/:id endpoints. It is a single index-backed JOIN (no secondary IN clause):

-- Members for a page of SCIM groups (one query, no secondary IN clause)
-- Uses index_scim_group_memberships_on_scim_group_uid and
--      index_scim_identities_on_user_id_and_group_id (group_id IS NULL)
SELECT scim_group_memberships.scim_group_uid, scim_identities.extern_uid, users.name
FROM scim_group_memberships
INNER JOIN scim_identities
  ON scim_identities.user_id = scim_group_memberships.user_id
  AND scim_identities.group_id IS NULL
INNER JOIN users ON users.id = scim_group_memberships.user_id
WHERE scim_group_memberships.scim_group_uid IN ($1, $2, ...);

Query plan (validated locally on GDK PostgreSQL 17.8, seeded with 12,000 scim_group_memberships across 300 groups + 69 instance scim_identities, then rolled back):

Hash Join  (actual time=0.024..0.040 rows=80 loops=1)  Buffers: shared hit=7
  Hash Cond: (scim_identities.user_id = users.id)
  ->  Hash Join  (actual time=0.014..0.024 rows=80 loops=1)
        Hash Cond: (scim_group_memberships.user_id = scim_identities.user_id)
        ->  Index Scan using index_scim_group_memberships_on_scim_group_uid on scim_group_memberships (rows=80)
              Index Cond: (scim_group_uid = ANY ('{...}'::uuid[]))
        ->  Hash -> Seq Scan on scim_identities (rows=69, Filter: group_id IS NULL)
  ->  Hash -> Seq Scan on users (rows=69)
 Execution Time: 0.049 ms

The selective scim_group_uid index scan drives the query; the seq scans are only on the tiny (69-row) scim_identities/users tables where a hash join is optimal. With enable_seqscan=off (production-scale shape) the plan uses index_scim_identities_on_user_id_and_group_id and users_pkey via merge/nested-loop joins (Execution Time: 0.059 ms). Full output in the database review thread.

Note: instance SCIM is self-managed only, so these tables are empty on the GitLab.com Database Lab clone (a raw run returns 0 rows); the plan above was produced against seeded data.

Scale / memory

Result size is bounded by SCIM group membership, which maps to SAML-linked group entitlements. The query uses pluck and returns lightweight tuples (no ActiveRecord object instantiation), and the number of queries is constant regardless of the number of groups on the page (preloaded once, verified by a QueryRecorder spec). The SCIM Group resource inherently embeds all of a group's members, so there is no per-member pagination; clients that manage very large groups can pass excludedAttributes=members on GET /Groups to skip the member lookup entirely.

Edited by Petar Prokić

Merge request reports

Loading
Loading