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