Skip to content

Commit cd58d5b

Browse files
committed
feat(credentials): saved GitLab token credential and credential-only gitlab_v2
A GitLab credential holds a personal, project, or group access token and the instance host (gitlab.com or self-managed). Saving verifies the token's self-lookup at that exact host over the SSRF-guarded fetch with no redirects and requires an active token with an API scope; runs only reach the verified host. Successors may now state the picker's required rule when the replaced fields differ (GitLab's host was optional).
1 parent f533899 commit cd58d5b

79 files changed

Lines changed: 368 additions & 80 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/components/ui/icon-mapping.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
476476
github: GithubIcon,
477477
github_v2: GithubIcon,
478478
gitlab: GitLabIcon,
479+
gitlab_v2: GitLabIcon,
479480
gmail: GmailIcon,
480481
gmail_v2: GmailIcon,
481482
gong: GongIcon,

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

Lines changed: 1 addition & 66 deletions
Large diffs are not rendered by default.

‎apps/sim/blocks/blocks/gitlab.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { GitLabIcon } from '@/components/icons'
22
import type { BlockConfig, BlockMeta } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
4+
import { withSavedCredential } from '@/blocks/utils'
45
import {
56
coerceGitLabAccessLevel,
67
GITLAB_ACCESS_LEVEL_OPTIONS,
@@ -131,6 +132,8 @@ function parseTriState(raw: unknown): boolean | undefined {
131132

132133
export const GitLabBlock: BlockConfig = {
133134
type: 'gitlab',
135+
hideFromToolbar: true,
136+
sunset: { status: 'legacy', replacedBy: 'gitlab_v2' },
134137
name: 'GitLab',
135138
description: 'Interact with GitLab projects, issues, merge requests, and pipelines',
136139
authMode: AuthMode.ApiKey,
@@ -3409,3 +3412,16 @@ export const GitLabBlockMeta = {
34093412
},
34103413
],
34113414
} as const satisfies BlockMeta
3415+
3416+
export const GitLabV2Block: BlockConfig = {
3417+
...GitLabBlock,
3418+
type: 'gitlab_v2',
3419+
authMode: AuthMode.OAuth,
3420+
hideFromToolbar: false,
3421+
sunset: undefined,
3422+
...withSavedCredential(
3423+
GitLabBlock,
3424+
{ id: 'credential', type: 'oauth-input', serviceId: 'gitlab', title: 'GitLab Credential' },
3425+
{ required: true }
3426+
),
3427+
}

‎apps/sim/blocks/registry-maps.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ import {
119119
GitHubV2Block,
120120
GitHubV2BlockMeta,
121121
} from '@/blocks/blocks/github'
122-
import { GitLabBlock, GitLabBlockMeta } from '@/blocks/blocks/gitlab'
122+
import { GitLabBlock, GitLabBlockMeta, GitLabV2Block } from '@/blocks/blocks/gitlab'
123123
import { GmailBlock, GmailBlockMeta, GmailV2Block, GmailV2BlockMeta } from '@/blocks/blocks/gmail'
124124
import { GongBlock, GongBlockMeta } from '@/blocks/blocks/gong'
125125
import { GoogleSearchBlock, GoogleSearchBlockMeta } from '@/blocks/blocks/google'
@@ -609,6 +609,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
609609
github: GitHubBlock,
610610
github_v2: GitHubV2Block,
611611
gitlab: GitLabBlock,
612+
gitlab_v2: GitLabV2Block,
612613
gmail: GmailBlock,
613614
gmail_v2: GmailV2Block,
614615
gong: GongBlock,

‎apps/sim/blocks/utils.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,19 @@ describe('withSavedCredential', () => {
430430
expect(() => withSavedCredential(mismatched, picker)).toThrow(/show or require differently/)
431431
})
432432

433+
it('accepts replaced fields that differ only in what the successor states itself', () => {
434+
const optionalHost: BlockConfig = {
435+
...legacy,
436+
subBlocks: legacy.subBlocks.map((subBlock) =>
437+
subBlock.id === 'secretAccessKey' ? { ...subBlock, required: false } : subBlock
438+
),
439+
}
440+
441+
const { subBlocks } = withSavedCredential(optionalHost, picker, { required: true })
442+
443+
expect(subBlocks.find((subBlock) => subBlock.id === 'credential')?.required).toBe(true)
444+
})
445+
433446
it('refuses options that would silently do nothing', () => {
434447
expect(() =>
435448
withSavedCredential(legacy, picker, {

‎apps/sim/blocks/utils.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,15 @@ export function withSavedCredential(
105105
throw new Error(`Block "${legacy.type}" has none of the fields a saved credential replaces`)
106106
}
107107

108-
// The picker shows and is required exactly where the secrets it replaces were.
108+
// The picker shows and is required exactly where the secrets it replaces were,
109+
// unless the successor states where; replaced fields must agree on whatever it inherits.
109110
const { condition: secretCondition, required: secretRequired } = legacy.subBlocks[at]
110-
const visibility = (subBlock: SubBlockConfig) =>
111-
JSON.stringify([subBlock.condition ?? null, subBlock.required ?? null])
112-
if (new Set(legacy.subBlocks.filter(isReplaced).map(visibility)).size > 1) {
111+
const inherited = (subBlock: SubBlockConfig) =>
112+
JSON.stringify([
113+
options.condition === undefined ? (subBlock.condition ?? null) : null,
114+
options.required === undefined ? (subBlock.required ?? null) : null,
115+
])
116+
if (new Set(legacy.subBlocks.filter(isReplaced).map(inherited)).size > 1) {
113117
throw new Error(`Block "${legacy.type}": the replaced secrets show or require differently`)
114118
}
115119
const condition = options.condition ?? secretCondition

‎apps/sim/lib/block-metadata/names.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export const BLOCK_NAMES: Readonly<Record<string, string>> = {
9696
gauge: 'Gauge',
9797
generic_webhook: 'Webhook Trigger',
9898
github_v2: 'GitHub',
99-
gitlab: 'GitLab',
99+
gitlab_v2: 'GitLab',
100100
gmail_v2: 'Gmail',
101101
gong: 'Gong',
102102
google_ads: 'Google Ads',

‎apps/sim/lib/credentials/token-service-accounts/descriptors.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const FLOW_SERVICE_ACCOUNT_PROVIDER_ID = 'flow-service-account' as const
8585
export const CODA_SERVICE_ACCOUNT_PROVIDER_ID = 'coda-service-account' as const
8686
export const GAUGE_SERVICE_ACCOUNT_PROVIDER_ID = 'gauge-service-account' as const
8787
export const HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID = 'higgsfield-service-account' as const
88+
export const GITLAB_SERVICE_ACCOUNT_PROVIDER_ID = 'gitlab-service-account' as const
8889
export const WHATSAPP_SERVICE_ACCOUNT_PROVIDER_ID = 'whatsapp-service-account' as const
8990
export const VERCEL_SERVICE_ACCOUNT_PROVIDER_ID = 'vercel-service-account' as const
9091
export const TYPEFORM_SERVICE_ACCOUNT_PROVIDER_ID = 'typeform-service-account' as const
@@ -191,6 +192,7 @@ export type TokenServiceAccountProviderId =
191192
| typeof CODA_SERVICE_ACCOUNT_PROVIDER_ID
192193
| typeof GAUGE_SERVICE_ACCOUNT_PROVIDER_ID
193194
| typeof HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID
195+
| typeof GITLAB_SERVICE_ACCOUNT_PROVIDER_ID
194196
| typeof WHATSAPP_SERVICE_ACCOUNT_PROVIDER_ID
195197
| typeof VERCEL_SERVICE_ACCOUNT_PROVIDER_ID
196198
| typeof TYPEFORM_SERVICE_ACCOUNT_PROVIDER_ID
@@ -1772,6 +1774,21 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
17721774
invalidCredentialsHelp:
17731775
'Meta rejected this token. Check that it has not expired or been revoked; temporary App Dashboard tokens last only 24 hours.',
17741776
},
1777+
[GITLAB_SERVICE_ACCOUNT_PROVIDER_ID]: {
1778+
providerId: GITLAB_SERVICE_ACCOUNT_PROVIDER_ID,
1779+
serviceLabel: 'GitLab',
1780+
tokenNoun: 'access token',
1781+
connectNoun: 'access token',
1782+
fields: [
1783+
{ id: 'apiToken', label: 'Access token', placeholder: 'glpat-...', secret: true },
1784+
{ id: 'domain', label: 'GitLab host', placeholder: 'gitlab.com', secret: false },
1785+
],
1786+
docsUrl: 'https://docs.sim.ai/integrations/gitlab',
1787+
helpText:
1788+
'Use a personal, project, or group access token with the api scope (read_api for read-only workflows). Enter gitlab.com, or your self-managed host.',
1789+
invalidCredentialsHelp:
1790+
'GitLab rejected this token. Check that it is active, has the api or read_api scope, and belongs to the host you entered.',
1791+
},
17751792
}
17761793

17771794
/**

‎apps/sim/lib/credentials/token-service-accounts/server.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
FLOW_SERVICE_ACCOUNT_PROVIDER_ID,
3838
GAMMA_SERVICE_ACCOUNT_PROVIDER_ID,
3939
GAUGE_SERVICE_ACCOUNT_PROVIDER_ID,
40+
GITLAB_SERVICE_ACCOUNT_PROVIDER_ID,
4041
GRAIN_SERVICE_ACCOUNT_PROVIDER_ID,
4142
GRANOLA_SERVICE_ACCOUNT_PROVIDER_ID,
4243
HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -109,6 +110,7 @@ import { validateClickupServiceAccount } from '@/lib/credentials/token-service-a
109110
import { validateCodaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/coda'
110111
import { validateFlowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/flow'
111112
import { validateGaugeServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gauge'
113+
import { validateGitLabServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gitlab'
112114
import { validateHarmonicServiceAccount } from '@/lib/credentials/token-service-accounts/validators/harmonic'
113115
import { validateHiggsfieldServiceAccount } from '@/lib/credentials/token-service-accounts/validators/higgsfield'
114116
import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot'
@@ -324,6 +326,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record<
324326
[AGENTMAIL_SERVICE_ACCOUNT_PROVIDER_ID]: validateAgentMailServiceAccount,
325327
[AFFINITY_SERVICE_ACCOUNT_PROVIDER_ID]: validateAffinityServiceAccount,
326328
[TAVILY_SERVICE_ACCOUNT_PROVIDER_ID]: validateTavilyServiceAccount,
329+
[GITLAB_SERVICE_ACCOUNT_PROVIDER_ID]: validateGitLabServiceAccount,
327330
[ASHBY_SERVICE_ACCOUNT_PROVIDER_ID]: validateAshbyServiceAccount,
328331
[CALENDLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalendlyServiceAccount,
329332
[CHECKR_SERVICE_ACCOUNT_PROVIDER_ID]: validateCheckrServiceAccount,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { jsonResponse } from '@sim/testing/helpers/http'
5+
import {
6+
inputValidationMock,
7+
inputValidationMockFns,
8+
} from '@sim/testing/mocks/input-validation.mock'
9+
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
11+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
12+
13+
import { validateGitLabServiceAccount } from '@/lib/credentials/token-service-accounts/validators/gitlab'
14+
15+
const activeToken = { name: 'sim-workflows', active: true, revoked: false, scopes: ['api'] }
16+
17+
describe('GitLab saved token verification', () => {
18+
const fetch = inputValidationMockFns.mockSecureFetchWithValidation
19+
20+
beforeEach(() => {
21+
fetch.mockReset()
22+
})
23+
24+
it('verifies the token at the host it was typed with and stores that host', async () => {
25+
fetch.mockResolvedValue(jsonResponse(activeToken))
26+
27+
await expect(
28+
validateGitLabServiceAccount({ apiToken: 'glpat-x', domain: 'https://gitlab.example.com/' })
29+
).resolves.toMatchObject({ normalizedDomain: 'gitlab.example.com' })
30+
expect(fetch).toHaveBeenCalledWith(
31+
'https://gitlab.example.com/api/v4/personal_access_tokens/self',
32+
expect.objectContaining({ maxRedirects: 0, profile: 'configuredEndpoint' })
33+
)
34+
})
35+
36+
it('refuses a host that could send the token somewhere else, without calling it', async () => {
37+
await expect(
38+
validateGitLabServiceAccount({ apiToken: 'glpat-x', domain: 'evil.example.com@gitlab.com' })
39+
).rejects.toMatchObject({ code: 'invalid_credentials' })
40+
expect(fetch).not.toHaveBeenCalled()
41+
})
42+
43+
it.each([
44+
['a revoked token', { ...activeToken, active: false, revoked: true }],
45+
['a token without an API scope', { ...activeToken, scopes: ['read_repository'] }],
46+
])('rejects %s', async (_, token) => {
47+
fetch.mockResolvedValue(jsonResponse(token))
48+
49+
await expect(
50+
validateGitLabServiceAccount({ apiToken: 'glpat-x', domain: 'gitlab.com' })
51+
).rejects.toMatchObject({ code: 'invalid_credentials' })
52+
})
53+
54+
it('reports an unreachable GitLab instance as unavailable, not as a bad token', async () => {
55+
fetch.mockResolvedValue(jsonResponse({ message: 'Bad Gateway' }, 502))
56+
57+
await expect(
58+
validateGitLabServiceAccount({ apiToken: 'glpat-x', domain: 'gitlab.com' })
59+
).rejects.toMatchObject({ code: 'provider_unavailable' })
60+
})
61+
})

0 commit comments

Comments
 (0)