refactor(API): Migrate DELETE /credentials/{credentialId} and PUT /credentials/{credentialId}/transfer to the decorator pattern (#38454)

Co-authored-by: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Sam Wooler
2026-09-14 14:28:25 +00:00
committed by GitHub
co-authored by Claude Sonnet 5
parent eb4eebaa98
commit 6307a5aaf1
17 changed files with 288 additions and 157 deletions
@@ -0,0 +1,17 @@
import { TransferCredentialPublicDto } from '../credential-public.dto';
describe('TransferCredentialPublicDto', () => {
test('accepts destinationProjectId and strips unknown keys', () => {
const result = TransferCredentialPublicDto.safeParse({
destinationProjectId: '1234',
somethingElse: true,
});
expect(result).toMatchObject({ success: true, data: { destinationProjectId: '1234' } });
expect(result.data).not.toHaveProperty('somethingElse');
});
test('rejects a missing destinationProjectId', () => {
expect(TransferCredentialPublicDto.safeParse({}).success).toBe(false);
});
});
@@ -22,6 +22,12 @@ export const credentialPublicSchema = publicApiCredentialResponseSchema.extend({
export class CredentialPublicDto extends Z.class(credentialPublicSchema.shape) {}
export const credentialDeletedPublicSchema = credentialPublicSchema.extend({
usageScope: z.enum(['project', 'instance']),
});
export class DeleteCredentialPublicDto extends Z.class(credentialDeletedPublicSchema.shape) {}
export const credentialListItemPublicSchema = credentialPublicSchema
.pick({ id: true, name: true, type: true, createdAt: true, updatedAt: true })
.extend({ shared: z.array(credentialSharedPublicSchema) });
@@ -97,3 +103,10 @@ export class UpdateCredentialPublicDto extends Z.class({
'If false, replaces the entire data object.',
}),
}) {}
export class TransferCredentialPublicDto extends Z.class({
destinationProjectId: z.string().openapi({
description: 'The ID of the project to transfer the credential to.',
example: 'VmwOO9HeTEj20kxM',
}),
}) {}
+2
View File
@@ -133,6 +133,8 @@ export {
CredentialListPublicDto,
CreateCredentialPublicDto,
UpdateCredentialPublicDto,
DeleteCredentialPublicDto,
TransferCredentialPublicDto,
credentialPublicSchema,
} from './credentials/credential-public.dto';
export { ListCredentialsQueryDto } from './credentials/list-credentials-query.dto';
-4
View File
@@ -136,10 +136,6 @@ export declare namespace UserRequest {
export declare namespace CredentialRequest {
type Test = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record<string, string>>;
type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>;
}
export declare namespace InsightsRequest {
@@ -3,8 +3,10 @@ import {
CreateCredentialPublicDto,
CredentialListPublicDto,
CredentialPublicDto,
DeleteCredentialPublicDto,
ListCredentialsQueryDto,
UpdateCredentialPublicDto,
TransferCredentialPublicDto,
} from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb, User } from '@n8n/db';
@@ -16,12 +18,14 @@ import {
ApiSummary,
ApiTags,
Body,
Delete,
Get,
Param,
Patch,
Post,
ProjectScope,
PublicApiController,
Put,
Query,
} from '@n8n/decorators';
import { hasGlobalScope } from '@n8n/permissions';
@@ -31,6 +35,7 @@ import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { CredentialTypes } from '@/credential-types';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
import { CredentialsHelper } from '@/credentials-helper';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
@@ -78,6 +83,14 @@ function toCredentialPublicDto(credential: CredentialPublicDtoSource): Credentia
};
}
/** The delete response adds `usageScope` to the standard credential fields. Carry over from legacy EOV handler. */
function toDeleteCredentialPublicDto(credential: CredentialsEntity): DeleteCredentialPublicDto {
return {
...toCredentialPublicDto(credential),
usageScope: credential.usageScope,
};
}
function toCredentialListItem(credential: CredentialsEntity) {
return {
id: credential.id,
@@ -102,6 +115,7 @@ export class CredentialsPublicController {
private readonly credentialsHelper: CredentialsHelper,
private readonly licenseState: LicenseState,
private readonly eventService: EventService,
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
) {}
@Get('/')
@@ -363,4 +377,55 @@ export class CredentialsPublicController {
return { updatePayload, decryptedDataForDeps };
}
@Delete('/:credentialId')
@ApiKeyScope('credential:delete')
@ProjectScope('credential:delete')
@ApiSummary('Delete credential by ID')
@ApiDescription(
'Deletes a credential from your instance. You must be the owner of the credential.',
)
@ApiTags(['Credential'])
@ApiResponse(200, DeleteCredentialPublicDto)
@ApiErrorResponse(404)
async deleteCredential(
req: AuthenticatedRequest,
_res: Response,
@Param('credentialId', credentialIdParamSchema) credentialId: string,
): Promise<DeleteCredentialPublicDto> {
const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId,
req.user,
['credential:delete'],
);
if (!credential) {
throw new NotFoundError('Not Found');
}
await this.credentialsService.delete(req.user, credentialId);
return toDeleteCredentialPublicDto(credential);
}
@Put('/:credentialId/transfer')
@ApiKeyScope('credential:move')
@ProjectScope('credential:move')
@ApiSummary('Transfer a credential to another project.')
@ApiDescription('Transfer a credential to another project.')
@ApiTags(['Credential'])
@ApiResponse(204)
@ApiErrorResponse(404)
async transferCredential(
req: AuthenticatedRequest,
_res: Response,
@Param('credentialId', credentialIdParamSchema) credentialId: string,
@Body body: TransferCredentialPublicDto,
): Promise<void> {
await this.enterpriseCredentialsService.transferOne(
req.user,
credentialId,
body.destinationProjectId,
);
}
}
@@ -1,23 +1,18 @@
import { Container } from '@n8n/di';
import { z } from 'zod';
import { CredentialTypes } from '@/credential-types';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
import { CredentialsHelper } from '@/credentials-helper';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { sanitizeCredentials, toJsonSchema } from './credentials.utils';
import { toJsonSchema } from './credentials.utils';
import type { CredentialTypeRequest, CredentialRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { publicApiScope, projectScope } from '../../shared/middlewares/global.middleware';
type CredentialsHandlers = {
testCredential: PublicAPIEndpoint<CredentialRequest.Test>;
transferCredential: PublicAPIEndpoint<CredentialRequest.Transfer>;
deleteCredential: PublicAPIEndpoint<CredentialRequest.Delete>;
getCredentialType: PublicAPIEndpoint<CredentialTypeRequest.Get>;
};
@@ -42,42 +37,6 @@ const credentialsHandlers: CredentialsHandlers = {
}
},
],
transferCredential: [
publicApiScope('credential:move'),
projectScope('credential:move', 'credential'),
async (req, res) => {
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
await Container.get(EnterpriseCredentialsService).transferOne(
req.user,
req.params.id,
body.destinationProjectId,
);
return res.status(204).send();
},
],
deleteCredential: [
publicApiScope('credential:delete'),
projectScope('credential:delete', 'credential'),
async (req, res) => {
const { id: credentialId } = req.params;
const credential = await Container.get(CredentialsFinderService).findCredentialForUser(
credentialId,
req.user,
['credential:delete'],
);
if (!credential) {
throw new NotFoundError('Not Found');
}
await Container.get(CredentialsService).delete(req.user, credentialId);
return res.json(sanitizeCredentials(credential));
},
],
getCredentialType: [
async (req, res) => {
const { credentialTypeName } = req.params;
@@ -1,32 +0,0 @@
put:
x-eov-operation-id: transferCredential
x-required-scope: credential:move
x-eov-operation-handler: v1/handlers/credentials/credentials.handler
tags:
- Credential
summary: Transfer a credential to another project.
description: Transfer a credential to another project.
parameters:
- $ref: '../schemas/parameters/credentialId.yml'
requestBody:
description: Destination project for the credential transfer.
content:
application/json:
schema:
type: object
properties:
destinationProjectId:
type: string
description: The ID of the project to transfer the credential to.
required:
- destinationProjectId
required: true
responses:
'200':
description: Operation successful.
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
@@ -1,27 +0,0 @@
delete:
x-eov-operation-id: deleteCredential
x-required-scope: credential:delete
x-eov-operation-handler: v1/handlers/credentials/credentials.handler
tags:
- Credential
summary: Delete credential by ID
description: Deletes a credential from your instance. You must be the owner of the credentials
operationId: deleteCredential
parameters:
- name: id
in: path
description: The credential ID that needs to be deleted
required: true
schema:
type: string
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/credential.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
@@ -0,0 +1,70 @@
operationId: deleteCredential
tags:
- Credential
summary: Delete credential by ID
description: Deletes a credential from your instance. You must be the owner of the credential.
x-required-scope: credential: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
description: The ID of the credential.
name: credentialId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
id:
type: string
name:
type: string
type:
type: string
isManaged:
type: boolean
isGlobal:
type: boolean
isResolvable:
type: boolean
resolvableAllowFallback:
type: boolean
resolverId:
type: string
nullable: true
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
usageScope:
type: string
enum:
- project
- instance
required:
- id
- name
- type
- isManaged
- isGlobal
- isResolvable
- resolvableAllowFallback
- resolverId
- createdAt
- updatedAt
- usageScope
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
@@ -0,0 +1,42 @@
operationId: transferCredential
tags:
- Credential
summary: Transfer a credential to another project.
description: Transfer a credential to another project.
x-required-scope: credential:move
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the credential.
name: credentialId
in: path
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
destinationProjectId:
type: string
description: The ID of the project to transfer the credential to.
example: VmwOO9HeTEj20kxM
required:
- destinationProjectId
responses:
'204':
description: Operation successful.
'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
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
@@ -1,34 +0,0 @@
required:
- name
- type
- data
type: object
properties:
id:
type: string
readOnly: true
example: R2DjclaysHbqn778
name:
type: string
example: Joe's Github Credentials
type:
type: string
example: githubApi
data:
type: object
writeOnly: true
example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
isResolvable:
type: boolean
example: false
description: Whether this credential has resolvable fields
createdAt:
type: string
format: date-time
readOnly: true
example: '2022-04-29T11:02:29.842Z'
updatedAt:
type: string
format: date-time
readOnly: true
example: '2022-04-29T11:02:29.842Z'
@@ -1,6 +0,0 @@
name: id
in: path
description: The ID of the credential.
required: true
schema:
type: string
@@ -13,6 +13,11 @@ paths:
$ref: ./handlers/credentials/spec/paths/getCredential.generated.yml
patch:
$ref: ./handlers/credentials/spec/paths/updateCredential.generated.yml
delete:
$ref: ./handlers/credentials/spec/paths/deleteCredential.generated.yml
/credentials/{credentialId}/transfer:
put:
$ref: ./handlers/credentials/spec/paths/transferCredential.generated.yml
/executions:
get:
$ref: ./handlers/executions/spec/paths/getExecutions.generated.yml
@@ -93,14 +93,10 @@ paths:
$ref: './handlers/sso-oidc/spec/paths/settings.sso.oidc.yml'
/settings/sso/saml:
$ref: './handlers/sso-saml/spec/paths/settings.sso.saml.yml'
/credentials/{id}:
$ref: './handlers/credentials/spec/paths/credentials.id.yml'
/credentials/{id}/test:
$ref: './handlers/credentials/spec/paths/credentials.id.test.yml'
/credentials/schema/{credentialTypeName}:
$ref: './handlers/credentials/spec/paths/credentials.schema.id.yml'
/credentials/{id}/transfer:
$ref: './handlers/credentials/spec/paths/credentials.id.transfer.yml'
/tags/{id}:
$ref: './handlers/tags/spec/paths/tags.id.yml'
/workflows/{id}/{versionId}:
@@ -14,8 +14,6 @@ WorkflowSettings:
$ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml'
WorkflowList:
$ref: './../../../handlers/workflows/spec/schemas/workflowList.yml'
Credential:
$ref: './../../../handlers/credentials/spec/schemas/credential.yml'
CredentialType:
$ref: './../../../handlers/credentials/spec/schemas/credentialType.yml'
Audit:
@@ -674,10 +674,20 @@ describe('DELETE /credentials/:id', () => {
expect(response.statusCode).toBe(200);
const { name, type } = response.body;
expect(name).toBe(savedCredential.name);
expect(type).toBe(savedCredential.type);
// Exact-shape check - proves the DTO's allowlisted fields
expect(response.body).toStrictEqual({
id: savedCredential.id,
name: savedCredential.name,
type: savedCredential.type,
isManaged: savedCredential.isManaged,
isGlobal: savedCredential.isGlobal,
isResolvable: savedCredential.isResolvable,
resolvableAllowFallback: savedCredential.resolvableAllowFallback,
resolverId: savedCredential.resolverId,
createdAt: savedCredential.createdAt.toISOString(),
updatedAt: savedCredential.updatedAt.toISOString(),
usageScope: savedCredential.usageScope,
});
const deletedCredential = await Container.get(CredentialsRepository).findOneBy({
id: savedCredential.id,
@@ -805,6 +815,20 @@ describe('DELETE /credentials/:id', () => {
expect(response.statusCode).toBe(404);
});
test('should return 403 when the API key lacks credential:delete', async () => {
const savedCredential = await saveCredential(dbCredential(), { user: owner });
const agent = await makeGlobalRoleUserAgent(['credential:read']);
const response = await agent.delete(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(403);
const stillThere = await Container.get(CredentialsRepository).findOneBy({
id: savedCredential.id,
});
expect(stillThere).not.toBeNull();
});
});
describe('PATCH /credentials/:id', () => {
@@ -1716,6 +1740,49 @@ describe('PUT /credentials/:id/transfer', () => {
*/
expect(response.statusCode).toBe(400);
});
test('should return 403 when the API key lacks credential:move', async () => {
const [firstProject, secondProject] = await Promise.all([
createTeamProject('first-project', owner),
createTeamProject('second-project', owner),
]);
const credentials = await createCredentials(
{ name: 'Test', type: 'test', data: '' },
firstProject,
);
const agent = await makeGlobalRoleUserAgent(['credential:read']);
const response = await agent
.put(`/credentials/${credentials.id}/transfer`)
.send({ destinationProjectId: secondProject.id });
expect(response.statusCode).toBe(403);
const sharings = await getCredentialSharings(credentials);
expect(sharings).toHaveLength(1);
expect(sharings[0]).toMatchObject({ projectId: firstProject.id });
});
test('should not transfer a credential the user has no access to', async () => {
const [firstProject, secondProject] = await Promise.all([
createTeamProject('first-project', owner),
createTeamProject('second-project', owner),
]);
const credentials = await createCredentials(
{ name: 'Test', type: 'test', data: '' },
firstProject,
);
const response = await authMemberAgent
.put(`/credentials/${credentials.id}/transfer`)
.send({ destinationProjectId: secondProject.id });
expect(response.statusCode).toBe(403);
const sharings = await getCredentialSharings(credentials);
expect(sharings).toHaveLength(1);
expect(sharings[0]).toMatchObject({ projectId: firstProject.id });
});
});
const credentialPayload = (): CredentialPayload => ({
@@ -54,7 +54,7 @@
"PATCH /credentials/{credentialId}": {
"status": "gap"
},
"DELETE /credentials/{id}": {
"DELETE /credentials/{credentialId}": {
"status": "covered",
"nodeOperation": "credential:delete"
},
@@ -62,7 +62,7 @@
"status": "covered",
"nodeOperation": "credential:getSchema"
},
"PUT /credentials/{id}/transfer": {
"PUT /credentials/{credentialId}/transfer": {
"status": "gap"
},
"POST /credentials/{id}/test": {