|
| 1 | +--- |
| 2 | +title: "43. GitLab support via webhook bridge" |
| 3 | +status: Accepted |
| 4 | +relates_to: |
| 5 | + - agent-infrastructure |
| 6 | + - agent-architecture |
| 7 | + - gitlab-support |
| 8 | +topics: |
| 9 | + - gitlab |
| 10 | + - forge |
| 11 | + - ci-cd |
| 12 | + - multi-platform |
| 13 | + - webhook |
| 14 | + - security |
| 15 | +--- |
| 16 | + |
| 17 | +# 43. GitLab support via webhook bridge |
| 18 | + |
| 19 | +Date: 2026-06-01 |
| 20 | + |
| 21 | +## Status |
| 22 | + |
| 23 | +Accepted |
| 24 | + |
| 25 | +Supersedes [ADR 0028](0028-gitlab-support.md) (Deprecated). |
| 26 | + |
| 27 | +## Context |
| 28 | + |
| 29 | +fullsend supports GitHub exclusively. Organizations on GitLab cannot adopt it. The forge abstraction (`forge.Client`, [ADR 0005](0005-forge-abstraction-layer.md)) was designed for multi-forge support, but the surrounding infrastructure — token mint, dispatch workflows, shim security model — is GitHub-specific. |
| 30 | + |
| 31 | +[ADR 0028](0028-gitlab-support.md) proposed GitLab support in April 2026 but was deprecated because the architecture changed significantly: |
| 32 | + |
| 33 | +- **ADR 0029** introduced the central token mint (GCP Cloud Function with OIDC validation), replacing PEM-in-repo-secrets. |
| 34 | +- **ADR 0041** moved dispatch from asynchronous `workflow_dispatch` to synchronous `workflow_call`. |
| 35 | +- **ADR 0031** introduced reusable workflows so `.fullsend` agent workflows are thin callers to upstream `fullsend-ai/fullsend`. |
| 36 | +- **ADR 0033** added per-repo installation mode alongside per-org. |
| 37 | + |
| 38 | +ADR 0028's authentication model (per-role Project Access Tokens stored as CI/CD variables), dispatch model (async pipeline trigger API), and unresolved webhook translation question no longer align with the current architecture. This ADR redesigns GitLab support from the current baseline. |
| 39 | + |
| 40 | +### The core problem: no `pull_request_target` |
| 41 | + |
| 42 | +On GitHub, `pull_request_target` runs the shim workflow from the **base branch**, preventing MR authors from modifying the workflow to exfiltrate secrets ([ADR 0009](0009-pull-request-target-in-shim-workflows.md)). GitLab has no equivalent mechanism. A `.gitlab-ci.yml` in an enrolled repo runs the MR branch version — an attacker could modify it to dump secrets. |
| 43 | + |
| 44 | +This is the fundamental security gap that any GitLab support design must close. |
| 45 | + |
| 46 | +### Webhook-to-pipeline wire incompatibility |
| 47 | + |
| 48 | +GitLab CI/CD pipelines cannot be natively triggered by issue events, note (comment) events, or MR review events. The `CI_PIPELINE_SOURCE` variable has no values for these event types. |
| 49 | + |
| 50 | +GitLab webhooks _can_ fire on these events, but webhooks deliver JSON payloads while the pipeline trigger API (`/api/v4/projects/:id/trigger/pipeline`) expects form-encoded parameters. Pointing a webhook URL at the trigger API produces a malformed request. An intermediary is required. |
| 51 | + |
| 52 | +## Options |
| 53 | + |
| 54 | +**Selected**: Webhook bridge Cloud Function (see [Decision](#decision) below). |
| 55 | + |
| 56 | +### Alternative 1: In-repo CI job as shim |
| 57 | + |
| 58 | +A `.gitlab-ci.yml` job in enrolled repos receives pipeline events and calls the `.fullsend` trigger API. |
| 59 | + |
| 60 | +**Rejected**: Cannot enforce protected-branch-only execution. MR authors can modify the CI job to exfiltrate the trigger token or any secrets available in the pipeline context. This reintroduces the exact vulnerability that `pull_request_target` prevents on GitHub. |
| 61 | + |
| 62 | +### Alternative 2: GitLab serverless functions |
| 63 | + |
| 64 | +Deploy a GitLab-hosted serverless function that translates webhooks to trigger API calls. |
| 65 | + |
| 66 | +**Rejected**: Requires GitLab Premium or Ultimate tier. Excluding Free tier users is an unacceptable constraint for an open-source project. |
| 67 | + |
| 68 | +### Alternative 3: Per-role Project Access Tokens stored as CI/CD variables |
| 69 | + |
| 70 | +Store PATs directly in `.fullsend` CI/CD variables (ADR 0028's original approach) instead of using the central token mint. |
| 71 | + |
| 72 | +**Rejected**: Diverges from the token mint model (ADR 0029). Managing credentials in two different systems (Secret Manager for GitHub, CI/CD variables for GitLab) increases operational complexity and the attack surface. The mint model is superior because it centralizes credential storage, enforces OIDC validation, and scopes tokens per-request. |
| 73 | + |
| 74 | +### Alternative 4: Pull-based model with GitLab CI scheduled pipelines |
| 75 | + |
| 76 | +Use GitLab CI jobs for events that CI can natively trigger (MR events via `merge_request_event` pipeline source). For everything else (issue events, note events, label changes), use a scheduled GitLab CI job that polls the GitLab API for recent activity and dispatches agent work. |
| 77 | + |
| 78 | +**Advantages**: No external infrastructure needed — everything runs in GitLab CI. Scheduled jobs run in the context of the target repo and have access to repo secrets. No webhook bridge to deploy, monitor, or make network-adjacent to self-hosted instances. |
| 79 | + |
| 80 | +**Deferred for further evaluation**: The pull model trades latency for simplicity — scheduled pipelines have a minimum 5-minute interval, so issue triage could be delayed up to 5 minutes. It also requires idempotent event processing (tracking which events have already been handled across polling intervals). However, for organizations where the bridge's per-instance deployment cost is prohibitive (especially self-hosted GitLab behind VPNs), this may be the better trade-off. This alternative warrants a proof-of-concept alongside the webhook bridge. |
| 81 | + |
| 82 | +## Decision |
| 83 | + |
| 84 | +### Webhook bridge Cloud Function |
| 85 | + |
| 86 | +Deploy a lightweight GCP Cloud Function — separate from the token mint — that translates GitLab webhook payloads into pipeline trigger API calls. |
| 87 | + |
| 88 | +``` |
| 89 | +GitLab webhook (JSON) |
| 90 | + → Bridge Cloud Function |
| 91 | + → validates webhook secret (constant-time) |
| 92 | + → extracts event type and payload |
| 93 | + → base64-encodes payload (prevents YAML injection) |
| 94 | + → calls Pipeline Trigger API with ref=main (hardcoded) |
| 95 | + → .fullsend dispatch pipeline (protected main branch) |
| 96 | + → determines stage from event payload |
| 97 | + → triggers child pipeline via trigger: include: artifact: |
| 98 | + → child pipeline authenticates to token mint via GitLab OIDC |
| 99 | + → agent executes in sandbox |
| 100 | +``` |
| 101 | + |
| 102 | +**Why a separate function, not co-deployed with the mint**: The bridge handles untrusted webhook payloads from the internet. If it has a vulnerability, compromise of a separate function gives the attacker only a pipeline trigger token. Co-deployed, a bridge exploit could reach the mint's Secret Manager access and all stored PEM keys. |
| 103 | + |
| 104 | +**Infrastructure cost**: Unlike the token mint (which can be centralized), the bridge must be network-reachable from the GitLab instance that sends webhooks. For GitLab.com this is straightforward (public endpoint). For self-hosted GitLab instances behind VPNs, the bridge must be deployed adjacent to each instance — potentially requiring a separate bridge deployment per GitLab instance with distinct network configurations. This is a meaningful operational cost beyond "one more Cloud Function." Each bridge deployment needs its own monitoring, trigger tokens, and webhook token cache. Organizations with multiple self-hosted GitLab instances should weigh this against the pull-based alternative (Alternative 4). |
| 105 | + |
| 106 | +**Why this is acceptable now**: [ADR 0009](0009-pull-request-target-in-shim-workflows.md) rejected hosted webhook receivers for "breaking compute-platform agnosticism." ADR 0029 subsequently introduced a hosted GCP Cloud Function (the token mint). Bridge functions follow the same deployment pattern. The per-instance cost is real but bounded — most organizations have one or two GitLab instances, not dozens. |
| 107 | + |
| 108 | +### Defense in depth |
| 109 | + |
| 110 | +Three independent layers prevent unauthorized pipeline execution: |
| 111 | + |
| 112 | +1. **Bridge hardcodes `ref=main`**: The target ref is never derived from the webhook payload. Even if an attacker crafts a malicious webhook, the dispatch pipeline always runs on the protected default branch. |
| 113 | + |
| 114 | +2. **Protected CI/CD variables**: All secrets in the `.fullsend` project (trigger tokens, role credentials, webhook tokens) are marked as "protected." GitLab only exposes protected variables to pipelines running on protected branches. If the bridge is compromised to call the trigger API with `ref=attacker-branch`, secrets are not exposed. |
| 115 | + |
| 116 | +3. **Per-project webhook secret validation**: Each enrolled project has a unique webhook secret token, stored as a protected CI/CD variable in `.fullsend` (variable name: `WEBHOOK_TOKEN_<sha256(project_path)>` for collision-free identification). The dispatch pipeline validates the token before processing. |
| 117 | + |
| 118 | +### Token mint extension |
| 119 | + |
| 120 | +The existing token mint ([ADR 0029](0029-central-token-mint-secretless-fullsend.md)) is extended with a credential backend abstraction: |
| 121 | + |
| 122 | +- **`GitHubCredentialBackend`** (existing): OIDC → validate claims → retrieve PEM from Secret Manager → generate JWT → exchange for scoped GitHub App installation token. |
| 123 | +- **`GitLabCredentialBackend`** (new): OIDC → validate claims → retrieve stored Project Access Token from Secret Manager → return token. |
| 124 | + |
| 125 | +GitLab OIDC integration: |
| 126 | + |
| 127 | +- GitLab CI jobs declare `id_tokens:` with audience `fullsend-mint`. |
| 128 | +- A WIF provider for the GitLab OIDC issuer is added to the existing WIF pool. |
| 129 | +- GitLab claim names are normalized to the mint's internal representation: `project_path` → `repository`, `namespace_path` → `repository_owner`. |
| 130 | +- The mint validates the pipeline originated from the `.fullsend` project (or a registered per-repo project) on a protected ref. |
| 131 | + |
| 132 | +### Credential model |
| 133 | + |
| 134 | +GitLab Project Access Tokens (PATs) replace GitHub Apps: |
| 135 | + |
| 136 | +- Created per-role per-project during `fullsend admin install` via GitLab API. |
| 137 | +- Stored in Secret Manager with project-scoped naming: `fullsend-{group}--{project}--{role}-pat`. Including the project name is necessary because PATs are per-project (unlike GitHub App PEMs which are per-org). |
| 138 | +- Role mapping: triage → Reporter, code → Developer, review → Developer, fix → Developer, fullsend (orchestrator) → Maintainer. |
| 139 | +- Max 1-year expiry. `fullsend admin analyze` warns when PATs are within 30 days of expiry. `fullsend admin rotate-tokens` performs rotation. |
| 140 | + |
| 141 | +**Mint statefulness trade-off**: Unlike GitHub PEMs (one per org per role), GitLab PATs are per-project per role. This means the mint must store credentials that scale with the number of enrolled projects, making it less stateless than the GitHub model. For organizations where this scaling is a concern, storing PATs as protected CI/CD variables in the `.fullsend` project (bypassing the mint entirely) is a viable alternative — see Alternative 3 and the open question below. |
| 142 | + |
| 143 | +**PAT limitation**: GitLab PATs cannot be used to mint further scoped-down tokens — they are the final credential. The mint returns the PAT directly rather than exchanging it for a short-lived token. This means PAT scope must be carefully set at creation time (per-role mapping above). This is a security regression compared to GitHub's model where the mint generates short-lived, repo-scoped installation tokens from long-lived PEMs. |
| 144 | + |
| 145 | +### GitLab dispatch pipeline |
| 146 | + |
| 147 | +The `.fullsend` project's dispatch pipeline mirrors GitHub's `dispatch.yml`: |
| 148 | + |
| 149 | +- Triggered by the bridge function via Pipeline Trigger API. |
| 150 | +- Validates the source project is enrolled (config.yaml lookup). |
| 151 | +- Determines the stage from the base64-decoded event payload (same routing logic as GitHub: issue events, note events, MR events, label events). |
| 152 | +- Generates a child pipeline config that includes the matching stage file. |
| 153 | +- Triggers the child pipeline via `trigger: include: artifact:` with `strategy: depend` (synchronous, preserving the run-correlation property from [ADR 0041](0041-synchronous-workflow-call-event-dispatch.md)). |
| 154 | + |
| 155 | +### Forge abstraction evolution |
| 156 | + |
| 157 | +New methods added to `forge.Client`: |
| 158 | + |
| 159 | +```go |
| 160 | +CreateWebhook(ctx, owner, repo, targetURL, secretToken string, events []string) (webhookID string, err error) |
| 161 | +DeleteWebhook(ctx, owner, repo, webhookID string) error |
| 162 | +CreateRoleCredential(ctx, owner, repo, roleName string, scopes []string, expiresAt time.Time) (credential string, credentialID string, err error) |
| 163 | +RevokeRoleCredential(ctx, owner, repo, credentialID string) error |
| 164 | +IsProtectedBranch(ctx, owner, repo, branch string) (bool, error) |
| 165 | +``` |
| 166 | + |
| 167 | +`CreateRoleCredential`/`RevokeRoleCredential` abstract over both GitHub Apps and GitLab PATs. The GitHub implementation returns `forge.ErrNotSupported` since GitHub credentials are managed through the token mint's PEM-based flow, not through the forge interface. |
| 168 | + |
| 169 | +GitHub-specific methods (`ListOrgInstallations`, `GetAppClientID`) move to an extension interface (`GitHubExtensions`). Callers that need them type-assert. |
| 170 | + |
| 171 | +### Installation modes |
| 172 | + |
| 173 | +Both per-org and per-repo modes are supported for GitLab: |
| 174 | + |
| 175 | +- **Per-org**: `.fullsend` is a project within the GitLab group. Enrolled projects configure webhooks pointing to the bridge. The dispatch pipeline and agent pipelines run in `.fullsend`. |
| 176 | +- **Per-repo**: `.fullsend/` directory lives within the target project. The webhook points to the bridge, which triggers the project's own pipeline on the protected default branch. |
| 177 | + |
| 178 | +### Platform requirements |
| 179 | + |
| 180 | +- **Minimum GitLab version**: 17.0+ (OIDC `id_tokens:` support, CI/CD components, current API stability). |
| 181 | +- **Self-hosted support**: Configurable instance URLs via `--gitlab-url` flag and `gitlab_instance_url` in config.yaml. The WIF pool needs per-instance OIDC providers. |
| 182 | + |
| 183 | +## Consequences |
| 184 | + |
| 185 | +### Positive |
| 186 | + |
| 187 | +- Organizations on GitLab (both GitLab.com and self-hosted 17.0+) can adopt fullsend. |
| 188 | +- Reuses the central token mint model (ADR 0029) rather than introducing a parallel credential system. |
| 189 | +- The same agent workflow (triage → code → review → fix → retro) works identically from the user's perspective. |
| 190 | +- Webhook-based dispatch is architecturally cleaner than an in-repo shim for GitLab's security model. |
| 191 | +- Forge abstraction (ADR 0005) is validated and strengthened by a second implementation. |
| 192 | + |
| 193 | +### Negative |
| 194 | + |
| 195 | +- Two CI/CD template sets to maintain (`.github/workflows/` and `.gitlab/ci/`). |
| 196 | +- Project Access Tokens are less granular than GitHub Apps (role-level, not per-permission). |
| 197 | +- PATs require rotation (max 1-year expiry); GitHub App private keys do not expire. |
| 198 | +- The webhook bridge adds a Cloud Function to deploy and monitor. |
| 199 | +- Post-scripts may need minor forge-specific branches where GitHub and GitLab APIs diverge (e.g., review semantics). |
| 200 | + |
| 201 | +### Risks |
| 202 | + |
| 203 | +- **Protected branch misconfiguration**: If `.fullsend` project's default branch is not protected, MR authors could modify pipeline code. Mitigated by validation during install and protected CI/CD variables as defense-in-depth. |
| 204 | +- **Bridge function availability**: If the bridge is down, no GitLab events are processed. Mitigated by Cloud Function auto-scaling and health monitoring. |
| 205 | +- **GitLab API rate limits**: GitLab.com has lower rate limits than GitHub. Mitigated by exponential backoff and retry in the GitLab forge client. |
| 206 | +- **Self-hosted GitLab version drift**: Wide version range among self-hosted instances. Mitigated by requiring 17.0+ and detecting version during install. |
| 207 | + |
| 208 | +## References |
| 209 | + |
| 210 | +- [ADR 0005](0005-forge-abstraction-layer.md): Forge abstraction layer |
| 211 | +- [ADR 0007](0007-per-role-github-apps.md): Per-role GitHub Apps (authentication model to replicate) |
| 212 | +- [ADR 0009](0009-pull-request-target-in-shim-workflows.md): `pull_request_target` security model (problem to solve) |
| 213 | +- [ADR 0028](0028-gitlab-support.md): GitLab Support Architecture (deprecated, superseded by this ADR) |
| 214 | +- [ADR 0029](0029-central-token-mint-secretless-fullsend.md): Central token mint |
| 215 | +- [ADR 0031](0031-reusable-workflows-for-action-installed-distribution.md): Reusable workflows |
| 216 | +- [ADR 0033](0033-per-repo-installation-mode.md): Per-repo installation mode |
| 217 | +- [ADR 0041](0041-synchronous-workflow-call-event-dispatch.md): Synchronous dispatch |
| 218 | +- [GitLab support implementation details](../problems/gitlab-support.md): Companion implementation document |
0 commit comments