Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server-nestjs/.env.integ-example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ GITLAB_TOKEN=
GITLAB_URL=
# URL interne de l'API Gitlab
GITLAB_INTERNAL_URL=
# Durée de validité (en jours) des tokens d'accès groupe utilisés pour le miroir
GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS=365
# Seuil (en jours) de rotation préventive des tokens, strictement inférieur à GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS
GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS=250

# --- Harbor ---
# Nom d'utilisateur admin Harbor
Expand Down
23 changes: 23 additions & 0 deletions apps/server-nestjs/src/config/gitlab.config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ describe('gitlabConfig', () => {
vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal:8080')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1')
vi.stubEnv('GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS', '90')
vi.stubEnv('GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS', '365')
expect(gitlabConfigFactory()).toMatchObject({
token: 'token',
url: 'https://gitlab.internal',
internalUrl: 'https://gitlab.internal:8080',
secretExposeInternalUrl: true,
mirrorTokenRotationThresholdDays: 90,
mirrorTokenExpirationDays: 365,
projectRootDir: 'forge-test/projects',
})
Expand All @@ -30,6 +33,26 @@ describe('gitlabConfig', () => {
const cfg = gitlabConfigFactory()
expect(cfg.internalUrl).toBeUndefined()
expect(cfg.url).toBe('https://gitlab.internal')
expect(cfg.mirrorTokenRotationThresholdDays).toBe(250)
expect(cfg.mirrorTokenExpirationDays).toBe(365)
})

it('throws when rotation threshold is not strictly lower than expiration', () => {
vi.stubEnv('GITLAB_TOKEN', 'token')
vi.stubEnv('GITLAB_URL', 'https://gitlab.internal')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS', '180')
vi.stubEnv('GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS', '180')
expect(() => gitlabConfigFactory()).toThrow(/must be strictly lower than GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS/)
})

it('throws when rotation threshold exceeds expiration', () => {
vi.stubEnv('GITLAB_TOKEN', 'token')
vi.stubEnv('GITLAB_URL', 'https://gitlab.internal')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS', '365')
vi.stubEnv('GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS', '90')
expect(() => gitlabConfigFactory()).toThrow()
})

it('throws when a required var is missing', () => {
Expand Down
11 changes: 11 additions & 0 deletions apps/server-nestjs/src/config/gitlab.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@ const gitlabFeatureSchema = z.object({
GITLAB_URL: z.string().url(),
GITLAB_INTERNAL_URL: z.string().url().optional(),
GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(365),
GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(250),
GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'),
PROJECTS_ROOT_DIR: z.string().min(1),
}).superRefine((raw, ctx) => {
// Rotation must trigger strictly before expiration, otherwise tokens die
// before any regeneration had a chance to replace them.
if (raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS >= raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS (${raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS}) must be strictly lower than GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS (${raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS})`,
})
}
}).transform(raw => ({
token: raw.GITLAB_TOKEN,
url: raw.GITLAB_URL,
internalUrl: raw.GITLAB_INTERNAL_URL,
secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL,
mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS,
mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS,
projectRootDir: raw.PROJECTS_ROOT_DIR,
}))

Expand Down
15 changes: 15 additions & 0 deletions apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
RepositoryTreeSchema,
} from '@gitbeaker/core'
import type { AdminRole, Project, User } from '@prisma/client'
import type { VaultSecret } from '../vault/vault-client.service'
import type { ProjectWithDetails } from './gitlab-datastore.service'
import { faker } from '@faker-js/faker'
import { AccessLevel } from '@gitbeaker/core'
Expand Down Expand Up @@ -391,6 +392,20 @@ export function makeAccessTokenExposedSchema(overrides: Partial<AccessTokenExpos
} satisfies AccessTokenExposedSchema
}

export function makeVaultSecret(overrides: Partial<VaultSecret> = {}): VaultSecret {
return {
data: {},
metadata: {
created_time: faker.date.recent({ days: 30 }).toISOString(),
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
...overrides,
} satisfies VaultSecret
}

export function makeRepositoryFileExpandedSchema(overrides: Partial<RepositoryFileExpandedSchema> = {}) {
return {
file_name: 'file.txt',
Expand Down
91 changes: 89 additions & 2 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { OBSERVABILITY_REPOSITORY } from '../observability/observability.constan
import { VaultClientService } from '../vault/vault-client.service'
import { GitlabClientService } from './gitlab-client.service'
import { GitlabDatastoreService } from './gitlab-datastore.service'
import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipeline, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils'
import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipeline, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails, makeVaultSecret } from './gitlab-testing.utils'
import { INFRA_APPS_REPO_NAME, MIRROR_REPO_NAME, PLUGIN_NAME, TOPIC_PLUGIN_MANAGED, TOPIC_SYSTEM_MANAGED } from './gitlab.constants'
import { GitlabService } from './gitlab.service'

Expand All @@ -36,7 +36,7 @@ describe('gitlabService', () => {
readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null),
readGitlabMirrorCreds: vi.fn().mockResolvedValue(null),
})
config = mockDeep<ConfigType<typeof gitlabConfigFactory>>({ projectRootDir: 'forge', url: 'https://gitlab.example.com' })
config = mockDeep<ConfigType<typeof gitlabConfigFactory>>({ projectRootDir: 'forge', url: 'https://gitlab.example.com', mirrorTokenRotationThresholdDays: 250 })

const moduleRef = await Test.createTestingModule({
providers: [
Expand Down Expand Up @@ -527,6 +527,93 @@ describe('gitlabService', () => {
MIRROR_TOKEN: accessToken.token,
})
})

it('reuses the mirror token when the vault secret is younger than the rotation threshold', async () => {
const project = makeProjectWithDetails({
slug: 'project-1',
repositories: [{
id: 'r1',
internalRepoName: 'repo-1',
externalRepoUrl: 'https://github.com/org/repo.git',
isPrivate: true,
externalUserName: 'user',
isInfra: false,
}],
})
const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 })
const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' })
const accessToken = makeAccessTokenExposedSchema({ name: 'bot', scopes: ['read_api'], access_level: 40 })
const recentSecret = makeVaultSecret({
data: { MIRROR_USER: accessToken.name, MIRROR_TOKEN: accessToken.token },
metadata: {
created_time: faker.date.recent({ days: 30 }).toISOString(),
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
})

gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group)
gitlab.getGroupMembers.mockResolvedValue([])
gitlab.getProjectGroup.mockResolvedValue(group)
gitlab.getProjectToken.mockResolvedValue({ name: accessToken.name, id: 11 })
gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })())
gitlab.getOrCreateProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git')
gitlab.createMirrorAccessToken.mockResolvedValue(accessToken)
gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false }))
gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken())
vault.readTechnReadOnlyCreds.mockResolvedValue(recentSecret)

await service.handleUpsert(project)

expect(gitlab.revokeProjectToken).not.toHaveBeenCalled()
expect(gitlab.createMirrorAccessToken).not.toHaveBeenCalled()
})

it('proactively rotates the mirror token when the vault secret is older than the rotation threshold', async () => {
const project = makeProjectWithDetails({
slug: 'project-1',
repositories: [{
id: 'r1',
internalRepoName: 'repo-1',
externalRepoUrl: 'https://github.com/org/repo.git',
isPrivate: true,
externalUserName: 'user',
isInfra: false,
}],
})
const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 })
const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' })
const accessToken = makeAccessTokenExposedSchema({ name: 'bot', scopes: ['read_api'], access_level: 40 })
const staleSecret = makeVaultSecret({
data: { MIRROR_USER: accessToken.name, MIRROR_TOKEN: accessToken.token },
metadata: {
created_time: faker.date.past({ years: 2 }).toISOString(),
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
})

gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group)
gitlab.getGroupMembers.mockResolvedValue([])
gitlab.getProjectGroup.mockResolvedValue(group)
gitlab.getProjectToken.mockResolvedValue({ name: accessToken.name, id: 11 })
gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })())
gitlab.getOrCreateProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git')
gitlab.createMirrorAccessToken.mockResolvedValue(accessToken)
gitlab.revokeProjectToken.mockResolvedValue(undefined)
gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false }))
gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken())
vault.readTechnReadOnlyCreds.mockResolvedValue(staleSecret)

await service.handleUpsert(project)

expect(gitlab.revokeProjectToken).toHaveBeenCalledWith(group, 11)
expect(gitlab.createMirrorAccessToken).toHaveBeenCalledWith('project-1')
})
})

describe('handleRepositorySync', () => {
Expand Down
31 changes: 15 additions & 16 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { CondensedGroupSchema, MemberSchema } from '@gitbeaker/core'
import type { GroupSchemaWith } from './gitlab-client.service'
import type { MemberSchema } from '@gitbeaker/core'
import type { ConfigType } from '@nestjs/config'
import type { RepositorySyncEventPayload } from '../events/app-events.service'
import type { RequiredPluginResult } from '../plugin/plugin.utils'
import type { MirrorUserSecret } from '../vault/vault-client.service'
import type { MirrorUserSecret, VaultSecret } from '../vault/vault-client.service'
import type { GroupSchemaWith } from './gitlab-client.service'
import type { ProjectWithDetails } from './gitlab-datastore.service'
import { specificallyEnabled } from '@cpn-console/hooks'
import { AccessLevel } from '@gitbeaker/core'
Expand Down Expand Up @@ -35,6 +35,7 @@ import {
} from './gitlab.constants'
import {
adminRoleFlag,
daysAgoFromNow,
generateAccessLevelMapping,
generateAdminRoleMapping,
generateName,
Expand Down Expand Up @@ -507,12 +508,14 @@ export class GitlabService {
if (!group) throw new Error(`No group found for project ${project.slug}`)
const currentToken = await this.gitlab.getProjectToken(group, project.slug)
if (currentToken) {
const vaultSecret = await this.getMirrorTokenFromVault(project)
if (vaultSecret) {
const vaultSecret = await this.vault.readTechnReadOnlyCreds(project.slug)
const expired = this.isMirrorTokenExpiring(vaultSecret)
span?.setAttribute('mirror.creds.expiring', expired)
if (vaultSecret && !expired) {
span?.setAttribute('mirror.creds.rotated', false)
return vaultSecret
return vaultSecret.data
}
this.logger.warn(`Mirror token invalid or vault secret missing, revoking (projectSlug=${project.slug}, tokenId=${currentToken.id})`)
this.logger.warn(`Mirror token invalid, expiring or vault secret missing, revoking (projectSlug=${project.slug}, tokenId=${currentToken.id})`)
span?.setAttribute('mirror.creds.revoking', true)
await this.gitlab.revokeProjectToken(group, currentToken.id).catch((err) => {
this.logger.error(`Failed to revoke stale mirror token (projectSlug=${project.slug}, tokenId=${currentToken.id}): ${err}`)
Expand All @@ -522,15 +525,11 @@ export class GitlabService {
return this.createMirrorAccessToken(project)
}

private async getMirrorTokenFromVault(project: ProjectWithDetails): Promise<MirrorUserSecret | undefined> {
const vaultSecret = await this.vault.readTechnReadOnlyCreds(project.slug)
const vaultToken = vaultSecret?.data?.MIRROR_TOKEN
if (vaultToken) {
const isValid = await this.gitlab.validateProjectToken(vaultToken)
if (isValid) {
return vaultSecret.data
}
}
private isMirrorTokenExpiring(vaultSecret: VaultSecret<MirrorUserSecret> | null | undefined): boolean {
const createdTimeRaw = vaultSecret?.metadata?.created_time
if (!createdTimeRaw) return false
const createdTime = new Date(createdTimeRaw)
return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays
}

@StartActiveSpan()
Expand Down