diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index 90c4969233..070e07d956 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -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 diff --git a/apps/server-nestjs/src/config/gitlab.config.spec.ts b/apps/server-nestjs/src/config/gitlab.config.spec.ts index 6b973746ed..dcdf61bb3a 100644 --- a/apps/server-nestjs/src/config/gitlab.config.spec.ts +++ b/apps/server-nestjs/src/config/gitlab.config.spec.ts @@ -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', }) @@ -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', () => { diff --git a/apps/server-nestjs/src/config/gitlab.config.ts b/apps/server-nestjs/src/config/gitlab.config.ts index 9367175272..182ceea1b4 100644 --- a/apps/server-nestjs/src/config/gitlab.config.ts +++ b/apps/server-nestjs/src/config/gitlab.config.ts @@ -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, })) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts index 7f19aaa168..3fcf53bd72 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts @@ -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' @@ -391,6 +392,20 @@ export function makeAccessTokenExposedSchema(overrides: Partial = {}): 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 = {}) { return { file_name: 'file.txt', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index 7769d0832a..33b81f4f48 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -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' @@ -36,7 +36,7 @@ describe('gitlabService', () => { readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), }) - config = mockDeep>({ projectRootDir: 'forge', url: 'https://gitlab.example.com' }) + config = mockDeep>({ projectRootDir: 'forge', url: 'https://gitlab.example.com', mirrorTokenRotationThresholdDays: 250 }) const moduleRef = await Test.createTestingModule({ providers: [ @@ -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', () => { diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index f26b5ea573..4af900a9d4 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -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' @@ -35,6 +35,7 @@ import { } from './gitlab.constants' import { adminRoleFlag, + daysAgoFromNow, generateAccessLevelMapping, generateAdminRoleMapping, generateName, @@ -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}`) @@ -522,15 +525,11 @@ export class GitlabService { return this.createMirrorAccessToken(project) } - private async getMirrorTokenFromVault(project: ProjectWithDetails): Promise { - 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 | 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()