Skip to content
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
6 changes: 3 additions & 3 deletions apps/docs/content/docs/search/gitlab.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,17 @@ Saving the CSVs grants the access listed in them. Changing a membership in GitLa

Download [export-permissions.py](/static/examples/gitlab/export-permissions.py) and save it as `ci/export-gitlab-permissions.py` in your repository. It uses Python 3.10 or later with no extra packages and exports one project per run.

Use GitLab 17.4 or later and an internal export token with `read_api` and permission to see all members of the target project and their primary emails. Keep this token in a masked, protected CI variable named `GITLAB_EXPORT_TOKEN`. It stays in your CI job; Sim receives the CSVs. The source's separate read-only PAT continues to retrieve content.
Use GitLab 17.4 or later and an internal export token with `read_api` and permission to see all members of the target project, their primary emails, and confirmation status. Keep this token in a masked, protected CI variable named `GITLAB_EXPORT_TOKEN`. It stays in your CI job; Sim receives the CSVs. The source's separate read-only PAT continues to retrieve content.

The exporter makes only these GitLab API requests:

| Endpoint | Purpose |
| --- | --- |
| `GET /api/v4/projects/:id` | Resolve the numeric ID and full project path. |
| `GET /api/v4/projects/:id/members/all` | Page through visible direct, inherited, and invited-group members, 100 at a time. |
| `GET /api/v4/users/:id` | Read each eligible member's account state and primary email. |
| `GET /api/v4/users/:id` | Read each eligible member's account state, email confirmation, and primary email. |

GitLab documents [membership visibility](https://docs.gitlab.com/api/project_members/#list-all-members-of-a-project) and [user profile fields](https://docs.gitlab.com/api/users/#retrieve-a-single-user). The `read_api` scope alone does not grant access to private emails. The export token needs that directory permission too. If a primary email is unavailable, the script stops; it never substitutes a public email. Confirm the exported emails match your teammates' verified Sim emails.
GitLab documents [membership visibility](https://docs.gitlab.com/api/project_members/#list-all-members-of-a-project) and [user profile fields](https://docs.gitlab.com/api/users/#retrieve-a-single-user). The `read_api` scope alone does not grant access to private emails. The export token needs that directory permission too. Profiles without a valid `confirmed_at` timestamp are excluded. If an eligible reader's primary email is unavailable, the script stops; it never substitutes a public email. Confirm the exported emails match your teammates' verified Sim emails.

With `GITLAB_EXPORT_TOKEN` set in the environment, run:

Expand Down
7 changes: 7 additions & 0 deletions apps/docs/public/static/examples/gitlab/export-permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ def export(client, project):
raise ExportError("GitLab returned a mismatched user identity.")
if not active(profile) or profile.get("bot", False):
continue
confirmed_at = profile.get("confirmed_at")
if not isinstance(confirmed_at, str):
continue
try:
datetime.fromisoformat(confirmed_at.replace("Z", "+00:00"))
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
except ValueError:
continue
email = profile.get("email")
if not isinstance(email, str):
raise ExportError(
Expand Down
13 changes: 12 additions & 1 deletion apps/docs/scripts/test-gitlab-export-e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,14 @@ def do_GET(self):
if scenario == "duplicate-email" and user_id == 2:
email = "alice@example.com"
profile = {"id": user_id, "state": "active", "locked": user_id == 9,
"bot": user_id == 5, "email": email}
"bot": user_id == 5, "email": email, "confirmed_at": "2025-01-01T00:00:00Z"}
if user_id == 2:
if scenario == "unconfirmed-email":
profile["confirmed_at"] = None
elif scenario == "missing-confirmation":
profile.pop("confirmed_at")
elif scenario == "invalid-confirmation":
profile["confirmed_at"] = "not-a-timestamp"
if scenario == "hidden-email":
profile.pop("email")
profile["public_email"] = f"public-alias-{user_id}@example.com"
Expand Down Expand Up @@ -121,6 +128,8 @@ def run():
for scenario, expected_error in [
("success", None), ("numeric-project", None), ("missing-pagination", None),
("issues-only", None), ("paced", None),
("unconfirmed-email", None), ("missing-confirmation", None),
("invalid-confirmation", None),
("truncated-membership", "total"), ("changing-total", "changed"),
("hidden-email", "email"), ("duplicate-email", "email"),
("wrong-identity", "identity"), ("denied", "403"),
Expand Down Expand Up @@ -157,6 +166,8 @@ def run():
expected = [["7", "carol@example.com"]] if scenario == "missing-pagination" else [
["1", "alice@example.com"], ["2", "bob@example.com"],
["7", "carol@example.com"]]
if scenario in ("unconfirmed-email", "missing-confirmation", "invalid-confirmation"):
expected = [["1", "alice@example.com"], ["7", "carol@example.com"]]
assert users == [["user_id", "email"], *expected], users
assert grants == [["project_path", "user_id"], *[
["engineering/platform", row[0]] for row in expected]], grants
Expand Down
Loading