Repository: python-gitlab/python-gitlab

Issue:
Add support for API Endpoint for SAML Group Sync
## Description of the problem, including code/CLI snippet
GitLab added the API Endpoint for SAML Group Sync in 15.3. Please add support for this new API endpoint.

## Expected Behavior
Supports SAML Group Sync

## Actual Behavior
Not yet supported

## Specifications

  - python-gitlab version:
  - API version you are using (v3/v4):
  - Gitlab server version (or gitlab.com):



Discussion:
please add support for this. 
@Jerez007 A link to the API endpoint would be useful for anyone who wants to work on this.
[GET /groups/:id/saml_group_links](https://docs.gitlab.com/ee/api/groups.html#saml-group-links)
Sorry for the late reply. Thanks to @gokeefe for posting the endpoint! 

https://docs.gitlab.com/ee/api/groups.html#add-saml-group-link

Resolving patch:
diff --git a/gitlab/v4/objects/groups.py b/gitlab/v4/objects/groups.py
--- a/gitlab/v4/objects/groups.py
+++ b/gitlab/v4/objects/groups.py
@@ -12,6 +12,7 @@
     CRUDMixin,
     DeleteMixin,
     ListMixin,
+    NoUpdateMixin,
     ObjectDeleteMixin,
     SaveMixin,
 )
@@ -58,6 +59,8 @@
     "GroupLDAPGroupLinkManager",
     "GroupSubgroup",
     "GroupSubgroupManager",
+    "GroupSAMLGroupLink",
+    "GroupSAMLGroupLinkManager",
 ]
 
 
@@ -98,6 +101,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
     subgroups: "GroupSubgroupManager"
     variables: GroupVariableManager
     wikis: GroupWikiManager
+    saml_group_links: "GroupSAMLGroupLinkManager"
 
     @cli.register_custom_action("Group", ("project_id",))
     @exc.on_http_error(exc.GitlabTransferProjectError)
@@ -464,3 +468,20 @@ class GroupLDAPGroupLinkManager(ListMixin, CreateMixin, DeleteMixin, RESTManager
     _create_attrs = RequiredOptional(
         required=("provider", "group_access"), exclusive=("cn", "filter")
     )
+
+
+class GroupSAMLGroupLink(ObjectDeleteMixin, RESTObject):
+    _id_attr = "name"
+    _repr_attr = "name"
+
+
+class GroupSAMLGroupLinkManager(NoUpdateMixin, RESTManager):
+    _path = "/groups/{group_id}/saml_group_links"
+    _obj_cls: Type[GroupSAMLGroupLink] = GroupSAMLGroupLink
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(required=("saml_group_name", "access_level"))
+
+    def get(
+        self, id: Union[str, int], lazy: bool = False, **kwargs: Any
+    ) -> GroupSAMLGroupLink:
+        return cast(GroupSAMLGroupLink, super().get(id=id, lazy=lazy, **kwargs))


Test patch:
diff --git a/tests/functional/api/test_groups.py b/tests/functional/api/test_groups.py
--- a/tests/functional/api/test_groups.py
+++ b/tests/functional/api/test_groups.py
@@ -304,3 +304,11 @@ def test_group_transfer(gl, group):
 
     transferred_group = gl.groups.get(transfer_group.id)
     assert transferred_group.path == transferred_group.full_path
+
+
+@pytest.mark.gitlab_premium
+@pytest.mark.xfail(reason="need to setup an identity provider or it's mock")
+def test_group_saml_group_links(group):
+    group.saml_group_links.create(
+        {"saml_group_name": "saml-group-1", "access_level": 10}
+    )
diff --git a/tests/unit/objects/test_groups.py b/tests/unit/objects/test_groups.py
--- a/tests/unit/objects/test_groups.py
+++ b/tests/unit/objects/test_groups.py
@@ -8,7 +8,12 @@
 import responses
 
 import gitlab
-from gitlab.v4.objects import GroupDescendantGroup, GroupLDAPGroupLink, GroupSubgroup
+from gitlab.v4.objects import (
+    GroupDescendantGroup,
+    GroupLDAPGroupLink,
+    GroupSAMLGroupLink,
+    GroupSubgroup,
+)
 from gitlab.v4.objects.projects import GroupProject, SharedProject
 
 content = {"name": "name", "id": 1, "path": "path"}
@@ -20,6 +25,11 @@
         "filter": "(memberOf=cn=some_group,ou=groups,ou=fake_ou,dc=sub_dc,dc=example,dc=tld)",
     }
 ]
+saml_group_links_content = [{"name": "saml-group-1", "access_level": 10}]
+create_saml_group_link_request_body = {
+    "saml_group_name": "saml-group-1",
+    "access_level": 10,
+}
 projects_content = [
     {
         "id": 9,
@@ -237,6 +247,75 @@ def resp_list_ldap_group_links(no_content):
         yield rsps
 
 
+@pytest.fixture
+def resp_list_saml_group_links():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/saml_group_links",
+            json=saml_group_links_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_saml_group_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/saml_group_links/saml-group-1",
+            json=saml_group_links_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_saml_group_link():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/saml_group_links",
+            match=[
+                responses.matchers.json_params_matcher(
+                    create_saml_group_link_request_body
+                )
+            ],
+            json=saml_group_links_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_saml_group_link(no_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/saml_group_links",
+            match=[
+                responses.matchers.json_params_matcher(
+                    create_saml_group_link_request_body
+                )
+            ],
+            json=saml_group_links_content[0],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/groups/1/saml_group_links/saml-group-1",
+            json=no_content,
+            content_type="application/json",
+            status=204,
+        )
+        yield rsps
+
+
 def test_get_group(gl, resp_groups):
     data = gl.groups.get(1)
     assert isinstance(data, gitlab.v4.objects.Group)
@@ -341,3 +420,36 @@ def test_update_group_push_rule(
 def test_delete_group_push_rule(group, resp_delete_push_rules_group):
     pr = group.pushrules.get()
     pr.delete()
+
+
+def test_list_saml_group_links(group, resp_list_saml_group_links):
+    saml_group_links = group.saml_group_links.list()
+    assert isinstance(saml_group_links[0], GroupSAMLGroupLink)
+    assert saml_group_links[0].name == saml_group_links_content[0]["name"]
+    assert (
+        saml_group_links[0].access_level == saml_group_links_content[0]["access_level"]
+    )
+
+
+def test_get_saml_group_link(group, resp_get_saml_group_link):
+    saml_group_link = group.saml_group_links.get("saml-group-1")
+    assert isinstance(saml_group_link, GroupSAMLGroupLink)
+    assert saml_group_link.name == saml_group_links_content[0]["name"]
+    assert saml_group_link.access_level == saml_group_links_content[0]["access_level"]
+
+
+def test_create_saml_group_link(group, resp_create_saml_group_link):
+    saml_group_link = group.saml_group_links.create(create_saml_group_link_request_body)
+    assert isinstance(saml_group_link, GroupSAMLGroupLink)
+    assert (
+        saml_group_link.name == create_saml_group_link_request_body["saml_group_name"]
+    )
+    assert (
+        saml_group_link.access_level
+        == create_saml_group_link_request_body["access_level"]
+    )
+
+
+def test_delete_saml_group_link(group, resp_delete_saml_group_link):
+    saml_group_link = group.saml_group_links.create(create_saml_group_link_request_body)
+    saml_group_link.delete()