Skip to content

Commit 6c04fb8

Browse files
committed
fix(gitlab): require confirmed emails in permission exports
1 parent 364d1f4 commit 6c04fb8

3 files changed

Lines changed: 22 additions & 4 deletions

File tree

‎apps/docs/content/docs/search/gitlab.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,17 @@ Saving the CSVs grants the access listed in them. Changing a membership in GitLa
7777

7878
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.
7979

80-
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.
80+
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.
8181

8282
The exporter makes only these GitLab API requests:
8383

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

90-
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.
90+
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.
9191

9292
With `GITLAB_EXPORT_TOKEN` set in the environment, run:
9393

‎apps/docs/public/static/examples/gitlab/export-permissions.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ def export(client, project):
140140
raise ExportError("GitLab returned a mismatched user identity.")
141141
if not active(profile) or profile.get("bot", False):
142142
continue
143+
confirmed_at = profile.get("confirmed_at")
144+
if not isinstance(confirmed_at, str):
145+
continue
146+
try:
147+
datetime.fromisoformat(confirmed_at.replace("Z", "+00:00"))
148+
except ValueError:
149+
continue
143150
email = profile.get("email")
144151
if not isinstance(email, str):
145152
raise ExportError(

‎apps/docs/scripts/test-gitlab-export-e2e.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,14 @@ def do_GET(self):
7878
if scenario == "duplicate-email" and user_id == 2:
7979
email = "alice@example.com"
8080
profile = {"id": user_id, "state": "active", "locked": user_id == 9,
81-
"bot": user_id == 5, "email": email}
81+
"bot": user_id == 5, "email": email, "confirmed_at": "2025-01-01T00:00:00Z"}
82+
if user_id == 2:
83+
if scenario == "unconfirmed-email":
84+
profile["confirmed_at"] = None
85+
elif scenario == "missing-confirmation":
86+
profile.pop("confirmed_at")
87+
elif scenario == "invalid-confirmation":
88+
profile["confirmed_at"] = "not-a-timestamp"
8289
if scenario == "hidden-email":
8390
profile.pop("email")
8491
profile["public_email"] = f"public-alias-{user_id}@example.com"
@@ -121,6 +128,8 @@ def run():
121128
for scenario, expected_error in [
122129
("success", None), ("numeric-project", None), ("missing-pagination", None),
123130
("issues-only", None), ("paced", None),
131+
("unconfirmed-email", None), ("missing-confirmation", None),
132+
("invalid-confirmation", None),
124133
("truncated-membership", "total"), ("changing-total", "changed"),
125134
("hidden-email", "email"), ("duplicate-email", "email"),
126135
("wrong-identity", "identity"), ("denied", "403"),
@@ -157,6 +166,8 @@ def run():
157166
expected = [["7", "carol@example.com"]] if scenario == "missing-pagination" else [
158167
["1", "alice@example.com"], ["2", "bob@example.com"],
159168
["7", "carol@example.com"]]
169+
if scenario in ("unconfirmed-email", "missing-confirmation", "invalid-confirmation"):
170+
expected = [["1", "alice@example.com"], ["7", "carol@example.com"]]
160171
assert users == [["user_id", "email"], *expected], users
161172
assert grants == [["project_path", "user_id"], *[
162173
["engineering/platform", row[0]] for row in expected]], grants

0 commit comments

Comments
 (0)