diff --git a/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts b/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts index adb181e8e23..289d16dc73e 100644 --- a/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts +++ b/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts @@ -34,7 +34,7 @@ export class UpdateGitConnectionDto extends Z.class({ password: z.string().min(1).optional(), }) {} -export class ConnectGitConnectionDto extends Z.class({ +export class CloneGitConnectionDto extends Z.class({ branchName: branchNameSchema.optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index ddab9856807..8a4e59a7d42 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -99,7 +99,7 @@ export { export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto'; export { type GitCommitInfo } from './source-control/push-work-folder-response.dto'; export { - ConnectGitConnectionDto, + CloneGitConnectionDto, CreateGitConnectionDto, GitConnectionListPublicDto, GitConnectionPublicDto, diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 8cb0e17edb8..7335c9e3695 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -69,6 +69,12 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = ` "sourceControl:pull", "sourceControl:push", "sourceControl:manage", + "gitConnection:create", + "gitConnection:read", + "gitConnection:update", + "gitConnection:delete", + "gitConnection:list", + "gitConnection:clone", "tag:create", "tag:read", "tag:update", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 4f3c4117b2b..44425200f20 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -34,6 +34,7 @@ export const RESOURCES = { securityAudit: ['generate'] as const, securitySettings: ['manage'] as const, sourceControl: ['pull', 'push', 'manage'] as const, + gitConnection: [...DEFAULT_OPERATIONS, 'clone'] as const, tag: [...DEFAULT_OPERATIONS] as const, user: [ 'resetPassword', @@ -107,6 +108,7 @@ export const API_KEY_RESOURCES = { credential: ['create', 'read', 'update', 'move', 'delete', 'list'] as const, eventBusDestination: ['test', 'create', 'read', 'update', 'delete', 'list'] as const, sourceControl: ['pull'] as const, + gitConnection: [...DEFAULT_OPERATIONS, 'clone'] as const, workflowTags: ['update', 'list'] as const, executionTags: ['update', 'list'] as const, communityPackage: ['install', 'uninstall', 'update', 'list'] as const, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 55d02a5b5d1..749b96c2406 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -13,6 +13,12 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'user:changeRole', 'user:delete', 'sourceControl:pull', + 'gitConnection:create', + 'gitConnection:read', + 'gitConnection:update', + 'gitConnection:delete', + 'gitConnection:list', + 'gitConnection:clone', 'securityAudit:generate', 'securitySettings:manage', 'saml:manage', diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index f310a6d0481..defad834276 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -59,6 +59,12 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'sourceControl:pull', 'sourceControl:push', 'sourceControl:manage', + 'gitConnection:create', + 'gitConnection:read', + 'gitConnection:update', + 'gitConnection:delete', + 'gitConnection:list', + 'gitConnection:clone', 'tag:create', 'tag:read', 'tag:update', diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index 9cdd522ac4f..1cf1efde89e 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -133,4 +133,28 @@ export const scopeInformation: Partial> = { displayName: 'Manage project roles', description: 'Allows creating, editing, and deleting project role definitions.', }, + 'gitConnection:create': { + displayName: 'Create Git Connection', + description: 'Allows creating Git connections and their authentication material.', + }, + 'gitConnection:read': { + displayName: 'Read Git Connection', + description: 'Allows reading Git connection configuration. Secrets are never returned.', + }, + 'gitConnection:update': { + displayName: 'Update Git Connection', + description: 'Allows updating Git connections, including their authentication material.', + }, + 'gitConnection:delete': { + displayName: 'Delete Git Connection', + description: 'Allows deleting Git connections and their local files.', + }, + 'gitConnection:list': { + displayName: 'List Git Connections', + description: 'Allows listing Git connections.', + }, + 'gitConnection:clone': { + displayName: 'Clone Git Connection', + description: 'Allows cloning and removing the local working copy of a Git connection.', + }, }; diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 54be7a0d8d1..2a9ef9cf9e7 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -30,6 +30,7 @@ describe('permissions', () => { securityAudit: {}, securitySettings: {}, sourceControl: {}, + gitConnection: {}, tag: {}, user: {}, variable: {}, @@ -129,6 +130,7 @@ describe('permissions', () => { securityAudit: {}, securitySettings: {}, sourceControl: {}, + gitConnection: {}, tag: { create: true, list: true, diff --git a/packages/cli/src/modules/git-connections.ee/git-connections.service.ts b/packages/cli/src/modules/git-connections.ee/git-connections.service.ts index f319daad240..820ac8f02a4 100644 --- a/packages/cli/src/modules/git-connections.ee/git-connections.service.ts +++ b/packages/cli/src/modules/git-connections.ee/git-connections.service.ts @@ -84,10 +84,10 @@ export class GitConnectionsService { return this.toPublic(saved); } - async connect(id: string, branchName?: string) { + async clone(id: string, branchName?: string) { const connection = await this.getEntity(id); const effectiveBranch = branchName ?? connection.branchName; - if (!effectiveBranch) throw new BadRequestError('A branch name is required to connect'); + if (!effectiveBranch) throw new BadRequestError('A branch name is required to clone'); const credentials = await this.decryptCredentials(connection); await this.gitService.clone({ connection, diff --git a/packages/cli/src/public-api/v1/controllers/git-connections.public.controller.ts b/packages/cli/src/public-api/v1/controllers/git-connections.public.controller.ts new file mode 100644 index 00000000000..bc30ef5ef0d --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/git-connections.public.controller.ts @@ -0,0 +1,196 @@ +import { + CloneGitConnectionDto, + CreateGitConnectionDto, + GitConnectionListPublicDto, + GitConnectionPublicDto, + ListGitConnectionsQueryDto, + MAX_ITEMS_PER_PAGE, + UpdateGitConnectionDto, +} from '@n8n/api-types'; +import { ModuleRegistry } from '@n8n/backend-common'; +import { LICENSE_FEATURES } from '@n8n/constants'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { + ApiDescription, + ApiErrorResponse, + ApiKeyScope, + ApiResponse, + ApiSummary, + ApiTags, + Body, + Delete, + Get, + GlobalScope, + Licensed, + Param, + Post, + PublicApiController, + Put, + Query, +} from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import type { Response } from 'express'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; +import { decodeCursor, encodeNextCursor } from '@/public-api/v1/shared/services/pagination.service'; + +const tags = ['GitConnections']; + +@PublicApiController('/git-connections') +export class GitConnectionsPublicController { + constructor(private readonly moduleRegistry: ModuleRegistry) {} + + private async gitConnectionsService() { + if (!this.moduleRegistry.isActive('git-connections')) { + throw new ServiceUnavailableError('Git connections module is not enabled'); + } + const { GitConnectionsService } = await import( + '@/modules/git-connections.ee/git-connections.service.js' + ); + return Container.get(GitConnectionsService); + } + + @Post('/') + @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) + @ApiKeyScope('gitConnection:create') + @GlobalScope('gitConnection:create') + @ApiSummary('Create a Git connection') + @ApiDescription('Creates a Git connection and its authentication material.') + @ApiTags(tags) + @ApiResponse(201, GitConnectionPublicDto) + async createGitConnection( + _req: AuthenticatedRequest, + _res: Response, + @Body input: CreateGitConnectionDto, + ): Promise { + return await (await this.gitConnectionsService()).create(input); + } + + @Get('/') + @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) + @ApiKeyScope('gitConnection:list') + @GlobalScope('gitConnection:list') + @ApiSummary('List Git connections') + @ApiDescription('Returns a cursor-paginated list of Git connections.') + @ApiTags(tags) + @ApiResponse(200, GitConnectionListPublicDto) + async getGitConnections( + _req: AuthenticatedRequest, + _res: Response, + @Query query: ListGitConnectionsQueryDto, + ): Promise { + let offset = 0; + let { limit } = query; + if (query.cursor) { + try { + const cursor = decodeCursor(query.cursor); + if (!('offset' in cursor)) throw new BadRequestError('An invalid cursor was provided'); + offset = cursor.offset; + limit = cursor.limit; + } catch (error) { + if (error instanceof BadRequestError) throw error; + throw new BadRequestError('An invalid cursor was provided'); + } + // A cursor is unsigned base64 the client can forge, so re-validate the + // bounds already enforced on the raw query params before hitting the DB. + if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1) { + throw new BadRequestError('An invalid cursor was provided'); + } + limit = Math.min(limit, MAX_ITEMS_PER_PAGE); + } + const { data, count } = await (await this.gitConnectionsService()).list(offset, limit); + return { + data, + nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), + }; + } + + @Get('/:id') + @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) + @ApiKeyScope('gitConnection:read') + @GlobalScope('gitConnection:read') + @ApiSummary('Retrieve a Git connection') + @ApiTags(tags) + @ApiResponse(200, GitConnectionPublicDto) + @ApiErrorResponse(404) + async getGitConnection( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + ): Promise { + return await (await this.gitConnectionsService()).findOne(id); + } + + @Put('/:id') + @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) + @ApiKeyScope('gitConnection:update') + @GlobalScope('gitConnection:update') + @ApiSummary('Update a Git connection') + @ApiDescription('Updates only the supplied fields. Secrets are never returned.') + @ApiTags(tags) + @ApiResponse(200, GitConnectionPublicDto) + @ApiErrorResponse(404) + async updateGitConnection( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + @Body input: UpdateGitConnectionDto, + ): Promise { + return await (await this.gitConnectionsService()).update(id, input); + } + + @Post('/:id/clone') + @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) + @ApiKeyScope('gitConnection:clone') + @GlobalScope('gitConnection:clone') + @ApiSummary('Clone a Git connection') + @ApiDescription('Clones the repository into local storage. Safe to call repeatedly.') + @ApiTags(tags) + @ApiResponse(200, GitConnectionPublicDto) + @ApiErrorResponse(404) + async cloneGitConnection( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + @Body input: CloneGitConnectionDto, + ): Promise { + return await (await this.gitConnectionsService()).clone(id, input.branchName); + } + + @Post('/:id/disconnect') + @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) + @ApiKeyScope('gitConnection:clone') + @GlobalScope('gitConnection:clone') + @ApiSummary('Disconnect a Git connection') + @ApiDescription( + 'Removes the local clone. The connection and its authentication material are retained.', + ) + @ApiTags(tags) + @ApiResponse(200, GitConnectionPublicDto) + @ApiErrorResponse(404) + async disconnectGitConnection( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + ): Promise { + return await (await this.gitConnectionsService()).disconnect(id); + } + + @Delete('/:id') + @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) + @ApiKeyScope('gitConnection:delete') + @GlobalScope('gitConnection:delete') + @ApiSummary('Delete a Git connection') + @ApiDescription('Deletes a Git connection and its local files.') + @ApiTags(tags) + @ApiResponse(204) + @ApiErrorResponse(404) + async deleteGitConnection( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + ): Promise { + await (await this.gitConnectionsService()).delete(id); + } +} diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index a45e4de3020..776e7611a11 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -3,6 +3,7 @@ * decorator metadata is registered before PublicApiControllerRegistry / * scope-parity / discover run. */ +import './git-connections.public.controller'; import './role-mapping-rules.public.controller'; import './roles.public.controller'; import './tags.public.controller'; diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/cloneGitConnection.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/cloneGitConnection.generated.yml new file mode 100644 index 00000000000..8e28fc57015 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/cloneGitConnection.generated.yml @@ -0,0 +1,40 @@ +operationId: cloneGitConnection +tags: + - GitConnections +summary: Clone a Git connection +description: Clones the repository into local storage. Safe to call repeatedly. +x-required-scope: gitConnection:clone +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: id + in: path +requestBody: + content: + application/json: + schema: + type: object + properties: + branchName: + type: string + minLength: 1 + maxLength: 255 +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/createGitConnection.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/createGitConnection.generated.yml new file mode 100644 index 00000000000..8e53f9d648f --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/createGitConnection.generated.yml @@ -0,0 +1,59 @@ +operationId: createGitConnection +tags: + - GitConnections +summary: Create a Git connection +description: Creates a Git connection and its authentication material. +x-required-scope: gitConnection:create +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 128 + repositoryUrl: + type: string + minLength: 1 + branchName: + type: string + minLength: 1 + maxLength: 255 + connectionType: + type: string + enum: + - ssh + - https + keyGeneratorType: + type: string + enum: + - ed25519 + - rsa + username: + type: string + minLength: 1 + password: + type: string + minLength: 1 + required: + - name + - repositoryUrl + - connectionType +responses: + '201': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/deleteGitConnection.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/deleteGitConnection.generated.yml new file mode 100644 index 00000000000..7d7a61c4de4 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/deleteGitConnection.generated.yml @@ -0,0 +1,24 @@ +operationId: deleteGitConnection +tags: + - GitConnections +summary: Delete a Git connection +description: Deletes a Git connection and its local files. +x-required-scope: gitConnection:delete +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: id + in: path +responses: + '204': + description: Operation successful. + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/disconnectGitConnection.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/disconnectGitConnection.generated.yml new file mode 100644 index 00000000000..e84f015e6db --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/disconnectGitConnection.generated.yml @@ -0,0 +1,28 @@ +operationId: disconnectGitConnection +tags: + - GitConnections +summary: Disconnect a Git connection +description: Removes the local clone. The connection and its authentication material are retained. +x-required-scope: gitConnection:clone +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: id + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/getGitConnection.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/getGitConnection.generated.yml new file mode 100644 index 00000000000..5ded374fe82 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/getGitConnection.generated.yml @@ -0,0 +1,27 @@ +operationId: getGitConnection +tags: + - GitConnections +summary: Retrieve a Git connection +x-required-scope: gitConnection:read +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: id + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/getGitConnections.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/getGitConnections.generated.yml new file mode 100644 index 00000000000..6f5b229e707 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/getGitConnections.generated.yml @@ -0,0 +1,76 @@ +operationId: getGitConnections +tags: + - GitConnections +summary: List Git connections +description: Returns a cursor-paginated list of Git connections. +x-required-scope: gitConnection:list +parameters: + - $ref: ../../../../shared/spec/parameters/limit.yml + - $ref: ../../../../shared/spec/parameters/cursor.yml +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + repositoryUrl: + type: string + branchName: + type: string + nullable: true + connectionType: + type: string + enum: + - ssh + - https + keyGeneratorType: + type: string + nullable: true + enum: + - ed25519 + - rsa + baseCommit: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - name + - repositoryUrl + - branchName + - connectionType + - keyGeneratorType + - baseCommit + - createdAt + - updatedAt + nextCursor: + type: string + nullable: true + required: + - data + - nextCursor + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/updateGitConnection.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/updateGitConnection.generated.yml new file mode 100644 index 00000000000..5070c317357 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/updateGitConnection.generated.yml @@ -0,0 +1,63 @@ +operationId: updateGitConnection +tags: + - GitConnections +summary: Update a Git connection +description: Updates only the supplied fields. Secrets are never returned. +x-required-scope: gitConnection:update +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: id + in: path +requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 128 + repositoryUrl: + type: string + minLength: 1 + branchName: + type: string + minLength: 1 + maxLength: 255 + connectionType: + type: string + enum: + - ssh + - https + keyGeneratorType: + type: string + enum: + - ed25519 + - rsa + username: + type: string + minLength: 1 + password: + type: string + minLength: 1 +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/gitConnectionPublicDto.generated.yml + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 42ae16853ea..1cf210b065c 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -3,6 +3,24 @@ info: title: decorator-routes version: 0.0.0 paths: + /git-connections: + post: + $ref: ./handlers/git-connections/spec/paths/createGitConnection.generated.yml + get: + $ref: ./handlers/git-connections/spec/paths/getGitConnections.generated.yml + /git-connections/{id}: + get: + $ref: ./handlers/git-connections/spec/paths/getGitConnection.generated.yml + put: + $ref: ./handlers/git-connections/spec/paths/updateGitConnection.generated.yml + delete: + $ref: ./handlers/git-connections/spec/paths/deleteGitConnection.generated.yml + /git-connections/{id}/clone: + post: + $ref: ./handlers/git-connections/spec/paths/cloneGitConnection.generated.yml + /git-connections/{id}/disconnect: + post: + $ref: ./handlers/git-connections/spec/paths/disconnectGitConnection.generated.yml /role-mapping-rules: get: $ref: ./handlers/role-mapping-rules/spec/paths/getRoleMappingRules.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 72bc4737a9b..b0a8945b851 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -39,6 +39,8 @@ tags: description: Operations about executions - name: Folders description: Operations about folders + - name: GitConnections + description: Operations about Git connections - name: Insights description: Operations about insights - name: LogStreaming diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/gitConnectionPublicDto.generated.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/gitConnectionPublicDto.generated.yml new file mode 100644 index 00000000000..3bad91d5a4a --- /dev/null +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/gitConnectionPublicDto.generated.yml @@ -0,0 +1,45 @@ +type: object +properties: + id: + type: string + name: + type: string + repositoryUrl: + type: string + branchName: + type: string + nullable: true + connectionType: + type: string + enum: + - ssh + - https + publicKey: + type: string + nullable: true + keyGeneratorType: + type: string + nullable: true + enum: + - ed25519 + - rsa + baseCommit: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time +required: + - id + - name + - repositoryUrl + - branchName + - connectionType + - publicKey + - keyGeneratorType + - baseCommit + - createdAt + - updatedAt diff --git a/packages/cli/test/integration/public-api/git-connections.test.ts b/packages/cli/test/integration/public-api/git-connections.test.ts new file mode 100644 index 00000000000..f5a659cfa70 --- /dev/null +++ b/packages/cli/test/integration/public-api/git-connections.test.ts @@ -0,0 +1,140 @@ +import { testDb } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { GitConnectionRepository } from '@/modules/git-connections.ee/database/repositories/git-connection.repository'; +import { createOwnerWithApiKey } from '@test-integration/db/users'; +import { setupTestServer } from '@test-integration/utils'; + +describe('Git connections in Public API', () => { + const testServer = setupTestServer({ + endpointGroups: ['publicApi'], + enabledFeatures: ['feat:gitConnections'], + modules: ['git-connections'], + }); + let owner: User; + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + testServer.license.reset(); + await Container.get(GitConnectionRepository).delete({}); + owner = await createOwnerWithApiKey(); + }); + + it('creates, retrieves, lists, updates, disconnects, and deletes an HTTPS connection', async () => { + const agent = testServer.publicApiAgentFor(owner); + const createResponse = await agent.post('/git-connections').send({ + name: 'Deployments', + repositoryUrl: 'https://example.com/org/repo.git', + branchName: 'main', + connectionType: 'https', + username: 'git-user', + password: 'secret', + }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body).toMatchObject({ + name: 'Deployments', + branchName: 'main', + connectionType: 'https', + publicKey: null, + }); + expect(createResponse.body).not.toHaveProperty('username'); + expect(createResponse.body).not.toHaveProperty('password'); + expect(createResponse.body).not.toHaveProperty('connected'); + const id = createResponse.body.id as string; + + const getResponse = await agent.get(`/git-connections/${id}`); + expect(getResponse.status).toBe(200); + expect(getResponse.body.id).toBe(id); + + const listResponse = await agent.get('/git-connections?limit=1'); + expect(listResponse.status).toBe(200); + expect(listResponse.body.data).toHaveLength(1); + expect(listResponse.body.data[0]).not.toHaveProperty('publicKey'); + + const updateResponse = await agent.put(`/git-connections/${id}`).send({ name: 'Renamed' }); + expect(updateResponse.status, JSON.stringify(updateResponse.body)).toBe(200); + expect(updateResponse.body.name).toBe('Renamed'); + + const disconnectResponse = await agent.post(`/git-connections/${id}/disconnect`); + expect(disconnectResponse.status).toBe(200); + expect(disconnectResponse.body.id).toBe(id); + expect(disconnectResponse.body).not.toHaveProperty('connected'); + + const deleteResponse = await agent.delete(`/git-connections/${id}`); + expect(deleteResponse.status).toBe(204); + expect(await Container.get(GitConnectionRepository).findOneBy({ id })).toBeNull(); + }); + + it('rejects a key without the source-control scope', async () => { + const unscopedOwner = await createOwnerWithApiKey({ scopes: ['tag:list'] }); + const response = await testServer.publicApiAgentFor(unscopedOwner).get('/git-connections'); + expect(response.status).toBe(403); + }); + + it('rejects requests when Git connections is not licensed', async () => { + testServer.license.disable('feat:gitConnections'); + const response = await testServer.publicApiAgentFor(owner).get('/git-connections'); + expect(response.status).toBe(403); + }); + + it('generates an SSH key pair without exposing the private key', async () => { + const response = await testServer.publicApiAgentFor(owner).post('/git-connections').send({ + name: 'SSH repository', + repositoryUrl: 'git@example.com:org/repo.git', + connectionType: 'ssh', + }); + + expect(response.status).toBe(201); + expect(response.body.publicKey).toMatch(/^ssh-ed25519 /); + expect(response.body.keyGeneratorType).toBe('ed25519'); + expect(response.body).not.toHaveProperty('privateKey'); + const entity = await Container.get(GitConnectionRepository).findOneByOrFail({ + id: response.body.id, + }); + expect(entity.encryptedPrivateKey).toBeTruthy(); + expect(entity.encryptedUsername).toBeNull(); + expect(entity.encryptedPassword).toBeNull(); + }); + + it('rejects replacing only one HTTPS credential and leaves the entity unchanged', async () => { + const agent = testServer.publicApiAgentFor(owner); + const created = await agent.post('/git-connections').send({ + name: 'HTTPS repository', + repositoryUrl: 'https://example.com/org/repo.git', + connectionType: 'https', + username: 'git-user', + password: 'secret', + }); + const before = await Container.get(GitConnectionRepository).findOneByOrFail({ + id: created.body.id, + }); + + const response = await agent + .put(`/git-connections/${created.body.id}`) + .send({ username: 'replacement' }); + + expect(response.status).toBe(400); + const after = await Container.get(GitConnectionRepository).findOneByOrFail({ + id: created.body.id, + }); + expect(after.encryptedUsername).toBe(before.encryptedUsername); + expect(after.encryptedPassword).toBe(before.encryptedPassword); + }); + + it('rejects mismatched URL and authentication types without persisting', async () => { + const response = await testServer.publicApiAgentFor(owner).post('/git-connections').send({ + name: 'Invalid', + repositoryUrl: 'git@example.com:org/repo.git', + connectionType: 'https', + username: 'git-user', + password: 'secret', + }); + expect(response.status).toBe(400); + expect(await Container.get(GitConnectionRepository).count()).toBe(0); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 3a02a1e0310..9fe127d1737 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -70,6 +70,7 @@ type ModuleName = | 'ldap' | 'redaction' | 'source-control' + | 'git-connections' | 'token-exchange' | 'workflow-reviews'; diff --git a/packages/frontend/@n8n/stores/src/rbac.store.ts b/packages/frontend/@n8n/stores/src/rbac.store.ts index 8f716102b8f..418e434e2c3 100644 --- a/packages/frontend/@n8n/stores/src/rbac.store.ts +++ b/packages/frontend/@n8n/stores/src/rbac.store.ts @@ -23,6 +23,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { variable: {}, projectVariable: {}, sourceControl: {}, + gitConnection: {}, externalSecretsProvider: {}, externalSecret: {}, project: {}, diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 3118ddd5848..7749aa06d46 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -1,6 +1,27 @@ { "$comment": "Coverage manifest: n8n node vs n8n public API. When a new endpoint is added to the OpenAPI spec, add it here with status covered/gap/excluded. See test/N8n.api-coverage.test.ts.", "endpoints": { + "GET /git-connections": { + "status": "gap" + }, + "POST /git-connections": { + "status": "gap" + }, + "GET /git-connections/{id}": { + "status": "gap" + }, + "PUT /git-connections/{id}": { + "status": "gap" + }, + "DELETE /git-connections/{id}": { + "status": "gap" + }, + "POST /git-connections/{id}/clone": { + "status": "gap" + }, + "POST /git-connections/{id}/disconnect": { + "status": "gap" + }, "POST /audit": { "status": "covered", "nodeOperation": "audit:generate"