From 9f4c1fa8e936955d60238aae62ac26ede040bb03 Mon Sep 17 00:00:00 2001 From: Mehfuz Khan Date: Mon, 3 Jun 2024 16:22:45 +0530 Subject: [PATCH] support group permission to codeComponents in gitlab_permission (#4392) --- reconcile/gitlab_permissions.py | 23 ++++++++ reconcile/test/test_gitlab_permissions.py | 57 ++++++++++++++++++ reconcile/test/utils/test_gitlab_api.py | 70 ++++++++++++++++++++++- reconcile/utils/gitlab_api.py | 67 ++++++++++++++++++---- 4 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 reconcile/test/test_gitlab_permissions.py diff --git a/reconcile/gitlab_permissions.py b/reconcile/gitlab_permissions.py index 6c5807af0c..5b622d4cbf 100644 --- a/reconcile/gitlab_permissions.py +++ b/reconcile/gitlab_permissions.py @@ -8,8 +8,10 @@ from reconcile.utils import batches from reconcile.utils.defer import defer from reconcile.utils.gitlab_api import GitLabApi +from reconcile.utils.unleash import get_feature_toggle_state QONTRACT_INTEGRATION = "gitlab-permissions" +APP_SRE_GROUP_NAME = "app-sre" PAGE_SIZE = 100 @@ -50,6 +52,19 @@ def run(dry_run, thread_pool_size=10, defer=None): if defer: defer(gl.cleanup) repos = queries.get_repos(server=gl.server, exclude_manage_permissions=True) + share_with_group_enabled = get_feature_toggle_state( + "gitlab-permissions-share-with-group", + default=False, + ) + if share_with_group_enabled: + share_project_with_group(gl, repos, dry_run) + else: + share_project_with_group_members(gl, repos, thread_pool_size, dry_run) + + +def share_project_with_group_members( + gl: GitLabApi, repos: list[str], thread_pool_size: int, dry_run: bool +) -> None: app_sre = gl.get_app_sre_group_users() results = threaded.run( get_members_to_add, repos, thread_pool_size, gl=gl, app_sre=app_sre @@ -61,6 +76,14 @@ def run(dry_run, thread_pool_size=10, defer=None): gl.add_project_member(m["repo"], m["user"]) +def share_project_with_group(gl: GitLabApi, repos: list[str], dry_run: bool) -> None: + group_id, shared_projects = gl.get_group_id_and_shared_projects(APP_SRE_GROUP_NAME) + shared_project_repos = {project["web_url"] for project in shared_projects} + repos_to_share = set(repos) - shared_project_repos + for repo in repos_to_share: + gl.share_project_with_group(repo_url=repo, group_id=group_id, dry_run=dry_run) + + def early_exit_desired_state(*args, **kwargs) -> dict[str, Any]: instance = queries.get_gitlab_instance() return { diff --git a/reconcile/test/test_gitlab_permissions.py b/reconcile/test/test_gitlab_permissions.py new file mode 100644 index 0000000000..2dbc78843a --- /dev/null +++ b/reconcile/test/test_gitlab_permissions.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock, create_autospec + +import pytest +from gitlab.v4.objects import CurrentUser, GroupMember +from pytest_mock import MockerFixture + +from reconcile import gitlab_permissions +from reconcile.utils.gitlab_api import GitLabApi + + +@pytest.fixture() +def mocked_queries(mocker: MockerFixture) -> MagicMock: + queries = mocker.patch("reconcile.gitlab_permissions.queries") + queries.get_gitlab_instance.return_value = {} + queries.get_app_interface_settings.return_value = {} + queries.get_repos.return_value = ["https://test-gitlab.com"] + return queries + + +@pytest.fixture() +def mocked_gl() -> MagicMock: + gl = create_autospec(GitLabApi) + gl.server = "test_server" + gl.user = create_autospec(CurrentUser) + gl.user.username = "test_name" + return gl + + +def test_run_share_with_members( + mocked_queries: MagicMock, mocker: MockerFixture, mocked_gl: MagicMock +) -> None: + mocker.patch("reconcile.gitlab_permissions.GitLabApi").return_value = mocked_gl + mocked_gl.get_app_sre_group_users.return_value = [ + create_autospec(GroupMember, id=123, username="test_name2") + ] + mocker.patch( + "reconcile.gitlab_permissions.get_feature_toggle_state" + ).return_value = False + mocked_gl.get_project_maintainers.return_value = ["test_name"] + + gitlab_permissions.run(False, thread_pool_size=1) + mocked_gl.add_project_member.assert_called_once() + + +def test_run_share_with_group( + mocked_queries: MagicMock, mocker: MockerFixture, mocked_gl: MagicMock +) -> None: + mocker.patch("reconcile.gitlab_permissions.GitLabApi").return_value = mocked_gl + mocker.patch( + "reconcile.gitlab_permissions.get_feature_toggle_state" + ).return_value = True + mocked_gl.get_group_id_and_shared_projects.return_value = ( + 1234, + [{"web_url": "https://test.com"}], + ) + gitlab_permissions.run(False, thread_pool_size=1) + mocked_gl.share_project_with_group.assert_called_once() diff --git a/reconcile/test/utils/test_gitlab_api.py b/reconcile/test/utils/test_gitlab_api.py index 5772d9948c..59a0ca7cc5 100644 --- a/reconcile/test/utils/test_gitlab_api.py +++ b/reconcile/test/utils/test_gitlab_api.py @@ -14,6 +14,9 @@ ProjectIssueNoteManager, ProjectLabel, ProjectLabelManager, + ProjectManager, + ProjectMember, + ProjectMemberManager, ProjectMergeRequest, ProjectMergeRequestManager, ProjectMergeRequestNote, @@ -466,5 +469,70 @@ def test_get_group_members( assert mocked_gitlab_api.get_group_if_exists("group") is group assert mocked_gitlab_api.get_group_members("group") == [ - {"user": "small", "access_level": None} + {"user": "small", "access_level": "owner"} ] + + +def test_share_project_with_group_positive( + mocked_gitlab_api: GitLabApi, + mocked_gl: Any, +): + projects = create_autospec(ProjectManager) + project = create_autospec(Project) + project.members = create_autospec(ProjectMemberManager) + project.members.all.return_value = [ + create_autospec(ProjectMember, id=mocked_gitlab_api.user.id, access_level=40) + ] + projects.get.return_value = project + mocked_gl.projects = projects + mocked_gitlab_api.share_project_with_group("test_repo", 1111, False) + project.share.assert_called_once_with(1111, 40) + + +def test_share_project_with_group_errored( + mocker: MockerFixture, + mocked_gitlab_api: GitLabApi, + mocked_gl: Any, +): + projects = create_autospec(ProjectManager) + project = create_autospec(Project) + project.members = create_autospec(ProjectMemberManager) + project.members.all.return_value = [] + projects.get.return_value = project + mocked_gl.projects = projects + mocked_logger = mocker.patch("reconcile.utils.gitlab_api.logging") + mocked_gitlab_api.share_project_with_group("test_repo", 1111, False, "maintainer") + mocked_logger.error.assert_called_once_with( + "%s is not shared with %s as %s", + "test_repo", + mocked_gitlab_api.user.username, + "maintainer", + ) + + +def test_get_group_id_and_shared_projects(mocked_gitlab_api: GitLabApi, mocked_gl: Any): + groups = create_autospec(GroupManager) + groups.get.return_value = create_autospec( + Group, + id=1234, + shared_projects=[ + { + "shared_with_groups": [ + { + "group_id": 1234, + "group_access_level": 40, + }, + {"group_id": 1244, "group_access_level": 50}, + ], + "web_url": "https://xyz.com", + }, + { + "web_url": "https://xyz.com", + "shared_with_groups": [{"group_id": 1234, "group_access_level": 30}], + }, + ], + ) + mocked_gl.groups = groups + id, shared_projects = mocked_gitlab_api.get_group_id_and_shared_projects("test") + assert id == 1234 + assert shared_projects[0]["web_url"] == "https://xyz.com" diff --git a/reconcile/utils/gitlab_api.py b/reconcile/utils/gitlab_api.py index 964bd79cb6..50ae5b5231 100644 --- a/reconcile/utils/gitlab_api.py +++ b/reconcile/utils/gitlab_api.py @@ -19,6 +19,13 @@ import gitlab import urllib3 +from gitlab.const import ( + DEVELOPER_ACCESS, + GUEST_ACCESS, + MAINTAINER_ACCESS, + OWNER_ACCESS, + REPORTER_ACCESS, +) from gitlab.v4.objects import ( CurrentUser, Group, @@ -250,6 +257,46 @@ def get_group_if_exists(self, group_name: str) -> Group | None: except gitlab.exceptions.GitlabGetError: return None + def share_project_with_group( + self, repo_url: str, group_id: int, dry_run: bool, access: str = "maintainer" + ) -> None: + project = self.get_project(repo_url) + if project is None: + return None + access_level = self.get_access_level(access) + # check if we have 'access_level' access so we can add the group with same role. + members = self.get_items( + project.members.all, query_parameters={"user_ids": self.user.id} + ) + if not any( + self.user.id == member.id and member.access_level >= access_level + for member in members + ): + logging.error( + "%s is not shared with %s as %s", + repo_url, + self.user.username, + access, + ) + return None + logging.info(["add_group_as_maintainer", repo_url, group_id]) + if not dry_run: + gitlab_request.labels(integration=INTEGRATION_NAME).inc() + project.share(group_id, access_level) + + def get_group_id_and_shared_projects( + self, group_name: str + ) -> tuple[int, list[dict]]: + gitlab_request.labels(integration=INTEGRATION_NAME).inc() + group = self.gl.groups.get(group_name) + return group.id, [ + project + for project in group.shared_projects + for shared_group in project["shared_with_groups"] + if shared_group["group_id"] == group.id + and shared_group["group_access_level"] >= MAINTAINER_ACCESS + ] + @staticmethod def _is_bot_username(username: str) -> bool: """crudely checking for the username @@ -331,30 +378,30 @@ def change_access(self, group, username, access): @staticmethod def get_access_level_string(access_level): - if access_level == gitlab.OWNER_ACCESS: + if access_level == OWNER_ACCESS: return "owner" - if access_level == gitlab.MAINTAINER_ACCESS: + if access_level == MAINTAINER_ACCESS: return "maintainer" - if access_level == gitlab.DEVELOPER_ACCESS: + if access_level == DEVELOPER_ACCESS: return "developer" - if access_level == gitlab.REPORTER_ACCESS: + if access_level == REPORTER_ACCESS: return "reporter" - if access_level == gitlab.GUEST_ACCESS: + if access_level == GUEST_ACCESS: return "guest" @staticmethod def get_access_level(access): access = access.lower() if access == "owner": - return gitlab.OWNER_ACCESS + return OWNER_ACCESS if access == "maintainer": - return gitlab.MAINTAINER_ACCESS + return MAINTAINER_ACCESS if access == "developer": - return gitlab.DEVELOPER_ACCESS + return DEVELOPER_ACCESS if access == "reporter": - return gitlab.REPORTER_ACCESS + return REPORTER_ACCESS if access == "guest": - return gitlab.GUEST_ACCESS + return GUEST_ACCESS def get_group_id_and_projects(self, group_name: str) -> tuple[str, list[str]]: gitlab_request.labels(integration=INTEGRATION_NAME).inc()