Skip to content

Commit fb9d890

Browse files
committed
feat(gitlab): add permission CSV exporter
1 parent 4a5b2c6 commit fb9d890

5 files changed

Lines changed: 464 additions & 11 deletions

File tree

‎.github/workflows/docs-negotiation.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ jobs:
268268
DOCS_GENERATORS_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/generators/report.json
269269
DOCS_COMPATIBILITY_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/compatibility.json
270270
DOCS_HEADER_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/docs-headers.json
271+
GITLAB_EXPORT_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/gitlab-export.json
271272
steps:
272273
- name: Checkout code
273274
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
@@ -292,6 +293,8 @@ jobs:
292293
- name: Verify native header edge cases
293294
if: ${{ !cancelled() && steps.generators.outcome != 'skipped' }}
294295
run: bun run --cwd apps/docs test:headers:e2e
296+
- name: Verify GitLab CSV export over HTTPS
297+
run: python3 apps/docs/scripts/test-gitlab-export-e2e.py
295298
- name: Bound the generated content cache
296299
if: always()
297300
run: bash .github/actions/docs-content-cache/prune.sh content

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

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,37 @@ In this example, Alice can search `engineering/platform` because the files link
7373

7474
Saving the CSVs grants the access listed in them. Changing a membership in GitLab alone does not change these grants. Replace the CSVs to add or revoke access; the change applies to the next search or read without re-indexing.
7575

76+
### Export both CSVs from GitLab
77+
78+
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.
79+
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.
81+
82+
The exporter makes only these GitLab API requests:
83+
84+
| Endpoint | Purpose |
85+
| --- | --- |
86+
| `GET /api/v4/projects/:id` | Resolve the numeric ID and full project path. |
87+
| `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. |
89+
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.
91+
92+
With `GITLAB_EXPORT_TOKEN` set in the environment, run:
93+
94+
```bash
95+
python3 ci/export-gitlab-permissions.py \
96+
--gitlab-url https://gitlab.example.com \
97+
--project engineering/platform \
98+
--output-dir gitlab-permissions
99+
```
100+
101+
This creates `gitlab-permissions/users.csv` and `gitlab-permissions/project-permissions.csv`. Numeric project IDs are also accepted. The output directory must not already exist. Both files are published together after the export completes and passes validation.
102+
103+
The export includes active, unlocked human members with Reporter access or higher. It excludes Guests, Planners, bots, and memberships expiring today or earlier in UTC. It does not infer access from public/internal visibility, administrator or Auditor privileges, or custom permissions below Reporter. GitLab policies such as SSO or external authorization are not represented by these CSVs. Review the grants against your organization's access requirements before uploading them.
104+
105+
Requests run serially, paced at 60 per minute by default. Set `--requests-per-minute` to change this export-job ceiling; it does not configure live Search traffic. Each run uses one project request, one request per membership page, and one profile request per candidate reader. API errors, hidden emails, identity conflicts, and oversized exports stop the job without publishing either file.
106+
76107
## Access checks
77108

78109
The source token retrieves content. Administrator mode proves the reader's current GitLab identity from verified Sim email, then checks project/group membership and policy during the request. Unsupported restrictions fail closed.
@@ -93,7 +124,7 @@ After an admin configures a source in **Non-admin token** mode, a job can [read
93124

94125
Store an organization admin's [personal Sim API key](/api-reference/authentication) with [**Full access**](/api-reference/authentication#key-access) as a [masked, protected GitLab CI variable](https://docs.gitlab.com/ci/variables/) named `SIM_API_KEY`. A personal key inherits its owner's access; a read-only key can read the revision but not replace the CSVs. This endpoint can only read the revision or replace both CSVs; it cannot change the GitLab PAT, host, or project.
95126

96-
This example runs on a schedule against a protected branch. Replace `./ci/export-gitlab-permissions` with your existing export command. It must write a fresh, complete `users.csv` and `project-permissions.csv` and exit nonzero if it cannot finish.
127+
Commit [the exporter above](#export-both-csvs-from-gitlab) at `ci/export-gitlab-permissions.py`. Set `GITLAB_EXPORT_TOKEN`, `GITLAB_PROJECT` (full path or numeric ID), and the Sim variables above. This example uses `CI_SERVER_URL` for the GitLab host and runs on a schedule against a protected branch. Choose the schedule based on how quickly permission changes must reach Sim.
97128

98129
```yaml title=".gitlab-ci.yml"
99130
sync_gitlab_permissions:
@@ -102,22 +133,28 @@ sync_gitlab_permissions:
102133
rules:
103134
- if: '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_REF_PROTECTED == "true"'
104135
before_script:
105-
- apk add --no-cache curl jq
136+
- apk add --no-cache ca-certificates curl jq python3
106137
script:
107138
- |
108139
set -eu
140+
umask 077
141+
export_root=$(mktemp -d)
142+
trap 'rm -rf "$export_root"' EXIT
109143
url="${SIM_BASE_URL%/}/api/v2/organizations/$SIM_ORG_ID/search/sources/$SIM_SOURCE_ID/permissions"
110144
auth="X-API-Key: $SIM_API_KEY"
111145
current=$(curl --fail --silent --show-error -H "$auth" "$url")
112146
revision=$(printf '%s' "$current" | jq -er '.data.revision')
113-
./ci/export-gitlab-permissions
147+
python3 ci/export-gitlab-permissions.py \
148+
--gitlab-url "$CI_SERVER_URL" --project "$GITLAB_PROJECT" \
149+
--output-dir "$export_root/permissions" --requests-per-minute 60
114150
jq -n --argjson rev "$revision" \
115-
--rawfile users users.csv --rawfile grants project-permissions.csv \
151+
--rawfile users "$export_root/permissions/users.csv" \
152+
--rawfile grants "$export_root/permissions/project-permissions.csv" \
116153
'{expectedRevision: $rev, permissions: {provider: "gitlab",
117154
userMapping: {filename: "users.csv", content: $users},
118-
projectPermissions: {filename: "project-permissions.csv", content: $grants}}}' > update.json
155+
projectPermissions: {filename: "project-permissions.csv", content: $grants}}}' > "$export_root/update.json"
119156
curl --fail --silent --show-error --request PUT -H "$auth" \
120-
-H 'Content-Type: application/json' --data-binary @update.json "$url"
157+
-H 'Content-Type: application/json' --data-binary "@$export_root/update.json" "$url"
121158
```
122159
123160
A successful update returns the new revision. A `409 Conflict` means someone updated the source after the job read its revision. Rerun the job from the beginning so it exports current grants. An invalid or header-only export leaves the previous grants in place. Alert on failed jobs so revocations aren't missed. If the project should have no readers, choose **Pause search** in the source settings. You can update the grants while the source is paused, then resume search when the grants are correct.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#!/usr/bin/env python3
2+
"""Export one GitLab project's readers to Sim's permission CSVs (Python 3.10+)."""
3+
4+
import argparse
5+
import csv
6+
from datetime import date, datetime, timezone
7+
import io
8+
import json
9+
import os
10+
from pathlib import Path
11+
import re
12+
import ssl
13+
import sys
14+
import tempfile
15+
import time
16+
from urllib.error import HTTPError, URLError
17+
from urllib.parse import quote, urlencode, urlsplit
18+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
19+
20+
21+
MAX_BYTES = 4 * 1024 * 1024
22+
MAX_ROWS = 100_000
23+
EMAIL = re.compile(
24+
r"[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@"
25+
r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
26+
r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
27+
)
28+
29+
30+
class ExportError(Exception):
31+
"""An incomplete or unsafe export must not be uploaded."""
32+
33+
34+
class NoRedirects(HTTPRedirectHandler):
35+
def redirect_request(self, req, fp, code, msg, headers, newurl):
36+
# A redirect must never forward the private token to another host.
37+
return None
38+
39+
40+
class GitLab:
41+
def __init__(self, base_url, token, requests_per_minute):
42+
parsed = urlsplit(base_url)
43+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username
44+
or parsed.password or parsed.query or parsed.fragment
45+
or any(ord(char) < 33 for char in base_url)):
46+
raise ExportError("Use an HTTPS GitLab instance URL without credentials, query, or fragment.")
47+
self.base = base_url.rstrip("/") + "/api/v4/"
48+
self.token = token
49+
self.interval = 60 / requests_per_minute
50+
self.last_request = 0.0
51+
self.opener = build_opener(NoRedirects(), HTTPSHandler(context=ssl.create_default_context()))
52+
53+
def get(self, path, query=None):
54+
delay = self.interval - (time.monotonic() - self.last_request)
55+
if delay > 0:
56+
time.sleep(delay)
57+
url = self.base + path + ("?" + urlencode(query) if query else "")
58+
request = Request(url, headers={"PRIVATE-TOKEN": self.token, "Accept": "application/json"})
59+
self.last_request = time.monotonic()
60+
try:
61+
with self.opener.open(request, timeout=30) as response:
62+
raw = response.read(8 * 1024 * 1024 + 1)
63+
headers = response.headers
64+
except HTTPError as error:
65+
raise ExportError(f"GitLab GET {path} failed (HTTP {error.code}); no export published.") from None
66+
except (URLError, TimeoutError, OSError):
67+
raise ExportError("GitLab connection failed. Check the host, HTTPS trust, and network access.") from None
68+
if len(raw) > 8 * 1024 * 1024:
69+
raise ExportError("GitLab response exceeds the 8 MiB limit.")
70+
try:
71+
return json.loads(raw), headers
72+
except (ValueError, UnicodeError):
73+
raise ExportError("GitLab returned invalid JSON.") from None
74+
75+
76+
def identifier(value):
77+
if type(value) is not int or not 0 < value <= 9_007_199_254_740_991:
78+
raise ExportError("GitLab returned an invalid numeric identity.")
79+
return value
80+
81+
82+
def active(record):
83+
if not isinstance(record, dict) or not isinstance(record.get("state"), str):
84+
raise ExportError("GitLab returned a user without an account state.")
85+
return record["state"] == "active" and not record.get("locked", False)
86+
87+
88+
def export(client, project):
89+
record, _ = client.get("projects/" + quote(project, safe=""))
90+
if not isinstance(record, dict):
91+
raise ExportError("GitLab returned an invalid project.")
92+
project_id = identifier(record.get("id"))
93+
project_path = record.get("path_with_namespace")
94+
if (not isinstance(project_path, str) or len(project_path) > 1024
95+
or "/" not in project_path
96+
or any(not part or part in (".", "..")
97+
or not all(char.isalnum() or char in "_.-" for char in part)
98+
for part in project_path.split("/"))):
99+
raise ExportError("GitLab returned an invalid project path.")
100+
if record.get("repository_access_level") == "disabled":
101+
raise ExportError("The project's repository is disabled.")
102+
users, emails, seen_members = {}, {}, set()
103+
today = datetime.now(timezone.utc).date()
104+
page = 1
105+
while page <= 1000:
106+
members, headers = client.get(f"projects/{project_id}/members/all",
107+
{"per_page": 100, "page": page})
108+
if not isinstance(members, list) or len(members) > 100:
109+
raise ExportError("GitLab returned an invalid membership page.")
110+
for member in members:
111+
if not isinstance(member, dict):
112+
raise ExportError("GitLab returned an invalid membership.")
113+
user_id = identifier(member.get("id"))
114+
if user_id in seen_members:
115+
raise ExportError("GitLab membership pagination repeated a user. Rerun the export.")
116+
seen_members.add(user_id)
117+
if len(seen_members) > MAX_ROWS:
118+
raise ExportError("Project membership exceeds the 100,000-row limit.")
119+
role = member.get("access_level")
120+
if type(role) is not int:
121+
raise ExportError("GitLab returned a membership without an access level.")
122+
if not active(member) or role < 20:
123+
continue
124+
expiry = member.get("expires_at")
125+
if expiry is not None:
126+
try:
127+
expired = date.fromisoformat(expiry) <= today
128+
except (TypeError, ValueError):
129+
raise ExportError("GitLab returned an invalid membership expiry.") from None
130+
if expired:
131+
continue
132+
profile, _ = client.get(f"users/{user_id}")
133+
if not isinstance(profile, dict) or identifier(profile.get("id")) != user_id:
134+
raise ExportError("GitLab returned a mismatched user identity.")
135+
if not active(profile) or profile.get("bot", False):
136+
continue
137+
email = profile.get("email")
138+
if not isinstance(email, str):
139+
raise ExportError(
140+
f"User {user_id}: primary email is unavailable. The CI token needs directory email access."
141+
)
142+
email = email.strip().lower()
143+
if len(email) > 254 or not EMAIL.fullmatch(email):
144+
raise ExportError(f"User {user_id}: primary email is invalid.")
145+
if email in emails and emails[email] != user_id:
146+
raise ExportError("Multiple GitLab users share an email. Resolve the identity conflict first.")
147+
emails[email] = user_id
148+
users[user_id] = email
149+
next_page = headers.get("X-Next-Page")
150+
if next_page is None:
151+
next_page = str(page + 1) if len(members) == 100 else ""
152+
if not next_page:
153+
break
154+
if not re.fullmatch(r"[0-9]+", next_page) or int(next_page) != page + 1 or not members:
155+
raise ExportError("GitLab returned invalid membership pagination.")
156+
page = int(next_page)
157+
else:
158+
raise ExportError("GitLab membership pagination exceeds 1,000 pages.")
159+
if not users:
160+
raise ExportError("No eligible readers. Pause search in Sim; empty CSVs cannot revoke all grants.")
161+
return project_path, users
162+
163+
164+
def write_export(output, project_path, users):
165+
files = {}
166+
ordered = sorted(users)
167+
for name, header, rows in [
168+
("users.csv", ["user_id", "email"], [(user_id, users[user_id]) for user_id in ordered]),
169+
("project-permissions.csv", ["project_path", "user_id"],
170+
[(project_path, user_id) for user_id in ordered]),
171+
]:
172+
buffer = io.StringIO(newline="")
173+
writer = csv.writer(buffer, lineterminator="\n")
174+
writer.writerow(header)
175+
writer.writerows(rows)
176+
files[name] = buffer.getvalue().encode("utf-8")
177+
if len(files[name]) > MAX_BYTES:
178+
raise ExportError(f"{name} exceeds Sim's 4 MiB CSV limit.")
179+
output.parent.mkdir(parents=True, exist_ok=True)
180+
# Publish the pair together, so an interrupted export cannot mix new and old CSVs.
181+
with tempfile.TemporaryDirectory(dir=output.parent, prefix=".gitlab-export-") as temporary:
182+
for name, content in files.items():
183+
path = Path(temporary) / name
184+
path.write_bytes(content)
185+
path.chmod(0o600)
186+
os.rename(temporary, output)
187+
188+
189+
def main():
190+
parser = argparse.ArgumentParser(description=__doc__)
191+
parser.add_argument("--gitlab-url", required=True, help="HTTPS instance URL, without /api/v4")
192+
parser.add_argument("--project", required=True, help="Project ID or full namespace/project path")
193+
parser.add_argument("--output-dir", default="gitlab-permissions", help="New directory for both CSVs")
194+
parser.add_argument("--requests-per-minute", type=int, default=60, help="Request-start ceiling (default: 60)")
195+
args = parser.parse_args()
196+
token = os.environ.get("GITLAB_EXPORT_TOKEN", "")
197+
if not token or any(char.isspace() for char in token):
198+
parser.error("Set GITLAB_EXPORT_TOKEN to the CI export token.")
199+
if args.requests_per_minute <= 0:
200+
parser.error("--requests-per-minute must be positive.")
201+
output = Path(args.output_dir).absolute()
202+
if output.exists() or output.is_symlink():
203+
raise ExportError("Output directory already exists. Choose a new directory for a fresh export.")
204+
client = GitLab(args.gitlab_url, token, args.requests_per_minute)
205+
project_path, users = export(client, args.project)
206+
write_export(output, project_path, users)
207+
print(f"Exported {len(users)} readers to {output}/users.csv and {output}/project-permissions.csv")
208+
209+
210+
if __name__ == "__main__":
211+
try:
212+
main()
213+
except (ExportError, OSError) as error:
214+
message = str(error) if isinstance(error, ExportError) else "Could not publish the CSV files."
215+
print(f"Export failed: {message}", file=sys.stderr)
216+
sys.exit(1)

‎apps/docs/scripts/fixtures/content-baseline.json‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177203,21 +177203,28 @@
177203177203
"meta": null,
177204177204
"sha256": "68cb9b0d31751b48e06f8d452f15821f1e68ee3816f17638a88294587a58908c",
177205177205
"bytes": 55,
177206-
"line": 48
177206+
"line": 52
177207177207
},
177208177208
{
177209177209
"lang": "csv",
177210177210
"meta": null,
177211177211
"sha256": "5c2ebfcc044ebc01c17a4af2664a4fbc667ae4916ef21555d90cfd9f245394bd",
177212177212
"bytes": 70,
177213-
"line": 56
177213+
"line": 60
177214+
},
177215+
{
177216+
"lang": "bash",
177217+
"meta": null,
177218+
"sha256": "473dea567e9f48d8b1fa1dded81bf8a30ce177ea12338164f05d3fd015570637",
177219+
"bytes": 154,
177220+
"line": 94
177214177221
},
177215177222
{
177216177223
"lang": "yaml",
177217177224
"meta": "title=\".gitlab-ci.yml\"",
177218-
"sha256": "bf3d6e7bec6b87f3966580713633eb8baed778c9dced4569a65cc62d790dee42",
177219-
"bytes": 1084,
177220-
"line": 98
177225+
"sha256": "29261e0a66aac3cf52e7b9164aaa44f235765bcfd2cae4e68d744d31b29b57bf",
177226+
"bytes": 1441,
177227+
"line": 129
177221177228
}
177222177229
],
177223177230
"links": [

0 commit comments

Comments
 (0)