Handle scim_group_uid on SamlGroupLink create/destroy

What does this MR do and why?

Handle scim_group_uid on SamlGroupLink create/destroy to address a known limitation:

New behavior:

  • On create, inherit scim_group_uid from an existing link with the same name and provider if present.
  • On destroy, if the link is the last one for the scim_group_uid, schedule a cleanup (::Authn::CleanupScimGroupMembershipsWorker) of the SCIM group membership tracking records (Authn::ScimGroupMembership).

References

Screenshots or screen recordings

Create

Before After
pry(main)> SamlGroupLink.all
=> [#<SamlGroupLink:0x000000014bb57588
  id: 1,
  access_level: 30,
  group_id: 22,
  created_at: "2026-08-13 05:10:53.850869000 +0000",
  updated_at: "2026-08-13 05:12:27.452035000 +0000",
  saml_group_name: "Developers",
  member_role_id: nil,
  assign_duo_seats: false,
  scim_group_uid: "cad3ca36-8a8f-4736-8da0-176adce74985",
  provider: nil>,
 #<SamlGroupLink:0x000000014bb571c8
  id: 2,
  access_level: 30,
  group_id: 97,
  created_at: "2026-08-13 05:12:57.860488000 +0000",
  updated_at: "2026-08-13 05:12:57.860488000 +0000",
  saml_group_name: "Developers",
  member_role_id: nil,
  assign_duo_seats: false,
  scim_group_uid: nil, # Is not inherited from existing link
  provider: nil>]
pry(main)> SamlGroupLink.all
=> [#<SamlGroupLink:0x000000014bb34240
  id: 1,
  access_level: 30,
  group_id: 22,
  created_at: "2026-08-13 05:10:53.850869000 +0000",
  updated_at: "2026-08-13 05:12:27.452035000 +0000",
  saml_group_name: "Developers",
  member_role_id: nil,
  assign_duo_seats: false,
  scim_group_uid: "cad3ca36-8a8f-4736-8da0-176adce74985",
  provider: nil>,
 #<SamlGroupLink:0x000000014bb34100
  id: 3,
  access_level: 30,
  group_id: 97,
  created_at: "2026-08-13 05:30:18.657883000 +0000",
  updated_at: "2026-08-13 05:30:18.657883000 +0000",
  saml_group_name: "Developers",
  member_role_id: nil,
  assign_duo_seats: false,
  scim_group_uid: "cad3ca36-8a8f-4736-8da0-176adce74985", # Is inherited from existing link
  provider: nil>]

Destroy

Before After
pry(main)> Authn::ScimGroupMembership.all
=> [#<Authn::ScimGroupMembership:0x000000013d5add48
  id: 1,
  created_at: "2026-08-14 03:09:57.936393000 +0000",
  updated_at: "2026-08-14 03:09:57.936393000 +0000",
  user_id: 71,
  scim_group_uid: "cad3ca36-8a8f-4736-8da0-176adce74985">]
pry(main)> Authn::ScimGroupMembership.all
=> []

How to set up and validate locally

  1. GITLAB_SIMULATE_SAAS=0
  2. Configure instance SAML with the groups_attribute present.
  3. Configure GitLab with SCIM and copy the token
  4. In a group, create a SAML group link named Developers
  5. Run the following to Create a SCIM group
    • SCIM_TOKEN=TOKEN
      GITLAB_URL=https://gdk-dev.test:4443
      GROUP_ID=$(curl -s --request POST "$GITLAB_URL/api/scim/v2/application/Groups" \
           --data '{"displayName":"Developers","schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"]}' \
           --header "Authorization: Bearer $SCIM_TOKEN" --header "Content-Type: application/scim+json" | jq -r '.id')
  6. In a separate group, create a new SAML group link with the same name
  7. Confirm there are two entries in Resources within the response of this request
    • curl -s "$GITLAB_URL/api/scim/v2/application/Groups?filter=displayName%20eq%20%22Developers%22" \
       --header "Authorization: Bearer $SCIM_TOKEN" \
       --header "Content-Type: application/scim+json" | jq

For destroy (after completing the above):

  1. Create a SCIM provisioned user
    • curl -s --request POST "$GITLAB_URL/api/scim/v2/application/Users" \
       --header "Authorization: Bearer $SCIM_TOKEN" --header "Content-Type: application/scim+json" \
       --data '{"externalId":"test_uid","active":null,"userName":"username","emails":[{"primary":true,"type":"work","value":"name@example.com"}],"name":{"formatted":"Test User","familyName":"User","givenName":"Test"},"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"meta":{"resourceType":"User"}}'
  2. Update a SCIM group to add the user to the SCIM group
    • curl -s --request PATCH "$GITLAB_URL/api/scim/v2/application/Groups/$GROUP_ID" \
       --data '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"add","path":"members","value":[{"value":"test_uid"}]}]}' \
       --header "Authorization: Bearer $SCIM_TOKEN" --header "Content-Type: application/scim+json"
  3. Delete the two SAML group links you created
  4. Observe that the SCIM group memberships have been cleared
    • Authn::ScimGroupMembership.all

Query plans

Default planner

Limit  (cost=0.29..48.06 rows=1 width=16) (actual time=0.008..0.008 rows=1 loops=1)
  Buffers: shared hit=7
  ->  Index Scan using index_saml_group_links_on_scim_group_uid on saml_group_links  (cost=0.29..239.17 rows=5 width=16) (actual time=0.008..0.008 rows=1 loops=1)
        Index Cond: (scim_group_uid IS NOT NULL)
        Filter: (saml_group_name = 'Developers'::text)
        Rows Removed by Filter: 4
        Buffers: shared hit=7
Planning:
  Buffers: shared hit=47
Planning Time: 0.523 ms
Execution Time: 0.012 ms

enable_seqscan=off to force the index shape

Limit  (cost=0.29..48.06 rows=1 width=16) (actual time=0.004..0.004 rows=1 loops=1)
  Buffers: shared hit=7
  ->  Index Scan using index_saml_group_links_on_scim_group_uid on saml_group_links  (cost=0.29..239.17 rows=5 width=16) (actual time=0.004..0.004 rows=1 loops=1)
        Index Cond: (scim_group_uid IS NOT NULL)
        Filter: (saml_group_name = 'Developers'::text)
        Rows Removed by Filter: 4
        Buffers: shared hit=7
Planning Time: 0.014 ms
Execution Time: 0.006 ms

No-match

Default planner

Limit  (cost=0.29..238.94 rows=1 width=16) (actual time=0.115..0.116 rows=0 loops=1)
  Buffers: shared hit=493
  ->  Index Scan using index_saml_group_links_on_scim_group_uid on saml_group_links  (cost=0.29..238.94 rows=1 width=16) (actual time=0.115..0.115 rows=0 loops=1)
        Index Cond: (scim_group_uid IS NOT NULL)
        Filter: (saml_group_name = 'Nonexistent'::text)
        Rows Removed by Filter: 498
        Buffers: shared hit=493
Planning:
  Buffers: shared hit=43
Planning Time: 0.322 ms
Execution Time: 0.120 ms

enable_seqscan=off to force the index shape

Limit  (cost=0.29..238.94 rows=1 width=16) (actual time=0.064..0.064 rows=0 loops=1)
  Buffers: shared hit=493
  ->  Index Scan using index_saml_group_links_on_scim_group_uid on saml_group_links  (cost=0.29..238.94 rows=1 width=16) (actual time=0.063..0.063 rows=0 loops=1)
        Index Cond: (scim_group_uid IS NOT NULL)
        Filter: (saml_group_name = 'Nonexistent'::text)
        Rows Removed by Filter: 498
        Buffers: shared hit=493
Planning Time: 0.018 ms
Execution Time: 0.067 ms

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Related to #582729 (closed)

Edited by Jio Castillo

Merge request reports

Loading
Loading