GitLab
Proxy for the GitLab REST API via the managed gateway.
What this connector does
Gives agents gated access to a GitLab instance (SaaS or self-hosted) for:
- Listing users, projects, merge requests.
- Creating and editing merge requests.
- Commenting on merge requests.
- Minting project access tokens.
- Listing container-registry repositories and tags.
All operations are gated by PBAC policy. Every call flows: agent → gateway → /introspect (with this connector's resource_type) → OPA decision → GitLab API → response.
The connector supports two auth modes, chosen per managed resource at install time:
- Per-user passthrough (
idp_passthrough, recommended) — the gateway forwards each calling user's own GitLab OAuth token (captured when they log in to PolicyArc through the GitLab IdP). GitLab enforces its own project membership and permissions, and its audit trail shows the real person. - Shared service token (
static) — every call uses one operator-held personal/project/group access token via thePRIVATE-TOKENheader. PolicyArc still enforces per-user policy and audit in front, but GitLab sees a single service identity.
Prerequisites
- The base URL of your GitLab instance (defaults to
https://gitlab.com). - Passthrough mode only: a GitLab IdP registered in PolicyArc with
provider key
gitlab(use the GitLab preset in the IdP setup wizard). The OAuth application on your GitLab instance must request theapiscope in addition to the OIDC login scopes (openid profile email). Users must log in to PolicyArc through that GitLab IdP — the gateway replays the token captured at login. - Static mode only: a GitLab access token that has enough permission for the operations you want to gate. Options:
- Personal Access Token — scoped to one user; simplest for PoC.
- Project Access Token — scoped to one project; recommended for production if the agent only needs one project.
- Group Access Token — scoped to a group of projects.
Install
Pick the auth mode in the install form's Authentication field
(defaults to idp_passthrough). The access-token field only appears when
static is selected; passthrough installs need no secret.
1. Provision the credential (static mode)
For static mode, create a GitLab access token with the minimum scopes you need. For MR automation:
| Operation | Minimum GitLab scope |
|---|---|
| List projects/MRs/notes | read_api |
| Create/edit MRs, post comments | api |
| Create project access tokens | api + project Owner/Maintainer |
| Container registry | read_registry |
Store the token as a PolicyArc secret (static mode only). In the install form, the Access token field's Add secret button does this. With the admin API:
curl -X POST "https://<your-as>/admin/api/secrets" \
-H "X-Admin-API-Key: <admin key>" \
-H 'Content-Type: application/json' \
-d '{"name": "gitlab_pat", "value": "glpat-..."}'
The gateway does not read environment variables for this connector. It sends
the field's value in the PRIVATE-TOKEN header, and it resolves only a
{{secret.<name>}} reference (StaticHeaderProvider.java).
2. Install the connector
From the Admin Portal:
- APIs & Connectors → + Connect a resource → GitLab
- Fill in the wizard:
- GitLab base URL —
https://gitlab.comor your self-hosted URL. - Access token — pick the secret that holds the token, such as
gitlab_pat. The form stores it as{{secret.gitlab_pat}}. (Static mode only. This field is hidden whenidp_passthroughis selected.)
- GitLab base URL —
- Select Connect.
The admin UI reads setup_fields directly from this connector's manifest — same fields shown below in the Setup fields reference.
3. Verify
The gateway path is /gateway/<resource name>/<GitLab REST path>. The install
form names the resource after the connector id without its vendor prefix, and
the name cannot be edited, so the resource answers at /gateway/gitlab/...:
curl "https://<your-as>/gateway/gitlab/api/v4/projects?search=acme" \
-H "Authorization: Bearer <token>"
In idp_passthrough mode, the default, <token> must be a user token, issued
through an authorization-code login by a user who signed in with the gitlab
IdP. A 200 with the project list means policy allowed the call and GitLab accepted the
forwarded user token. A client_credentials token carries no user, so the
gateway answers 502 with "Upstream auth failed: token resolution requires an
authenticated subject". In static mode the gateway sends its configured token
itself, and an agent's client_credentials token is enough.
Getting tokens covers both grants.
What can go wrong
| Symptom | Cause | Fix |
|---|---|---|
401 from GitLab (static mode) | The secret's value is wrong or expired, or the field holds plain text instead of a {{secret.<name>}} reference | Pick the secret in the Access token field, and check its value. Plain text is sent as the header value as written. |
403 from the gateway | Agent lacks the required scope | Request a token with gitlab:read, gitlab:write, or gitlab:admin matching the route. |
404 at the gateway | Wrong path | Gateway path is /gateway/<resource name>/<GitLab REST path>; a default install is named gitlab. GitLab paths start with /api/v4/.... |
| Agent sees an empty project list (static mode) | The PAT's user is in no projects | Use a wider-scope token or add the user to the target projects. |
401 with "error": "idp_link_required" (passthrough mode) | The user has no linked gitlab token, or the linked token lacks a scope the connector now requires | Open the link_url from the response body and sign in again. |
502 "Upstream auth failed: token resolution requires an authenticated subject" (passthrough mode) | The token is a client_credentials token, which carries no user | Use a user token from an authorization-code login. |
GitLab 401/403 upstream (passthrough mode) | The captured token expired and refresh failed, or the OAuth app wasn't granted api | Check the IdP registration's scopes. |
Scopes
| Scope | Meaning | Routes |
|---|---|---|
gitlab:read | Read-only operations | list/get projects, MRs, notes, registries |
gitlab:write | Mutating operations | create/edit MRs, post MR comments |
gitlab:admin | Privileged operations | create project access tokens |
Policy authors can scope a rule with input.context.rs_context.resource_type == "urn:connector:identos:gitlab". Per-project rules compare input.context.rs_context.resource_id with the bare project_id path value, such as release-critical, not with gitlab://projects/.... The gateway passes the first path parameter the route's resource_id_template names (ConnectorV1Adapter.java).
Setup fields
| Field | Required | Default | Purpose |
|---|---|---|---|
base_url | yes | https://gitlab.com | GitLab base URL (SaaS or self-hosted). |
upstream_auth.type | yes | idp_passthrough | Authentication — idp_passthrough (per-user, default) or static (shared token). |
token_env | conditional | — | Secret holding the access token, stored as {{secret.<name>}}. Static mode only; hidden when idp_passthrough is selected. |
MCP tools
When the managed gateway's MCP aggregator is enabled, this connector exposes the following tools to agents:
| Tool | Scope | Description |
|---|---|---|
list_users | gitlab:read | Look up users — username for exact lookup (resolves ids for assignee/reviewer fields), search for fuzzy. |
list_projects | gitlab:read | List projects, filtered by search, membership, ownership, visibility, min access level, topic or activity. |
get_project | gitlab:read | Get a project by ID or URL-encoded path, optionally with license and statistics. |
list_merge_requests | gitlab:read | List MRs on a project, filtered by scope, author, assignee, reviewer, labels, milestone, branches, text or date range. |
create_merge_request | gitlab:write | Open a new MR, including assignees, reviewers, labels, milestone and merge options. |
list_merge_request_notes | gitlab:read | List comments on an MR, with sort order. |
create_merge_request_note | gitlab:write | Add a comment to an MR, optionally internal. body executes GitLab quick actions — see Comment bodies carry quick actions. |
get_merge_request | gitlab:read | Get one MR, including diff_refs (base_sha, head_sha, start_sha), the SHAs that anchor an inline discussion comment. |
create_merge_request_discussion | gitlab:write | Start a discussion thread on an MR. Supply position to anchor it to lines of the diff, with SHAs from get_merge_request, or omit it for an MR-level thread. |
edit_merge_request | gitlab:write | Update MR title/description/state, assignees, reviewers, labels (replace or add/remove), target branch, milestone, squash, remove-source-branch, discussion lock. |
create_project_access_token | gitlab:admin | Mint a project access token. Defaults are not least privilege — see Access-token defaults. |
list_registry_repositories | gitlab:read | List container registry repositories on a project. |
list_registry_tags | gitlab:read | List tags in a registry repository. |
get_registry_tag | gitlab:read | Get a registry tag's details, including created_at (publish date), digest, and size. |
list_releases | gitlab:read | List releases for a project (newest first). |
get_release | gitlab:read | Get a single release by tag name. |
Each tool's input schema is embedded in the manifest and forwarded verbatim to MCP clients.
What the scope layer does and does not promise
Worth being explicit, because the connector's tool list can read like a capability boundary and it is not one.
A route is the unit of authorization. Each route pairs one
(method, pattern) with one scope, and that pair is what gitlab:read /
gitlab:write / gitlab:admin gates. Arguments are then mapped onto the
upstream request and are not validated against the route's input_schema.
Two things follow, both intended:
- A new GitLab parameter works without a code change. The route forwards the
request body as-is, so the day GitLab adds a field it is already usable over
/gateway/{name}/**. Declaring it ininput_schemais what makes it discoverable — a schema-driven MCP client only sends what the schema advertises, so an undeclared field is unreachable for an agent even though the transport already carries it. That asymmetry is the whole reason this connector's schemas are kept wide:edit_merge_requestonce declared onlytitle/description/state_event, and agents therefore treated assignees and reviewers as impossible when the endpoint had always accepted them. - Leaving a field out is not a restriction.
created_atandmerge_request_diff_head_shaare undeclared here so they are not offered, but a caller that sends one anyway has it proxied. Treat the tool list as an affordance, not a wall.
How to actually restrict an argument
Policy, not the schema. Rules see the whole argument object at
input.context.rs_context.tool_arguments.
One thing about the input shape decides whether such a rule fires at all, and
it is easy to get wrong — see the Rego input-schema reference in the docs site
(docs/reference/rego-schemas) for the full contract:
- There is no method or path to match on.
input.actionis a bare scope string, and the request path is never sent to OPA. Discriminate a route by its scope plus the shape of its arguments.
Keep the rule in this connector's aggregated namespace — oauth.evaluations_ext
walks data.pbac.connectors.<vendor>.<pkg> — and put whatever is tenant-specific
in the operator data key pbac.operator.connectors.identos.gitlab, which is a
data namespace, not a rule one. connectors/identos.google-gmail/policy/gmail.rego
is the worked example of that split.
package pbac.connectors.identos.gitlab
import rego.v1
_is_gitlab if input.context.rs_context.resource_type == "urn:connector:identos:gitlab"
# rs_context is absent at token issuance and carries resource_name on a gateway
# data-plane call. Any rule that fires on the ABSENCE of an argument needs this
# guard, or it denies every token request for the resource type.
_is_gateway_call if input.context.rs_context.resource_name
_args := input.context.rs_context.tool_arguments
_scopes := input.context.rs_context.scopes
deny contains msg if {
_is_gitlab
_is_gateway_call
"gitlab:write" in _scopes
"body" in object.keys(_args) # the note routes; the MR routes carry `description`
"created_at" in object.keys(_args)
msg := "gitlab: backdating notes is not permitted"
}
object.keys(_args) rather than _args.created_at matters: a missing key is
undefined, and not _args.created_at would silently skip the rule.
GitLab behaviours an operator should know
Three facts about GitLab that the scope names do not convey. None is a defect in this connector; each is worth a deliberate decision.
Access-token defaults are not least privilege
create_project_access_token sits behind gitlab:admin, but GitLab's defaults
are the problem: omit access_level and you get a Maintainer (40) token, and
omit expires_at and you get the maximum lifetime the instance allows. Both
are declared on the tool, which is what lets a caller ask for less — and lets
policy see what was asked. Declaring access_level also makes Owner (50)
requestable where it previously was not, so if you allow this tool, cap it:
deny contains msg if {
_is_gitlab
_is_gateway_call
"gitlab:admin" in _scopes # create_project_access_token is the only gitlab:admin route
object.get(_args, "access_level", 40) > 30
msg := "gitlab: project access tokens are capped at Developer (30)"
}
The object.get default of 40 is the point: GitLab applies Maintainer when the
argument is absent, so a rule that only inspected a supplied value would miss
exactly the call you care about.
The token this route mints lives outside PolicyArc — no introspection, no revocation, no audit — so its role and lifetime are the only controls you get.
Comment bodies carry quick actions
GitLab executes leading slash commands in a note body as the commenting user. So
create_merge_request_note can assign, label, close, approve or merge when
body starts with /assign, /close, /approve, /merge. This connector
exposes no merge route; the note body reaches past that, by GitLab's design.
GitLab's own permissions still apply — a quick action cannot exceed what the
calling credential could do directly. The thing to plan for is narrower: the
scope says "comment" and the effect can be "merge", and a command-only body
applies the command without posting a comment, so the MR history shows the
effect and not the instruction. In static mode every caller shares one
operator token, so its role bounds everyone.
Not shipped as connector policy, because a legitimate comment may reasonably
begin with /. If that trade is wrong for your tenant:
deny contains msg if {
_is_gitlab
_is_gateway_call
"gitlab:write" in _scopes
some line in split(object.get(_args, "body", ""), "\n")
startswith(trim_space(line), "/")
msg := "gitlab: quick actions are not permitted in comment bodies"
}
Check every line, not just the first — GitLab scans the whole body.
list_users search widens under an admin token
search matches name, username and public email for a normal caller. Under an
administrator token — which static mode often holds — GitLab widens it to
match private email addresses. Prefer username for exact lookups, and scope
the static-mode token to the least privileged account that does the job.
Example policy
Block MR creation on a sensitive project unless the caller belongs to the
release-managers group. The group comes from the groups claim of the user's
IdP login, which the AS captures onto input.subject.groups when the IdP sends
one.
package pbac.connectors.identos.gitlab
import rego.v1
deny contains msg if {
input.context.rs_context.resource_type == "urn:connector:identos:gitlab"
input.context.rs_context.resource_id == "release-critical"
"gitlab:write" in input.context.rs_context.scopes
"source_branch" in object.keys(input.context.rs_context.tool_arguments) # create_merge_request
# object.get over the whole path: a client_credentials token has no
# subject at all, and must be refused rather than skipped.
not "release-managers" in object.get(input, ["subject", "groups"], [])
msg := "only release-managers can open MRs against release-critical"
}
resource_id is the bare project_id path value, not
gitlab://projects/.... On a local instance, create_merge_request over MCP
for release-critical returned 403 for a user whose groups were ["sales"]
and for a client_credentials token, and reached the upstream for a user in
release-managers and for another project. An earlier version of this example
compared resource_id with gitlab://projects/release-critical and read
input.subject.groups directly; it never denied.
Keep the rule here, in the namespace the aggregator walks, and put the
tenant-specific part — which project, which group — in the operator data key
pbac.operator.connectors.identos.gitlab, so it is configuration rather than a
code change.
Reference
- Manifest:
connectors/identos.gitlab/connector.v1.jsonin the repo. - Upstream API docs: docs.gitlab.com/ee/api
- Access token types: docs.gitlab.com/ee/user/profile/personal_access_tokens
Manifest reference
- ID:
identos.gitlab - Version:
1.5.0 - Resource type:
urn:connector:identos:gitlab - Capabilities:
mcp
Supported auth modes
| Type | Details |
|---|---|
idp_passthrough | requires IdP gitlab |
static | scheme header; header PRIVATE-TOKEN; setup fields: token_env |
Setup fields
| ID | Label | Default | Secret? | Notes |
|---|---|---|---|---|
base_url | GitLab base URL | https://gitlab.com | no | placeholder: https://git.example.com |
upstream_auth.type | Authentication | idp_passthrough | no | idp_passthrough forwards each user's own GitLab OAuth token (recommended). static uses a shared personal/project/group access token via the PRIVATE-TOKEN header. |
token_env | Access token | — | yes | Pick a secret holding a GitLab personal, project, or group access token. Required only for the static auth mode. / shown when upstream_auth.type == 'static' |
Scopes
| Scope |
|---|
gitlab:read |
gitlab:write |
gitlab:admin |
Routes
| Method | Pattern | Scope | Resource template |
|---|---|---|---|
GET | /api/v4/users | gitlab:read | — |
GET | /api/v4/projects | gitlab:read | — |
GET | /api/v4/projects/{project_id} | gitlab:read | gitlab://projects/{{project_id}} |
GET | /api/v4/projects/{project_id}/merge_requests | gitlab:read | gitlab://projects/{{project_id}} |
POST | /api/v4/projects/{project_id}/merge_requests | gitlab:write | gitlab://projects/{{project_id}} |
GET | /api/v4/projects/{project_id}/merge_requests/{merge_request_iid}/notes | gitlab:read | gitlab://projects/{{project_id}}/mr/{{merge_request_iid}} |
POST | /api/v4/projects/{project_id}/merge_requests/{merge_request_iid}/notes | gitlab:write | gitlab://projects/{{project_id}}/mr/{{merge_request_iid}} |
GET | /api/v4/projects/{project_id}/merge_requests/{merge_request_iid} | gitlab:read | gitlab://projects/{{project_id}}/mr/{{merge_request_iid}} |
POST | /api/v4/projects/{project_id}/merge_requests/{merge_request_iid}/discussions | gitlab:write | gitlab://projects/{{project_id}}/mr/{{merge_request_iid}} |
PUT | /api/v4/projects/{project_id}/merge_requests/{merge_request_iid} | gitlab:write | gitlab://projects/{{project_id}}/mr/{{merge_request_iid}} |
POST | /api/v4/projects/{project_id}/access_tokens | gitlab:admin | gitlab://projects/{{project_id}} |
GET | /api/v4/projects/{project_id}/registry/repositories | gitlab:read | gitlab://projects/{{project_id}} |
GET | /api/v4/projects/{project_id}/registry/repositories/{repository_id}/tags | gitlab:read | gitlab://projects/{{project_id}}/registry/{{repository_id}} |
GET | /api/v4/projects/{project_id}/registry/repositories/{repository_id}/tags/{tag_name} | gitlab:read | gitlab://projects/{{project_id}}/registry/{{repository_id}} |
GET | /api/v4/projects/{project_id}/releases | gitlab:read | gitlab://projects/{{project_id}} |
GET | /api/v4/projects/{project_id}/releases/{tag_name} | gitlab:read | gitlab://projects/{{project_id}}/releases/{{tag_name}} |
MCP tools
| Name | Scope | Description |
|---|---|---|
list_users | gitlab:read | Look up GitLab users. Use username for an exact single-user lookup — this is how you resolve the numeric ids needed by assignee_ids/reviewer_ids on create_merge_request and edit_merge_request. search is a fuzzy fallback. |
list_projects | gitlab:read | List GitLab projects visible to the caller. Narrow with membership/owned/starred/min_access_level rather than paging the whole instance; simple cuts the response to core fields. |
get_project | gitlab:read | Get a GitLab project by ID or URL-encoded path. |
list_merge_requests | gitlab:read | List merge requests for a project. Filter server-side rather than paging everything and discarding: scope covers the common 'mine' cases, and author/assignee/reviewer accept usernames so you don't need a list_users round-trip first. |
create_merge_request | gitlab:write | Create a merge request. Set assignees, reviewers and labels here rather than opening bare and following up with edit_merge_request. Prefix the title with 'Draft:' to open it as a draft — GitLab derives draft status from the title. |
list_merge_request_notes | gitlab:read | List comments on a merge request. Defaults to newest first; pass sort=asc to read a thread in the order it happened. |
create_merge_request_note | gitlab:write | Add a comment on a merge request. WARNING: GitLab interprets leading slash commands in body as quick actions executed as the caller — a body of /merge, /approve, /assign @user or /close performs that action instead of commenting. Treat this argument as privileged input, not free text. GitLab's own created_at (backdating) and merge_request_diff_head_sha (arms the /merge quick action) attributes are deliberately not exposed by this connector. |
get_merge_request | gitlab:read | Get a single merge request, including diff_refs (base_sha, head_sha, start_sha) — the SHAs needed to anchor an inline discussion comment to specific diff lines. |
create_merge_request_discussion | gitlab:write | Start a discussion thread on a merge request. Supply position to anchor the comment to specific lines of the diff (an inline code-review comment); omit it for a plain MR-level thread. Position SHAs come from the merge request's diff_refs (fetch via get_merge_request). |
edit_merge_request | gitlab:write | Update a merge request: title, description, state, assignees, reviewers, labels, target branch, milestone, and merge options. Only the fields you supply are changed. |
create_project_access_token | gitlab:admin | Create a project access token. Least privilege is the caller's responsibility and GitLab's defaults are NOT least privilege: omitting access_level mints a Maintainer (40) token, and omitting expires_at gives it the maximum lifetime the instance allows. Always set both explicitly, and pick the narrowest scopes that work. |
list_registry_repositories | gitlab:read | List container registry repositories for a project. |
list_registry_tags | gitlab:read | List tags for a container registry repository. |
get_registry_tag | gitlab:read | Get a container registry tag's details, including created_at (publish date), digest, and total size. |
list_releases | gitlab:read | List releases for a project (newest first). |
get_release | gitlab:read | Get a single release by tag name. |
Send feedback about this page