Skip to content

Commit aba5042

Browse files
committed
feat(sync-gitlab): idempotency in teams and members synchronization
1 parent 9185e1c commit aba5042

File tree

4 files changed

+404
-100
lines changed

4 files changed

+404
-100
lines changed

team-mapping-gitlab-gitguardian/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Optional environment variables:
3737

3838
- `GITGUARDIAN_INSTANCE` - The URL of a self-hosted GitGuardian instance. Just the scheme and hostname: https://gitguardian.example.com
3939
- `SEND_EMAIL` - Defines whether we should send an email to users when sending invitations
40+
- `REMOVE_MEMBERS` - Defines whether we should delete users from teams if they are not in any Gitlab group
4041
- `EXCLUDE_ADMIN` - Defines whether we should exclude admin users from sync
4142
- `DEFAULT_INCIDENT_PERMISSION` - Defines the default incident permission level for team members, defaults to `can_edit`, it's value must be one of :
4243
- `can_view` : For read permissions
@@ -51,9 +52,9 @@ python config.py
5152

5253
### Nested groups
5354

54-
Teams in GitGuardian will be created based on the name of the deepest group (not the **full path**) of every user's group.
55+
Teams in GitGuardian will be created based on the full path of the group of every user's group.
5556

56-
This means that if a user is in `top-group / middle-group / bottom-group`, he will be added to the team `bottom-group` in GitGuardian.
57+
This means that if a user is in `top-group / middle-group / bottom-group`, he will be added to the team `top-group / middle-group / bottom-group` in GitGuardian.
5758

5859
### Invoking
5960

team-mapping-gitlab-gitguardian/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,18 @@ class Config:
2020
exclude_admin: bool
2121
default_incident_permission: IncidentPermission = IncidentPermission.EDIT
2222
logger_level: str = logging.INFO
23+
remove_members: bool = False
24+
pagination_size: int = 100
2325

2426
@classmethod
2527
def from_env(cls):
2628
incident_permission = cls.default_incident_permission
2729
if incident_permission_env := os.environ.get("DEFAULT_INCIDENT_PERMISSION"):
2830
incident_permission = IncidentPermission(incident_permission_env)
2931

30-
logger_level = logging._nameToLevel(os.environ.get("LOG_LEVEL", "INFO"))
32+
logger_level = logging._nameToLevel[
33+
os.environ.get("LOG_LEVEL", logging._levelToName[cls.logger_level])
34+
]
3135

3236
return cls(
3337
gitlab_token=os.environ["GITLAB_ACCESS_TOKEN"],
@@ -52,7 +56,9 @@ def __repr__(self):
5256
f"send_email={self.send_email}, "
5357
f"gitlab_url={self.gitlab_url}, "
5458
f"gitlab_token={self.gitlab_token}, "
59+
f"logger_level={logging._levelToName[self.logger_level]}, "
5560
f"exclude_admin={self.exclude_admin}, "
61+
f"remove_members={self.remove_members}, "
5662
f"gitguardian_url={self.gitguardian_url}, "
5763
f"gitguardian_api_key={self.gitguardian_api_key}, "
5864
f"default_incident_permission={self.default_incident_permission}"

team-mapping-gitlab-gitguardian/gitguardian_client.py

Lines changed: 167 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
from collections import defaultdict
13
import logging
24

35
from urllib.parse import urlparse, parse_qs
@@ -9,6 +11,7 @@
911
CreateTeamInvitation,
1012
CreateTeamMember,
1113
CreateTeamMemberParameters,
14+
DeleteMemberParameters,
1215
Detail,
1316
IncidentPermission,
1417
Invitation,
@@ -18,13 +21,15 @@
1821
Team,
1922
TeamInvitation,
2023
TeamsParameters,
24+
UpdateMember,
25+
UpdateTeam,
2126
UpdateTeamSource,
2227
)
2328
from pygitguardian.models_utils import (
2429
CursorPaginatedResponse,
2530
PaginationParameter,
2631
)
27-
from typing import Any
32+
from typing import Any, Iterable
2833

2934
from config import CONFIG
3035

@@ -51,7 +56,9 @@ def pagination_max_results(
5156
on all `list` methods
5257
"""
5358

54-
pagination_parameters = parameter_cls(**additional_parameters)
59+
pagination_parameters = parameter_cls(
60+
per_page=CONFIG.pagination_size, **additional_parameters
61+
)
5562
paginated_response: CursorPaginatedResponse | Detail = method(
5663
parameters=pagination_parameters
5764
)
@@ -64,7 +71,9 @@ def pagination_max_results(
6471
next = paginated_response.next
6572

6673
while next and (cursor := get_cursor(next)):
67-
pagination_parameters = parameter_cls(cursor=cursor, **additional_parameters)
74+
pagination_parameters = parameter_cls(
75+
cursor=cursor, per_page=CONFIG.pagination_size, **additional_parameters
76+
)
6877
paginated_response = method(parameters=pagination_parameters)
6978

7079
if isinstance(paginated_response, Detail):
@@ -95,9 +104,10 @@ def list_all_team_members(
95104
)
96105
team_members = pagination_max_results(list_team_members)
97106
for team_member in team_members:
98-
all_team_members[team.name].append(
99-
(team_member.id, id_to_member[team_member.member_id])
100-
)
107+
if team_member.member_id in id_to_member:
108+
all_team_members[team.name].append(
109+
(team_member.id, id_to_member[team_member.member_id])
110+
)
101111

102112
return all_team_members
103113

@@ -113,15 +123,31 @@ def list_team_sources(team: Team) -> list[Source]:
113123
return pagination_max_results(wrapper)
114124

115125

116-
def list_all_teams() -> list[Team]:
126+
def list_all_teams() -> tuple[list[Team], dict[str, list[Team]]]:
117127
"""
118-
Get all teams from GitGuardian
128+
Get syncable teams from GitGuardian and teams by external id
119129
"""
120130

121-
return pagination_max_results(
131+
all_teams: list[Team] = pagination_max_results(
122132
CONFIG.client.list_teams, TeamsParameters, {"is_global": False}
123133
)
124134

135+
sync_teams = []
136+
teams_by_external_id: dict[str, Team] = {}
137+
for team in all_teams:
138+
if team.description is None:
139+
continue
140+
try:
141+
metadata = json.loads(team.description)
142+
external_id = metadata["id"]
143+
144+
teams_by_external_id[external_id] = team
145+
sync_teams.append(team)
146+
except json.JSONDecodeError:
147+
pass
148+
149+
return sync_teams, teams_by_external_id
150+
125151

126152
def list_all_members() -> list[Member]:
127153
"""
@@ -158,15 +184,22 @@ def remove_team_member(team: Team, team_member_id: int):
158184
team_id=team.id, team_member_id=team_member_id
159185
)
160186
if isinstance(response, Detail):
161-
raise RuntimeError(f"Unable to remove team member: {response.content}")
187+
raise RuntimeError(f"Unable to remove team member: {response.detail}")
188+
189+
logger.warning(
190+
f"Successfully removed member {team_member_id} from {team.name}",
191+
)
192+
193+
194+
def remove_team_invitation(team: Team, invitation_id: int, email: str):
195+
response = CONFIG.client.delete_team_invitation(
196+
team_id=team.id, invitation_id=invitation_id
197+
)
198+
if isinstance(response, Detail):
199+
raise RuntimeError(f"Unable to remove team invitation: {response.detail}")
162200

163201
logger.warning(
164-
"Successfully removed team member",
165-
extra=dict(
166-
team_id=team.id,
167-
team_name=team.name,
168-
team_member_id=team_member_id,
169-
),
202+
f"Successfully removed team invitation for {email} from {team.name}",
170203
)
171204

172205

@@ -184,9 +217,12 @@ def send_invitation(
184217
)
185218

186219
if isinstance(response, Detail):
187-
raise RuntimeError(f"Unable to invite member: {response.content}")
220+
if response.status_code == 409:
221+
logger.debug(f"User {member_email} is already invited to the workspace")
222+
else:
223+
raise RuntimeError(f"Unable to invite member: {response.detail}")
188224

189-
logger.info("Successfully invited member", email=member_email)
225+
logger.info(f"Successfully invited member {member_email}")
190226

191227
return response
192228

@@ -209,15 +245,10 @@ def send_team_invitation(
209245
f"User {invitation.email} is already invited to the team ({team.name})"
210246
)
211247
return
212-
raise RuntimeError(f"Unable to invite member to the team: {response.content}")
248+
raise RuntimeError(f"Unable to invite member to the team: {response.detail}")
213249

214250
logger.info(
215-
"Successfully invited member to the team",
216-
extra=dict(
217-
email=invitation.email,
218-
team_id=team.id,
219-
team_name=team.name,
220-
),
251+
f"Successfully invited member {invitation.email} to the team {team.name}",
221252
)
222253

223254
return response
@@ -250,33 +281,58 @@ def add_member_to_team(
250281
)
251282
return
252283
else:
253-
254-
raise RuntimeError(f"Unable to add member to the team: {response.content}")
284+
raise RuntimeError(f"Unable to add member to the team: {response.detail}")
255285

256286
logger.info(
257-
"Successfully added member to the team",
258-
extra=dict(
259-
email=member.email,
260-
team_id=team.id,
261-
team_name=team.name,
262-
),
287+
f"Successfully added member to the team {member.email}",
263288
)
264289

265290

266-
def delete_teams_by_name(all_teams: list[Team], team_names_to_delete: set[str]):
291+
def list_sources_by_team_id(all_teams: Iterable[Team]) -> dict[id, list[Source]]:
292+
"""
293+
Give a list of teams, return all their sources mapped by team id to a list of source
294+
"""
295+
source_by_team_id = defaultdict(list)
296+
for team in all_teams:
297+
team_sources = list_team_sources(team)
298+
source_by_team_id[team.id] = team_sources
299+
300+
return source_by_team_id
301+
302+
303+
def delete_team(team: Team):
304+
response = CONFIG.client.delete_team(team.id)
305+
306+
if isinstance(response, Detail):
307+
raise RuntimeError(f"Unable to delete team {team.name}: {response.detail}")
308+
309+
logger.info(f"Successfully deleted team {team.name}")
310+
311+
312+
def delete_teams_by_name(
313+
all_teams: list[Team],
314+
team_names_to_delete: set[str],
315+
sources_by_team_id: dict[id, list[Source]],
316+
):
267317
"""
268318
Given every team available in GitGuardian, remove teams that are in the set of
269319
team to delete
320+
It will not delete teams that have sources outside of gitlab
270321
"""
271322

272323
to_remove = [team for team in all_teams if team.name in team_names_to_delete]
273324

274325
for team in to_remove:
275-
CONFIG.client.delete_team(team.id)
276-
logger.warning(
277-
"Successfully deleted team",
278-
extra=dict(team_id=team.id, team_name=team.name),
279-
)
326+
team_sources = sources_by_team_id[team.id]
327+
if any(source.type != "gitlab" for source in team_sources):
328+
logger.warning(
329+
f"Cannot delete team {team.name}, it has sources not in gitlab"
330+
)
331+
else:
332+
CONFIG.client.delete_team(team.id)
333+
logger.warning(
334+
f"Successfully deleted team {team.name}",
335+
)
280336

281337

282338
def create_new_teams(teams: set[str]) -> list[Team]:
@@ -290,8 +346,7 @@ def create_new_teams(teams: set[str]) -> list[Team]:
290346
new_teams.append(team)
291347

292348
logger.info(
293-
"Successfully created team",
294-
extra=dict(team_id=team.id, team_name=team.name),
349+
f"Successfully created team {team.name}",
295350
)
296351

297352
return new_teams
@@ -308,14 +363,77 @@ def update_team_source(
308363
response = CONFIG.client.update_team_source(payload)
309364

310365
if isinstance(response, Detail):
311-
raise RuntimeError(f"Unable to update team source: {response.content}")
366+
raise RuntimeError(f"Unable to update team source: {response.detail}")
367+
368+
logger.info(
369+
f"Successfully updated sources for {team.name}",
370+
)
371+
372+
373+
def delete_invitation(invitation: Invitation):
374+
response = CONFIG.client.delete_invitation(invitation.id)
375+
376+
if isinstance(response, Detail):
377+
raise RuntimeError(f"Unable to delete invitation: {response.detail}")
312378

313379
logger.info(
314-
"Successfully updated team sources",
315-
extra=dict(
316-
team_id=team.id,
317-
team_name=team.name,
318-
sources_added=sources_to_add,
319-
sources_removed=sources_to_remove,
320-
),
380+
f"Successfully deleted invitation for {invitation.email}",
321381
)
382+
383+
384+
def delete_invitations(invitations: Iterable[Invitation]):
385+
for invitation in invitations:
386+
delete_invitation(invitation)
387+
388+
389+
def delete_member(member: Member):
390+
"""
391+
Delete a member from the workspace
392+
"""
393+
394+
if not CONFIG.remove_members:
395+
logger.debug("Removing members is disabled, skipping...")
396+
return
397+
398+
payload = DeleteMemberParameters(member.id, send_email=CONFIG.send_email)
399+
response = CONFIG.client.delete_member(payload)
400+
401+
if isinstance(response, Detail):
402+
logger.error(f"Unable to delete member : {member.email}")
403+
else:
404+
logger.info(f"Successfully deleted member {member.email}")
405+
406+
407+
def deactivate_member(member: Member):
408+
"""
409+
Deactivate a member from the workspace
410+
"""
411+
412+
response = CONFIG.client.update_member(
413+
UpdateMember(member.id, active=False, access_level=AccessLevel.RESTRICTED)
414+
)
415+
416+
if isinstance(response, Detail):
417+
logger.error(f"Unable to deactivate member : {member.email}")
418+
else:
419+
logger.info(f"Successfully deactivated member {member.email}")
420+
421+
422+
def remove_members(members_to_delete: Iterable[Member]):
423+
"""
424+
Delete or deactive members from the workspace depending on CONFIG
425+
"""
426+
427+
if not CONFIG.remove_members:
428+
logger.debug("Removing members is disabled, skipping...")
429+
return
430+
431+
for member in members_to_delete:
432+
delete_member(member)
433+
434+
435+
def list_team_invitations(team: Team) -> list[TeamInvitation]:
436+
wrapper = lambda parameters: CONFIG.client.list_team_invitations(
437+
team.id, parameters=parameters
438+
)
439+
return pagination_max_results(wrapper)

0 commit comments

Comments
 (0)