1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# Copyright: (c) 2021, Sergey Mikhaltsov <metanovii@gmail.com> 5# Copyright: (c) 2020, Zainab Alsaffar <Zainab.Alsaffar@mail.rit.edu> 6# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 7 8from __future__ import absolute_import, division, print_function 9__metaclass__ = type 10 11DOCUMENTATION = r''' 12--- 13module: gitlab_project_members 14short_description: Manage project members on GitLab Server 15version_added: 2.2.0 16description: 17 - This module allows to add and remove members to/from a project, or change a member's access level in a project on GitLab. 18author: 19 - Sergey Mikhaltsov (@metanovii) 20 - Zainab Alsaffar (@zanssa) 21requirements: 22 - python-gitlab python module <= 1.15.0 23 - owner or maintainer rights to project on the GitLab server 24options: 25 api_token: 26 description: 27 - A personal access token to authenticate with the GitLab API. 28 required: true 29 type: str 30 validate_certs: 31 description: 32 - Whether or not to validate TLS/SSL certificates when supplying a HTTPS endpoint. 33 - Should only be set to C(false) if you can guarantee that you are talking to the correct server 34 and no man-in-the-middle attack can happen. 35 default: true 36 type: bool 37 api_username: 38 description: 39 - The username to use for authentication against the API. 40 type: str 41 api_password: 42 description: 43 - The password to use for authentication against the API. 44 type: str 45 api_url: 46 description: 47 - The resolvable endpoint for the API. 48 type: str 49 project: 50 description: 51 - The name of the GitLab project the member is added to/removed from. 52 required: true 53 type: str 54 gitlab_user: 55 description: 56 - A username or a list of usernames to add to/remove from the GitLab project. 57 - Mutually exclusive with I(gitlab_users_access). 58 type: list 59 elements: str 60 access_level: 61 description: 62 - The access level for the user. 63 - Required if I(state=present), user state is set to present. 64 type: str 65 choices: ['guest', 'reporter', 'developer', 'maintainer'] 66 gitlab_users_access: 67 description: 68 - Provide a list of user to access level mappings. 69 - Every dictionary in this list specifies a user (by username) and the access level the user should have. 70 - Mutually exclusive with I(gitlab_user) and I(access_level). 71 - Use together with I(purge_users) to remove all users not specified here from the project. 72 type: list 73 elements: dict 74 suboptions: 75 name: 76 description: A username or a list of usernames to add to/remove from the GitLab project. 77 type: str 78 required: true 79 access_level: 80 description: 81 - The access level for the user. 82 - Required if I(state=present), user state is set to present. 83 type: str 84 choices: ['guest', 'reporter', 'developer', 'maintainer'] 85 required: true 86 version_added: 3.7.0 87 state: 88 description: 89 - State of the member in the project. 90 - On C(present), it adds a user to a GitLab project. 91 - On C(absent), it removes a user from a GitLab project. 92 choices: ['present', 'absent'] 93 default: 'present' 94 type: str 95 purge_users: 96 description: 97 - Adds/remove users of the given access_level to match the given I(gitlab_user)/I(gitlab_users_access) list. 98 If omitted do not purge orphaned members. 99 - Is only used when I(state=present). 100 type: list 101 elements: str 102 choices: ['guest', 'reporter', 'developer', 'maintainer'] 103 version_added: 3.7.0 104notes: 105 - Supports C(check_mode). 106''' 107 108EXAMPLES = r''' 109- name: Add a user to a GitLab Project 110 community.general.gitlab_project_members: 111 api_url: 'https://gitlab.example.com' 112 api_token: 'Your-Private-Token' 113 validate_certs: True 114 project: projectname 115 gitlab_user: username 116 access_level: developer 117 state: present 118 119- name: Remove a user from a GitLab project 120 community.general.gitlab_project_members: 121 api_url: 'https://gitlab.example.com' 122 api_token: 'Your-Private-Token' 123 validate_certs: False 124 project: projectname 125 gitlab_user: username 126 state: absent 127 128- name: Add a list of Users to A GitLab project 129 community.general.gitlab_project_members: 130 api_url: 'https://gitlab.example.com' 131 api_token: 'Your-Private-Token' 132 gitlab_project: projectname 133 gitlab_user: 134 - user1 135 - user2 136 access_level: developer 137 state: present 138 139- name: Add a list of Users with Dedicated Access Levels to A GitLab project 140 community.general.gitlab_project_members: 141 api_url: 'https://gitlab.example.com' 142 api_token: 'Your-Private-Token' 143 project: projectname 144 gitlab_users_access: 145 - name: user1 146 access_level: developer 147 - name: user2 148 access_level: maintainer 149 state: present 150 151- name: Add a user, remove all others which might be on this access level 152 community.general.gitlab_project_members: 153 api_url: 'https://gitlab.example.com' 154 api_token: 'Your-Private-Token' 155 project: projectname 156 gitlab_user: username 157 access_level: developer 158 pruge_users: developer 159 state: present 160 161- name: Remove a list of Users with Dedicated Access Levels to A GitLab project 162 community.general.gitlab_project_members: 163 api_url: 'https://gitlab.example.com' 164 api_token: 'Your-Private-Token' 165 project: projectname 166 gitlab_users_access: 167 - name: user1 168 access_level: developer 169 - name: user2 170 access_level: maintainer 171 state: absent 172''' 173 174RETURN = r''' # ''' 175 176from ansible.module_utils.api import basic_auth_argument_spec 177from ansible.module_utils.basic import AnsibleModule, missing_required_lib 178 179from ansible_collections.community.general.plugins.module_utils.gitlab import gitlabAuthentication 180 181import traceback 182 183try: 184 import gitlab 185 HAS_PY_GITLAB = True 186except ImportError: 187 GITLAB_IMP_ERR = traceback.format_exc() 188 HAS_PY_GITLAB = False 189 190 191class GitLabProjectMembers(object): 192 def __init__(self, module, gl): 193 self._module = module 194 self._gitlab = gl 195 196 def get_project(self, project_name): 197 project_exists = self._gitlab.projects.list(search=project_name) 198 if project_exists: 199 return project_exists[0].id 200 201 def get_user_id(self, gitlab_user): 202 user_exists = self._gitlab.users.list(username=gitlab_user) 203 if user_exists: 204 return user_exists[0].id 205 206 # get all members in a project 207 def get_members_in_a_project(self, gitlab_project_id): 208 project = self._gitlab.projects.get(gitlab_project_id) 209 return project.members.list(all=True) 210 211 # get single member in a project by user name 212 def get_member_in_a_project(self, gitlab_project_id, gitlab_user_id): 213 member = None 214 project = self._gitlab.projects.get(gitlab_project_id) 215 try: 216 member = project.members.get(gitlab_user_id) 217 if member: 218 return member 219 except gitlab.exceptions.GitlabGetError as e: 220 return None 221 222 # check if the user is a member of the project 223 def is_user_a_member(self, members, gitlab_user_id): 224 for member in members: 225 if member.id == gitlab_user_id: 226 return True 227 return False 228 229 # add user to a project 230 def add_member_to_project(self, gitlab_user_id, gitlab_project_id, access_level): 231 project = self._gitlab.projects.get(gitlab_project_id) 232 add_member = project.members.create( 233 {'user_id': gitlab_user_id, 'access_level': access_level}) 234 235 # remove user from a project 236 def remove_user_from_project(self, gitlab_user_id, gitlab_project_id): 237 project = self._gitlab.projects.get(gitlab_project_id) 238 project.members.delete(gitlab_user_id) 239 240 # get user's access level 241 def get_user_access_level(self, members, gitlab_user_id): 242 for member in members: 243 if member.id == gitlab_user_id: 244 return member.access_level 245 246 # update user's access level in a project 247 def update_user_access_level(self, members, gitlab_user_id, access_level): 248 for member in members: 249 if member.id == gitlab_user_id: 250 member.access_level = access_level 251 member.save() 252 253 254def main(): 255 argument_spec = basic_auth_argument_spec() 256 argument_spec.update(dict( 257 api_token=dict(type='str', required=True, no_log=True), 258 project=dict(type='str', required=True), 259 gitlab_user=dict(type='list', elements='str'), 260 state=dict(type='str', default='present', choices=['present', 'absent']), 261 access_level=dict(type='str', choices=['guest', 'reporter', 'developer', 'maintainer']), 262 purge_users=dict(type='list', elements='str', choices=[ 263 'guest', 'reporter', 'developer', 'maintainer']), 264 gitlab_users_access=dict( 265 type='list', 266 elements='dict', 267 options=dict( 268 name=dict(type='str', required=True), 269 access_level=dict(type='str', choices=[ 270 'guest', 'reporter', 'developer', 'maintainer'], required=True), 271 ) 272 ), 273 )) 274 275 module = AnsibleModule( 276 argument_spec=argument_spec, 277 mutually_exclusive=[ 278 ['api_username', 'api_token'], 279 ['api_password', 'api_token'], 280 ['gitlab_user', 'gitlab_users_access'], 281 ['access_level', 'gitlab_users_access'], 282 ], 283 required_together=[ 284 ['api_username', 'api_password'], 285 ['gitlab_user', 'access_level'], 286 ], 287 required_one_of=[ 288 ['api_username', 'api_token'], 289 ['gitlab_user', 'gitlab_users_access'], 290 ], 291 required_if=[ 292 ['state', 'present', ['access_level', 'gitlab_users_access'], True], 293 ], 294 supports_check_mode=True, 295 ) 296 297 if not HAS_PY_GITLAB: 298 module.fail_json(msg=missing_required_lib('python-gitlab', url='https://python-gitlab.readthedocs.io/en/stable/'), exception=GITLAB_IMP_ERR) 299 300 access_level_int = { 301 'guest': gitlab.GUEST_ACCESS, 302 'reporter': gitlab.REPORTER_ACCESS, 303 'developer': gitlab.DEVELOPER_ACCESS, 304 'maintainer': gitlab.MAINTAINER_ACCESS, 305 } 306 307 gitlab_project = module.params['project'] 308 state = module.params['state'] 309 access_level = module.params['access_level'] 310 purge_users = module.params['purge_users'] 311 312 if purge_users: 313 purge_users = [access_level_int[level] for level in purge_users] 314 315 # connect to gitlab server 316 gl = gitlabAuthentication(module) 317 318 project = GitLabProjectMembers(module, gl) 319 320 gitlab_project_id = project.get_project(gitlab_project) 321 322 # project doesn't exist 323 if not gitlab_project_id: 324 module.fail_json(msg="project '%s' not found." % gitlab_project) 325 326 members = [] 327 if module.params['gitlab_user'] is not None: 328 gitlab_users_access = [] 329 gitlab_users = module.params['gitlab_user'] 330 for gl_user in gitlab_users: 331 gitlab_users_access.append( 332 {'name': gl_user, 'access_level': access_level_int[access_level] if access_level else None}) 333 elif module.params['gitlab_users_access'] is not None: 334 gitlab_users_access = module.params['gitlab_users_access'] 335 for user_level in gitlab_users_access: 336 user_level['access_level'] = access_level_int[user_level['access_level']] 337 338 if len(gitlab_users_access) == 1 and not purge_users: 339 # only single user given 340 members = [project.get_member_in_a_project( 341 gitlab_project_id, project.get_user_id(gitlab_users_access[0]['name']))] 342 if members[0] is None: 343 members = [] 344 elif len(gitlab_users_access) > 1 or purge_users: 345 # list of users given 346 members = project.get_members_in_a_project(gitlab_project_id) 347 else: 348 module.exit_json(changed='OK', result="Nothing to do, please give at least one user or set purge_users true.", 349 result_data=[]) 350 351 changed = False 352 error = False 353 changed_users = [] 354 changed_data = [] 355 356 for gitlab_user in gitlab_users_access: 357 gitlab_user_id = project.get_user_id(gitlab_user['name']) 358 359 # user doesn't exist 360 if not gitlab_user_id: 361 if state == 'absent': 362 changed_users.append("user '%s' not found, and thus also not part of the project" % gitlab_user['name']) 363 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'OK', 364 'msg': "user '%s' not found, and thus also not part of the project" % gitlab_user['name']}) 365 else: 366 error = True 367 changed_users.append("user '%s' not found." % gitlab_user['name']) 368 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'FAILED', 369 'msg': "user '%s' not found." % gitlab_user['name']}) 370 continue 371 372 is_user_a_member = project.is_user_a_member(members, gitlab_user_id) 373 374 # check if the user is a member in the project 375 if not is_user_a_member: 376 if state == 'present': 377 # add user to the project 378 try: 379 if not module.check_mode: 380 project.add_member_to_project(gitlab_user_id, gitlab_project_id, gitlab_user['access_level']) 381 changed = True 382 changed_users.append("Successfully added user '%s' to project" % gitlab_user['name']) 383 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'CHANGED', 384 'msg': "Successfully added user '%s' to project" % gitlab_user['name']}) 385 except (gitlab.exceptions.GitlabCreateError) as e: 386 error = True 387 changed_users.append("Failed to updated the access level for the user, '%s'" % gitlab_user['name']) 388 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'FAILED', 389 'msg': "Not allowed to add the access level for the member, %s: %s" % (gitlab_user['name'], e)}) 390 # state as absent 391 else: 392 changed_users.append("User, '%s', is not a member in the project. No change to report" % gitlab_user['name']) 393 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'OK', 394 'msg': "User, '%s', is not a member in the project. No change to report" % gitlab_user['name']}) 395 # in case that a user is a member 396 else: 397 if state == 'present': 398 # compare the access level 399 user_access_level = project.get_user_access_level(members, gitlab_user_id) 400 if user_access_level == gitlab_user['access_level']: 401 changed_users.append("User, '%s', is already a member in the project. No change to report" % gitlab_user['name']) 402 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'OK', 403 'msg': "User, '%s', is already a member in the project. No change to report" % gitlab_user['name']}) 404 else: 405 # update the access level for the user 406 try: 407 if not module.check_mode: 408 project.update_user_access_level(members, gitlab_user_id, gitlab_user['access_level']) 409 changed = True 410 changed_users.append("Successfully updated the access level for the user, '%s'" % gitlab_user['name']) 411 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'CHANGED', 412 'msg': "Successfully updated the access level for the user, '%s'" % gitlab_user['name']}) 413 except (gitlab.exceptions.GitlabUpdateError) as e: 414 error = True 415 changed_users.append("Failed to updated the access level for the user, '%s'" % gitlab_user['name']) 416 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'FAILED', 417 'msg': "Not allowed to update the access level for the member, %s: %s" % (gitlab_user['name'], e)}) 418 else: 419 # remove the user from the project 420 try: 421 if not module.check_mode: 422 project.remove_user_from_project(gitlab_user_id, gitlab_project_id) 423 changed = True 424 changed_users.append("Successfully removed user, '%s', from the project" % gitlab_user['name']) 425 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'CHANGED', 426 'msg': "Successfully removed user, '%s', from the project" % gitlab_user['name']}) 427 except (gitlab.exceptions.GitlabDeleteError) as e: 428 error = True 429 changed_users.append("Failed to removed user, '%s', from the project" % gitlab_user['name']) 430 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'FAILED', 431 'msg': "Failed to remove user, '%s' from the project: %s" % (gitlab_user['name'], e)}) 432 433 # if state = present and purge_users set delete users which are in members having give access level but not in gitlab_users 434 if state == 'present' and purge_users: 435 uppercase_names_in_gitlab_users_access = [] 436 for name in gitlab_users_access: 437 uppercase_names_in_gitlab_users_access.append(name['name'].upper()) 438 439 for member in members: 440 if member.access_level in purge_users and member.username.upper() not in uppercase_names_in_gitlab_users_access: 441 try: 442 if not module.check_mode: 443 project.remove_user_from_project(member.id, gitlab_project_id) 444 changed = True 445 changed_users.append("Successfully removed user '%s', from project. Was not in given list" % member.username) 446 changed_data.append({'gitlab_user': member.username, 'result': 'CHANGED', 447 'msg': "Successfully removed user '%s', from project. Was not in given list" % member.username}) 448 except (gitlab.exceptions.GitlabDeleteError) as e: 449 error = True 450 changed_users.append("Failed to removed user, '%s', from the project" % gitlab_user['name']) 451 changed_data.append({'gitlab_user': gitlab_user['name'], 'result': 'FAILED', 452 'msg': "Failed to remove user, '%s' from the project: %s" % (gitlab_user['name'], e)}) 453 454 if len(gitlab_users_access) == 1 and error: 455 # if single user given and an error occurred return error for list errors will be per user 456 module.fail_json(msg="FAILED: '%s '" % changed_users[0], result_data=changed_data) 457 elif error: 458 module.fail_json( 459 msg='FAILED: At least one given user/permission could not be set', result_data=changed_data) 460 461 module.exit_json(changed=changed, msg='Successfully set memberships', result="\n".join(changed_users), result_data=changed_data) 462 463 464if __name__ == '__main__': 465 main() 466