Skip to content

Commit 44f0c86

Browse files
committed
feat: add script to change role for GitLab organizations
Add Python script with, using a .env file besides, will change roles of organization groups and projects members. Still need to have the suitable permission to prevent 403 errors. Assisted-by: GPT-4o-mini (Dinootoo) Signed-off-by: Pierre-Yves Lapersonne <[email protected]>
1 parent ee6ca79 commit 44f0c86

File tree

3 files changed

+272
-0
lines changed

3 files changed

+272
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ toolbox/diver/.floss-toolbox/data
66

77
toolbox/gitlab/data
88
toolbox/gitlab/.env
9+
toolbox/gitlab/utils/.env
910

1011
toolbox/utils/text-generator/_templates/new-GitHub-repository-contributors.fr.template.txt.result
1112
toolbox/utils/third-party-generator/components.csv.result

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased](https://github.com/Orange-OpenSource/floss-toolbox/compare/2.22.0..dev)
99

10+
### Added
11+
12+
- GitLab Python script to update permissions
13+
1014
## [2.22.0](https://github.com/Orange-OpenSource/floss-toolbox/compare/2.22.0..2.21.0) - 2025-01-27
1115

1216
### Added
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
#!/usr/bin/env python3.8
2+
# Software Name: floss-toolbox
3+
# SPDX-FileCopyrightText: Copyright (c) Orange SA
4+
# SPDX-License-Identifier: Apache-2.0
5+
#
6+
# This software is distributed under the Apache 2.0 license,
7+
# the text of which is available at https://opensource.org/license/apache-2-0
8+
# or see the "LICENSE.txt" file for more details.
9+
#
10+
# Authors: See CONTRIBUTORS.txt
11+
# Software description: A toolbox of scripts to help work of forges admins and open source referents
12+
13+
import argparse
14+
from dotenv import load_dotenv
15+
import requests
16+
import os
17+
import time
18+
19+
# Environment variables
20+
# ---------------------
21+
22+
# Below are expected environment variables defined in .env file beside this script:
23+
# - GITLAB_URL: the URL to the GitLab instance (e.g. "https://gitlab.com")
24+
# - ORGANIZATION_NAME: organization name for GitLab (e.g. "Orange-OpenSource")
25+
# - ROLE_ID_TO_CHANGE: the identifier of the role to change (e.g. "50" for "Owner")
26+
# - CUSTOM_ROLE_ID_TO_APPLY: the identifier of the new role to apply (e.g. "2004291" for a custom role)
27+
# - PROTECTED_USERS: the list of users for whom role must not be changed, comma separated (e.g. "bbailleux,lmarie23,pylapersonne-orange,NicolasToussaint")
28+
# - GITLAB_PRIVATE_TOKEN: private API token for GitLab organization
29+
30+
# See for values of roles doc at https://docs.gitlab.com/development/permissions/predefined_roles/
31+
# Get custom identifier for role at https://gitlab.com/groups/{ORG_NAME}/-/settings/roles_and_permissions
32+
33+
load_dotenv()
34+
35+
# Configuration
36+
# -------------
37+
38+
GITLAB_URL = os.getenv('GITLAB_URL')
39+
ORG_NAME = os.getenv('ORGANIZATION_NAME')
40+
ROLE_ID_TO_CHANGE = int(os.getenv('ROLE_ID_TO_CHANGE'), 10)
41+
CUSTOM_ROLE_ID_TO_APPLY = int(os.getenv('CUSTOM_ROLE_ID_TO_APPLY', 10))
42+
GITLAB_PRIVATE_TOKEN = os.getenv('GITLAB_PRIVATE_TOKEN')
43+
PROTECTED_USERS = os.getenv('PROTECTED_USERS', '').split(',')
44+
45+
headers = {
46+
'Private-Token': GITLAB_PRIVATE_TOKEN
47+
}
48+
49+
# Environement checks
50+
# -------------------
51+
52+
if not GITLAB_URL:
53+
raise ValueError("💥 Error: The environment variable 'GITLAB_URL' is not set or is empty.")
54+
if not ORG_NAME:
55+
raise ValueError("💥 Error: The environment variable 'ORG_NAME' is not set or is empty.")
56+
if not ROLE_ID_TO_CHANGE:
57+
raise ValueError("💥 Error: The environment variable 'ROLE_ID_TO_CHANGE' is not set or is empty.")
58+
if not CUSTOM_ROLE_ID_TO_APPLY:
59+
raise ValueError("💥 Error: The environment variable 'CUSTOM_ROLE_ID_TO_APPLY' is not set or is empty.")
60+
if not GITLAB_PRIVATE_TOKEN:
61+
raise ValueError("💥 Error: The environment variable 'GITLAB_PRIVATE_TOKEN' is not set or is empty.")
62+
if not PROTECTED_USERS:
63+
raise ValueError("💥 Error: The environment variable 'PROTECTED_USERS' is not set or is empty.")
64+
try:
65+
CUSTOM_ROLE_ID_TO_APPLY = int(CUSTOM_ROLE_ID_TO_APPLY)
66+
except ValueError:
67+
raise ValueError("💥 Error: The environment variable 'CUSTOM_ROLE_ID_TO_APPLY' must be an integer.")
68+
try:
69+
ROLE_ID_TO_CHANGE = int(ROLE_ID_TO_CHANGE)
70+
except ValueError:
71+
raise ValueError("💥 Error: The environment variable 'ROLE_ID_TO_CHANGE' must be an integer.")
72+
73+
# Services API
74+
# ------------
75+
76+
# Change roles for groups and projects
77+
# ------------------------------------
78+
79+
def change_role(project_id, user_id):
80+
"""
81+
Change the role of a member in a project by applying role with id CUSTOM_ROLE_ID_TO_APPLY.
82+
83+
:param project_id: ID of the project where the role needs to be changed.
84+
:param user_id: ID of the user whose role needs to be changed.
85+
:return: True if the change was successful, otherwise False.
86+
"""
87+
url = f"{GITLAB_URL}/api/v4/projects/{project_id}/members/{user_id}"
88+
data = {'access_level': CUSTOM_ROLE_ID_TO_APPLY}
89+
response = requests.put(url, headers=headers, data=data)
90+
if response.status_code != 200:
91+
print(f"❌ Failed to change role for project: {response.status_code} - {response.text}")
92+
return False
93+
else:
94+
return True
95+
96+
def change_group_role(group_id, user_id):
97+
"""
98+
Change the role of a member in a group by applying role with id CUSTOM_ROLE_ID_TO_APPLY.
99+
100+
:param group_id: ID of the group where the role needs to be changed.
101+
:param user_id: ID of the user whose role needs to be changed.
102+
:return: True if the change was successful, otherwise False.
103+
"""
104+
url = f"{GITLAB_URL}/api/v4/groups/{group_id}/members/{user_id}"
105+
data = {'access_level': CUSTOM_ROLE_ID_TO_APPLY}
106+
response = requests.put(url, headers=headers, data=data)
107+
if response.status_code != 200:
108+
print(f"❌ Failed to change role for group: {response.status_code} - {response.text}")
109+
return False
110+
else:
111+
return True
112+
113+
# List roles to change for groups and projects
114+
# --------------------------------------------
115+
116+
def list_group_role_to_change(group_id):
117+
"""
118+
List all members with the role identified by ROLE_ID_TO_CHANGE to change in a group.
119+
120+
:param group_id: ID of the group whose members need to be listed.
121+
:return: List of members with the role ROLE_ID_TO_CHANGE.
122+
"""
123+
members_url = f"{GITLAB_URL}/api/v4/groups/{group_id}/members"
124+
response = requests.get(members_url, headers=headers)
125+
126+
if response.status_code == 200:
127+
members = response.json()
128+
return [member for member in members if member['access_level'] == ROLE_ID_TO_CHANGE]
129+
else:
130+
print(f"❌ Failed to retrieve members for group '{group_id}': {response.status_code} - {response.text}")
131+
return []
132+
133+
def list_project_role_to_change(project_id):
134+
"""
135+
List all members with the role identified by ROLE_ID_TO_CHANGE in a project.
136+
137+
:param project_id: ID of the project whose members need to be listed.
138+
:return: List of members with the role ROLE_ID_TO_CHANGE.
139+
"""
140+
members_url = f"{GITLAB_URL}/api/v4/projects/{project_id}/members"
141+
response = requests.get(members_url, headers=headers)
142+
143+
if response.status_code == 200:
144+
members = response.json()
145+
return [member for member in members if member['access_level'] == ROLE_ID_TO_CHANGE]
146+
else:
147+
print(f"❌ Failed to retrieve members for project '{project_id}': {response.status_code} - {response.text}")
148+
return []
149+
150+
# Get groups and projects names
151+
# -----------------------------
152+
153+
def get_group_name(group_id):
154+
"""
155+
Get the name of the group by its ID.
156+
157+
:param group_id: ID of the group.
158+
:return: Name of the group.
159+
"""
160+
url = f"{GITLAB_URL}/api/v4/groups/{group_id}"
161+
response = requests.get(url, headers=headers)
162+
if response.status_code == 200:
163+
return response.json().get('name')
164+
else:
165+
print(f"❌ Failed to retrieve group name for group '{group_id}': {response.status_code} - {response.text}")
166+
return None
167+
168+
def get_project_name(project_id):
169+
"""
170+
Get the name of the project by its ID.
171+
172+
:param project_id: ID of the project.
173+
:return: Name of the project.
174+
"""
175+
url = f"{GITLAB_URL}/api/v4/projects/{project_id}"
176+
response = requests.get(url, headers=headers)
177+
if response.status_code == 200:
178+
return response.json().get('name')
179+
else:
180+
print(f"❌ Failed to retrieve project name for project '{project_id}': {response.status_code} - {response.text}")
181+
return None
182+
183+
# Process groups and projects
184+
# ---------------------------
185+
186+
def process_group(group_id):
187+
"""
188+
Process a group by retrieving its members, subgroups, and projects.
189+
190+
:param group_id: ID of the group to process.
191+
"""
192+
group_name = get_group_name(group_id)
193+
print(f"⏳ Processing group with name '{group_name}'")
194+
# List roles to change of the group
195+
group_roles_to_change = list_group_role_to_change(group_id)
196+
if group_roles_to_change:
197+
print(f"ℹ️ Roles to change in group '{group_name}' (ID: '{group_id}'): {[to_change['username'] for to_change in group_roles_to_change]}")
198+
for to_change in group_roles_to_change:
199+
if to_change['username'] not in PROTECTED_USERS:
200+
if change_group_role(group_id, to_change['id']):
201+
print(f"✅ Changed role for '{to_change['username']}' in group '{group_name}'")
202+
else:
203+
print(f"😱 Failed to change role for '{to_change['username']}' in group '{group_name}'")
204+
else:
205+
print(f"🛑 '{to_change['username']}' is a protected user and will not be changed.")
206+
207+
# Retrieve subgroups
208+
subgroups_url = f"{GITLAB_URL}/api/v4/groups/{group_id}/subgroups"
209+
subgroups = requests.get(subgroups_url, headers=headers).json()
210+
211+
if isinstance(subgroups, list):
212+
for subgroup in subgroups:
213+
process_group(subgroup['id'])
214+
215+
# Retrieve projects in the group
216+
projects_url = f"{GITLAB_URL}/api/v4/groups/{group_id}/projects"
217+
projects = requests.get(projects_url, headers=headers).json()
218+
219+
if isinstance(projects, list):
220+
for project in projects:
221+
project_name = get_project_name(project['id'])
222+
print(f"⏳ Processing project with name '{project_name}'")
223+
project_role_to_change = list_project_role_to_change(project['id'])
224+
if project_role_to_change:
225+
print(f"ℹ️ Roles to change in project '{project_name}' (ID: '{project['id']}'): {[to_change['username'] for to_change in project_role_to_change]}")
226+
for to_change in project_role_to_change:
227+
if to_change['username'] not in PROTECTED_USERS:
228+
if change_role(project['id'], to_change['id']):
229+
print(f"✅ Changed role for '{to_change['username']}' in project '{project_name}'")
230+
else:
231+
print(f"😱 Failed to change role for '{to_change['username']}' in project '{project_name}'")
232+
else:
233+
print(f"🛑 '{to_change['username']}' is a protected user and will not be changed.")
234+
else:
235+
print(f"❌ Unexpected response format for projects in group '{group_name}': {projects}")
236+
237+
# Main
238+
# -----
239+
240+
def main():
241+
"""
242+
Main function that handles command-line arguments and initiates processing.
243+
"""
244+
start_time = time.time() # Record the start time
245+
246+
print(f"❗In organization '{ORG_NAME}' on '{GITLAB_URL}' will change all roles with identifier ROLE_ID_TO_CHANGE '{ROLE_ID_TO_CHANGE}' to CUSTOM_ROLE_ID_TO_APPLY '{CUSTOM_ROLE_ID_TO_APPLY}'")
247+
# Wait for user input before exiting
248+
input("👉 Press any key to continue...")
249+
250+
# Retrieve the ID of the organization
251+
groups_url = f"{GITLAB_URL}/api/v4/groups?search={ORG_NAME}"
252+
groups = requests.get(groups_url, headers=headers).json()
253+
254+
if isinstance(groups, list):
255+
for group in groups:
256+
process_group(group['id'])
257+
else:
258+
print(f"❌ Unexpected response format for groups: {groups}")
259+
260+
end_time = time.time()
261+
elapsed_time = end_time - start_time
262+
print(f"⌛ Elapsed time: {elapsed_time:.2f} seconds")
263+
print("🧡 If you spotted a bug or have idea to improve the script, go there: https://github.com/Orange-OpenSource/floss-toolbox/issues/new/choose")
264+
print("👋 Bye!")
265+
266+
if __name__ == "__main__":
267+
main()

0 commit comments

Comments
 (0)