diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index 90c4969233..ae83980f01 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -48,6 +48,10 @@ HARBOR_ADMIN_PASSWORD= HARBOR_URL= # URL interne de l'API Harbor HARBOR_INTERNAL_URL= +# Durée de validité (en jours) des robots Harbor +HARBOR_ROBOT_EXPIRATION_DAYS=90 +# Seuil (en jours) de rotation préventive des secrets, strictement inférieur à HARBOR_ROBOT_EXPIRATION_DAYS +HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS=60 # --- Keycloak (admin) --- # Utilisateur admin Keycloak pour la gestion des royaumes/clients diff --git a/apps/server-nestjs/src/config/harbor.config.spec.ts b/apps/server-nestjs/src/config/harbor.config.spec.ts index b1151196c2..7a8550d1ce 100644 --- a/apps/server-nestjs/src/config/harbor.config.spec.ts +++ b/apps/server-nestjs/src/config/harbor.config.spec.ts @@ -3,7 +3,7 @@ import { resetEnvs } from './config-testing.utils' import { harborConfigFactory } from './harbor.config' describe('harborConfig', () => { - beforeEach(() => { resetEnvs(['HARBOR_URL', 'HARBOR_INTERNAL_URL', 'HARBOR_ADMIN', 'HARBOR_ADMIN_PASSWORD', 'HARBOR_RULE_TEMPLATE', 'HARBOR_RULE_COUNT', 'HARBOR_RETENTION_CRON', 'HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', 'HARBOR_PROJECT_SLUG_CACHE_TTL_MS']) }) + beforeEach(() => { resetEnvs(['HARBOR_URL', 'HARBOR_INTERNAL_URL', 'HARBOR_ADMIN', 'HARBOR_ADMIN_PASSWORD', 'HARBOR_RULE_TEMPLATE', 'HARBOR_RULE_COUNT', 'HARBOR_RETENTION_CRON', 'HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', 'HARBOR_ROBOT_EXPIRATION_DAYS', 'HARBOR_PROJECT_SLUG_CACHE_TTL_MS']) }) afterEach(() => { vi.unstubAllEnvs() }) it('parses a full config', () => { @@ -13,6 +13,8 @@ describe('harborConfig', () => { vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') vi.stubEnv('HARBOR_RULE_TEMPLATE', 'always') vi.stubEnv('HARBOR_RULE_COUNT', '3') + vi.stubEnv('HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', '30') + vi.stubEnv('HARBOR_ROBOT_EXPIRATION_DAYS', '60') expect(harborConfigFactory()).toMatchObject({ url: 'https://harbor.internal', internalUrl: 'https://harbor.internal:8080', @@ -21,7 +23,8 @@ describe('harborConfig', () => { ruleTemplate: 'always', ruleCount: 3, retentionCron: '0 22 2 * * *', - robotRotationThresholdDays: 90, + robotRotationThresholdDays: 30, + robotExpirationDays: 60, projectSlugCacheTtlMs: 300_000, }) }) @@ -45,6 +48,26 @@ describe('harborConfig', () => { expect(cfg.ruleTemplate).toBeUndefined() expect(cfg.ruleCount).toBeUndefined() expect(cfg.retentionCron).toBe('0 22 2 * * *') + expect(cfg.robotRotationThresholdDays).toBe(60) + expect(cfg.robotExpirationDays).toBe(90) + }) + + it('throws when rotation threshold is not strictly lower than expiration', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.internal') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', '180') + vi.stubEnv('HARBOR_ROBOT_EXPIRATION_DAYS', '180') + expect(() => harborConfigFactory()).toThrow(/must be strictly lower than HARBOR_ROBOT_EXPIRATION_DAYS/) + }) + + it('throws when rotation threshold exceeds expiration', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.internal') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', '365') + vi.stubEnv('HARBOR_ROBOT_EXPIRATION_DAYS', '90') + expect(() => harborConfigFactory()).toThrow() }) it('throws when a required var is missing', () => { diff --git a/apps/server-nestjs/src/config/harbor.config.ts b/apps/server-nestjs/src/config/harbor.config.ts index 94395a1c1a..9cda6ac889 100644 --- a/apps/server-nestjs/src/config/harbor.config.ts +++ b/apps/server-nestjs/src/config/harbor.config.ts @@ -20,8 +20,18 @@ const harborFeatureSchema = z.object({ HARBOR_RULE_TEMPLATE: ruleTemplateSchema.optional(), HARBOR_RULE_COUNT: z.coerce.number().int().positive().optional(), HARBOR_RETENTION_CRON: cronSchema.default('0 22 2 * * *'), - HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(60), + HARBOR_ROBOT_EXPIRATION_DAYS: z.coerce.number().int().positive().default(90), HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).superRefine((raw, ctx) => { + // Rotation must trigger strictly before expiration, otherwise robots die + // before any secret regeneration had a chance to replace them. + if (raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS >= raw.HARBOR_ROBOT_EXPIRATION_DAYS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS (${raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS}) must be strictly lower than HARBOR_ROBOT_EXPIRATION_DAYS (${raw.HARBOR_ROBOT_EXPIRATION_DAYS})`, + }) + } }).transform(raw => ({ url: raw.HARBOR_URL, internalUrl: raw.HARBOR_INTERNAL_URL, @@ -31,6 +41,7 @@ const harborFeatureSchema = z.object({ ruleCount: raw.HARBOR_RULE_COUNT, retentionCron: raw.HARBOR_RETENTION_CRON, robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + robotExpirationDays: raw.HARBOR_ROBOT_EXPIRATION_DAYS, projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, })) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index e10a3da40b..3345ef61bf 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -69,6 +69,7 @@ describe('registryService', () => { ruleCount: 10, retentionCron: '0 22 2 * * *', robotRotationThresholdDays: 90, + robotExpirationDays: 90, }) baseConfig = mockDeep>({ projectsRootDir: 'forge', @@ -274,6 +275,35 @@ describe('registryService', () => { }), `forge/${project.slug}/REGISTRY/ro-robot`) }) + it('creates robots with the configured expiration duration', async () => { + const project = makeProjectWithDetails() + vault.read.mockImplementation(async () => makeVaultSecret({ + data: { + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+robot`, + TOKEN: 'old', + }, + })) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.ensureRobot.mockResolvedValue({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' }) + + await service.handleUpsert(project) + + expect(client.ensureRobot).toHaveBeenCalledWith(expect.objectContaining({ + name: 'ro-robot', + duration: 90, + })) + expect(client.ensureRobot).toHaveBeenCalledWith(expect.objectContaining({ + name: 'rw-robot', + duration: 90, + })) + }) + it('parses plugin config and enables project robot publishing', async () => { const project = makeProjectWithDetails({ plugins: [ diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index eaa6be40f3..bd0ce56dac 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -80,7 +80,7 @@ export class RegistryService { private async ensureProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { const created = await this.client.ensureRobot( - generateRobotPermissions(project, robotName, access), + generateRobotPermissions(project, robotName, access, this.harborConfig.robotExpirationDays), ) if (!created) { throw new Error(`Harbor robot already exists (${robotName})`) @@ -435,10 +435,10 @@ function generateRobotFullName(project: ProjectWithDetails, robotName: string) { return `robot$${project.slug}+${robotName}` } -function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest { +function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[], durationDays: number): HarborRobotCreateRequest { return { name: robotName, - duration: -1, + duration: durationDays, description: 'robot for ci builds', disable: false, level: 'project',