Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NOISSUE): added script to remove directly added members that are part of a group in GitLab #19

Merged
merged 3 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Python bytecode files
__pycache__/
*.py[cod]
*$py.class

# Virtual environment
venv/
ENV/
env/
.venv/
env.bak/
lib/
bin/

# pytest cache
.pytest_cache/

# MyPy
.mypy_cache/

# Jupyter Notebook
.ipynb_checkpoints/

# pyenv
.python-version

# Pylint
.pylint.d/

# Coverage reports
.coverage
.coverage.*
*.cover
*.py,cover
nosetests.xml
coverage.xml

# dotenv
.env
.env.*

# Virtual environment tools
pip-log.txt
pip-delete-this-directory.txt

# Distribution / packaging
*.egg-info/
dist/
build/
*.egg
*.eggs
*.pyc

# Logs
*.log
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ A collection of various helper scripts that I use.
* [Query LogSeq for outdated pages](query_logsec_for_outdated_pages/README.md)
* [Brew cask and adopt for manually installed applications](brew_cask_and_adopt_manual_installed_applications/README.md)
* [Receive Ex-Dividend Dates for stocks](stock_dividend_tracker/README.md)
* [List GitLab Pipeline schedules for a specific user](gitlab_pipeline_schedules/README.md)
* [Remove directly added members which are also group members in GitLab](gitlab_remove_doubleton_members/README.md)

Note: Have a look at the README files in the specific subdirectories for documentation.

Expand Down
67 changes: 67 additions & 0 deletions gitlab_remove_doubleton_members/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# GitLab Direct Members Cleanup Script

This script removes direct members from GitLab repositories that are part of a specified group. It efficiently handles paginated API responses and processes repositories, including those in subgroups.

## Description

The script connects to a GitLab instance, retrieves all projects within a specified group (and its subgroups), and removes direct members who are also part of the group. This is useful for maintaining clean and consistent access control across repositories by ensuring that members are not directly added if they are already covered by group membership.

## Use Cases

- **Clean Up Direct Members**: Ensure that members who are already part of a group do not have redundant direct access to repositories.
- **Maintain Access Control**: Automatically manage repository permissions based on group memberships.
- **Audit Memberships**: Verify that repository access aligns with group memberships.

## Requirements

- Python 3
- `gitlab` library
- `urllib3` library
- `colorama` library (for colored output)

## Usage

The script can be executed from the command line. You need to provide the GitLab URL, an access token with API scope, and the group ID or path. You can also specify a list of repositories to limit the scope and use a dry-run option to preview changes without making modifications.

```sh
usage: gitlab_remove_doubleton_members.py [-h] [-u GITLAB_URL] -t ACCESS_TOKEN -g GROUP_ID [--dry-run] [--repo-scope REPO_SCOPE [REPO_SCOPE ...]]

Remove direct members from repositories that are part of a specified GitLab group.

options:
-h, --help show this help message and exit
-u GITLAB_URL, --gitlab-url GITLAB_URL
GitLab base URL (default: https://gitlab.com)
-t ACCESS_TOKEN, --access-token ACCESS_TOKEN
GitLab access token (API scope)
-g GROUP_ID, --group-id GROUP_ID
The group ID or path for which to clean up repositories
--dry-run If set, just print members that would be removed
--repo-scope REPO_SCOPE [REPO_SCOPE ...]
Optional list of repository names to limit scope
```

## Example

To run the script and remove direct members from repositories within a group, use:

```sh
python gitlab_remove_doubleton_members.py -u https://gitlab.example.com -t your_access_token -g your_group
```
## Example Output

```sh
Fetching members of group 1765555
Fetching repositories for group 1765555
Fetching projects from subgroup Projects (ID: 17899)
Fetching projects from subgroup Automation (ID: 17900)
Fetching projects from subgroup Linting (ID: 17901)
Processing repository Automation Templates (ID: 1304)
Processing repository Linting Boilerplate (ID: 1513)
Processing repository Project Templates (ID: 19951)
Dry-run: Would remove member $member from repository Linting (https://gitlab.example.com/projects/linting/-/project_members)
```

## License

This project is licensed under the MIT License - see the LICENSE file for details.
177 changes: 177 additions & 0 deletions gitlab_remove_doubleton_members/gitlab_remove_doubleton_members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env python3

"""
A script to remove direct members from GitLab repositories that are part of a specified group.
It efficiently handles paginated API responses and processes repositories,
including those in subgroups.
"""

import argparse
import logging
import warnings
import gitlab
from urllib3.exceptions import InsecureRequestWarning
from colorama import Fore, Style

# Suppress the InsecureRequestWarning from urllib3 because you will get
# a error in certain infrastructures..
warnings.simplefilter("ignore", InsecureRequestWarning)

# Configure logging
logging.basicConfig(level=logging.INFO, format="%(message)s")


def get_paginated_data(get_function, **kwargs):
"""Helper function to handle paginated API results."""
all_data = []
page = 1
while True:
data = get_function(page=page, per_page=100, **kwargs)
if not data:
break
all_data.extend(data)
page += 1
return all_data


def get_group_members(gl, group_id):
"""Get all members of a group, including inherited members."""
group = gl.groups.get(group_id)
return get_paginated_data(group.members.list)


def get_repo_members(gl, repo_id):
"""Get all direct members of a repository (project)."""
project = gl.projects.get(repo_id)
return get_paginated_data(project.members.list)


def get_group_projects(gl, group_id):
"""Get all projects in a group, including those in subgroups."""
group = gl.groups.get(group_id)
all_projects = get_paginated_data(group.projects.list)

# Recursively get projects from subgroups
subgroups = get_paginated_data(group.subgroups.list)
for subgroup in subgroups:
logging.info(
"Fetching projects from subgroup %s (ID: %s)", subgroup.name, subgroup.id
)
all_projects.extend(get_group_projects(gl, subgroup.id))

return all_projects


def remove_direct_members(gl, group_id, dry_run, repo_scope=None):
"""Remove direct members of repositories that are part of the group."""
# Get all members of the group
logging.info("Fetching members of group %s", group_id)
group_members = get_group_members(gl, group_id)
group_member_ids = {member.id for member in group_members}

# Get all repositories in the group, including subgroups
logging.info("Fetching repositories for group %s", group_id)
projects = get_group_projects(gl, group_id)

# Filter repositories if scope is provided
if repo_scope:
projects = [project for project in projects if project.name in repo_scope]

# Ensure that we have a valid list of projects
if not projects:
logging.info("No repositories found for group %s", group_id)
return

# Loop through each repository
for project in projects:
logging.info("Processing repository %s (ID: %s)", project.name, project.id)

# Get direct members of the project
repo_members = get_repo_members(gl, project.id)

# Construct the URL for the members tab of the project
project_url = f"{gl.url}/{project.path_with_namespace}/-/project_members"

for member in repo_members:
if member.id in group_member_ids:
# Direct member found in the group
if dry_run:
# Use lazy formatting, add color, and include the project URL
logging.info(
Fore.YELLOW
+ "Dry-run: Would remove member %s from repository %s (%s)"
+ Style.RESET_ALL,
member.username,
project.name,
project_url,
)
else:
try:
logging.info(
"Removing member %s from repository %s",
member.username,
project.name,
)
project.members.delete(member.id)
except gitlab.exceptions.GitlabDeleteError as e:
logging.error(
"Failed to remove %s from %s: %s",
member.username,
project.name,
e,
)


def main():
"""Main function to parse arguments and clean up repository members in a GitLab group.

This function handles the following steps:
1. Parses command-line arguments.
2. Connects to the GitLab instance using the provided URL and access token.
3. Fetches and processes the group and its repositories.
4. Optionally performs a dry-run or removes direct members from repositories
based on the arguments.

Command-line arguments:
gitlab_url (str): The base URL of the GitLab instance.
access_token (str): GitLab personal access token.
group_id (int or str): The ID or path of the group to clean up.
--dry-run (optional): If set, print members that would be removed without making changes.
--repo-scope (optional): List of repositories to limit the scope to certain repositories.

Returns:
None
"""
# Parse command line arguments
parser = argparse.ArgumentParser(
description="GitLab group repository member cleanup script"
)
parser.add_argument("gitlab_url", help="The base URL of the GitLab instance")
parser.add_argument("access_token", help="GitLab personal access token")
parser.add_argument(
"group_id", help="The group ID or path for which to clean up repositories"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="If set, just print members that would be removed",
)
parser.add_argument(
"--repo-scope",
nargs="*",
help="Optional list of repository names to limit scope",
)

args = parser.parse_args()

# Initialize GitLab connection
gl = gitlab.Gitlab(
args.gitlab_url, private_token=args.access_token, ssl_verify=False
)

# Run the member cleanup process
remove_direct_members(gl, args.group_id, args.dry_run, args.repo_scope)


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions gitlab_remove_doubleton_members/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
python-gitlab==4.11.1
urllib3==2.2.3
colorama==0.4.6