mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
import {
|
|
CreateCredentialDto,
|
|
CredentialsGetManyRequestQuery,
|
|
CredentialsGetOneRequestQuery,
|
|
GenerateCredentialNameRequestQuery,
|
|
} from '@n8n/api-types';
|
|
import { LicenseState, Logger } from '@n8n/backend-common';
|
|
import { GlobalConfig } from '@n8n/config';
|
|
import {
|
|
SharedCredentials,
|
|
ProjectRelationRepository,
|
|
SharedCredentialsRepository,
|
|
AuthenticatedRequest,
|
|
} from '@n8n/db';
|
|
import {
|
|
Delete,
|
|
Get,
|
|
Licensed,
|
|
Patch,
|
|
Post,
|
|
Put,
|
|
RestController,
|
|
ProjectScope,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
} from '@n8n/decorators';
|
|
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
|
import { In } from '@n8n/typeorm';
|
|
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
|
import { z } from 'zod';
|
|
|
|
import { CredentialsFinderService } from './credentials-finder.service';
|
|
import { CredentialsService } from './credentials.service';
|
|
import { EnterpriseCredentialsService } from './credentials.service.ee';
|
|
import { getExternalSecretExpressionPaths } from './external-secrets.utils';
|
|
|
|
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
|
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
|
import { EventService } from '@/events/event.service';
|
|
import { listQueryMiddleware } from '@/middlewares';
|
|
import { userHasScopes } from '@/permissions.ee/check-access';
|
|
import { CredentialRequest } from '@/requests';
|
|
import { NamingService } from '@/services/naming.service';
|
|
import { UserManagementMailer } from '@/user-management/email';
|
|
import * as utils from '@/utils';
|
|
|
|
@RestController('/credentials')
|
|
export class CredentialsController {
|
|
constructor(
|
|
private readonly globalConfig: GlobalConfig,
|
|
private readonly credentialsService: CredentialsService,
|
|
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
|
|
private readonly namingService: NamingService,
|
|
private readonly licenseState: LicenseState,
|
|
private readonly logger: Logger,
|
|
private readonly userManagementMailer: UserManagementMailer,
|
|
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
|
private readonly projectRelationRepository: ProjectRelationRepository,
|
|
private readonly eventService: EventService,
|
|
private readonly credentialsFinderService: CredentialsFinderService,
|
|
) {}
|
|
|
|
@Get('/', { middlewares: listQueryMiddleware })
|
|
async getMany(
|
|
req: CredentialRequest.GetMany,
|
|
_res: unknown,
|
|
@Query query: CredentialsGetManyRequestQuery,
|
|
) {
|
|
const credentials = await this.credentialsService.getMany(req.user, {
|
|
listQueryOptions: req.listQueryOptions,
|
|
includeScopes: query.includeScopes,
|
|
includeData: query.includeData,
|
|
onlySharedWithMe: query.onlySharedWithMe,
|
|
includeGlobal: query.includeGlobal,
|
|
filters: {
|
|
externalSecretsStore: query.externalSecretsStore,
|
|
},
|
|
});
|
|
credentials.forEach((c) => {
|
|
// @ts-expect-error: This is to emulate the old behavior of removing the shared
|
|
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
|
|
// though. So to avoid leaking the information we just delete it.
|
|
delete c.shared;
|
|
});
|
|
return credentials;
|
|
}
|
|
|
|
@Get('/for-workflow')
|
|
async getProjectCredentials(req: CredentialRequest.ForWorkflow) {
|
|
const options = z
|
|
.union([z.object({ workflowId: z.string() }), z.object({ projectId: z.string() })])
|
|
.parse(req.query);
|
|
return await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(req.user, options);
|
|
}
|
|
|
|
@Get('/new')
|
|
async generateUniqueName(
|
|
_req: unknown,
|
|
_res: unknown,
|
|
@Query query: GenerateCredentialNameRequestQuery,
|
|
) {
|
|
const requestedName = query.name ?? this.globalConfig.credentials.defaultName;
|
|
|
|
return {
|
|
name: await this.namingService.getUniqueCredentialName(requestedName),
|
|
};
|
|
}
|
|
|
|
@Get('/:credentialId')
|
|
@ProjectScope('credential:read')
|
|
async getOne(
|
|
req: CredentialRequest.Get,
|
|
_res: unknown,
|
|
@Param('credentialId') credentialId: string,
|
|
@Query query: CredentialsGetOneRequestQuery,
|
|
) {
|
|
const { shared, ...credential } = this.licenseState.isSharingLicensed()
|
|
? await this.enterpriseCredentialsService.getOneForUser(
|
|
req.user,
|
|
credentialId,
|
|
// TODO: editor-ui is always sending this, maybe we can just rely on the
|
|
// the scopes and always decrypt the data if the user has the permissions
|
|
// to do so.
|
|
query.includeData,
|
|
)
|
|
: await this.credentialsService.getOne(req.user, credentialId, query.includeData);
|
|
|
|
const scopes = await this.credentialsService.getCredentialScopes(
|
|
req.user,
|
|
req.params.credentialId,
|
|
);
|
|
|
|
return { ...credential, scopes };
|
|
}
|
|
|
|
// TODO: Write at least test cases for the failure paths.
|
|
@Post('/test')
|
|
async testCredentials(req: CredentialRequest.Test) {
|
|
try {
|
|
return await this.credentialsService.testWithCredentials(req.user, req.body.credentials);
|
|
} catch (error) {
|
|
if (error instanceof CredentialNotFoundError) {
|
|
throw new ForbiddenError();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@Post('/')
|
|
async createCredentials(
|
|
req: AuthenticatedRequest,
|
|
_: Response,
|
|
@Body payload: CreateCredentialDto,
|
|
) {
|
|
const newCredential = await this.credentialsService.createUnmanagedCredential(
|
|
payload,
|
|
req.user,
|
|
);
|
|
|
|
const project = await this.sharedCredentialsRepository.findCredentialOwningProject(
|
|
newCredential.id,
|
|
);
|
|
|
|
this.eventService.emit('credentials-created', {
|
|
user: req.user,
|
|
credentialType: newCredential.type,
|
|
credentialId: newCredential.id,
|
|
publicApi: false,
|
|
projectId: project?.id,
|
|
projectType: project?.type,
|
|
uiContext: payload.uiContext,
|
|
isDynamic: newCredential.isResolvable ?? false,
|
|
usesExternalSecrets: getExternalSecretExpressionPaths(payload.data).length > 0,
|
|
});
|
|
|
|
return newCredential;
|
|
}
|
|
|
|
@Patch('/:credentialId')
|
|
@ProjectScope('credential:update')
|
|
async updateCredentials(req: CredentialRequest.Update) {
|
|
const {
|
|
body,
|
|
user,
|
|
params: { credentialId },
|
|
} = req;
|
|
|
|
const credential = await this.credentialsFinderService.findCredentialForUser(
|
|
credentialId,
|
|
user,
|
|
['credential:update'],
|
|
);
|
|
|
|
if (!credential) {
|
|
this.logger.info('Attempt to update credential blocked due to lack of permissions', {
|
|
credentialId,
|
|
userId: user.id,
|
|
});
|
|
throw new NotFoundError(
|
|
'Credential to be updated not found. You can only update credentials owned by you',
|
|
);
|
|
}
|
|
|
|
if (credential.isManaged) {
|
|
throw new BadRequestError('Managed credentials cannot be updated');
|
|
}
|
|
|
|
// We never want to allow users to change the oauthTokenData
|
|
delete body.data?.oauthTokenData;
|
|
|
|
const preparedCredentialData = await this.credentialsService.prepareUpdateData(
|
|
req.user,
|
|
req.body,
|
|
credential,
|
|
);
|
|
const newCredentialData = await this.credentialsService.createEncryptedData({
|
|
id: credential.id,
|
|
name: preparedCredentialData.name,
|
|
type: preparedCredentialData.type,
|
|
data: preparedCredentialData.data as unknown as ICredentialDataDecryptedObject,
|
|
});
|
|
|
|
// Update isGlobal if provided in the payload and user has permission
|
|
const isGlobal = body.isGlobal;
|
|
if (isGlobal !== undefined && isGlobal !== credential.isGlobal) {
|
|
if (!this.licenseState.isSharingLicensed()) {
|
|
throw new ForbiddenError('You are not licensed for sharing credentials');
|
|
}
|
|
|
|
const canShareGlobally = hasGlobalScope(req.user, 'credential:shareGlobally');
|
|
if (!canShareGlobally) {
|
|
throw new ForbiddenError(
|
|
'You do not have permission to change global sharing for credentials',
|
|
);
|
|
}
|
|
newCredentialData.isGlobal = isGlobal;
|
|
}
|
|
|
|
newCredentialData.isResolvable = body.isResolvable ?? credential.isResolvable;
|
|
const responseData = await this.credentialsService.update(
|
|
credentialId,
|
|
newCredentialData,
|
|
body.data
|
|
? (preparedCredentialData.data as unknown as ICredentialDataDecryptedObject)
|
|
: undefined,
|
|
);
|
|
|
|
if (responseData === null) {
|
|
throw new NotFoundError(`Credential ID "${credentialId}" could not be found to be updated.`);
|
|
}
|
|
|
|
// Remove the encrypted data as it is not needed in the frontend
|
|
const { data, shared, ...rest } = responseData;
|
|
|
|
this.logger.debug('Credential updated', { credentialId });
|
|
|
|
this.eventService.emit('credentials-updated', {
|
|
user: req.user,
|
|
credentialType: credential.type,
|
|
credentialId: credential.id,
|
|
isDynamic: newCredentialData.isResolvable ?? false,
|
|
usesExternalSecrets: getExternalSecretExpressionPaths(preparedCredentialData.data).length > 0,
|
|
});
|
|
|
|
const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
|
|
|
|
return { ...rest, scopes };
|
|
}
|
|
|
|
@Delete('/:credentialId')
|
|
@ProjectScope('credential:delete')
|
|
async deleteCredentials(req: CredentialRequest.Delete) {
|
|
const { credentialId } = req.params;
|
|
|
|
const credential = await this.credentialsFinderService.findCredentialForUser(
|
|
credentialId,
|
|
req.user,
|
|
['credential:delete'],
|
|
);
|
|
|
|
if (!credential) {
|
|
this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
|
|
credentialId,
|
|
userId: req.user.id,
|
|
});
|
|
throw new NotFoundError(
|
|
'Credential to be deleted not found. You can only removed credentials owned by you',
|
|
);
|
|
}
|
|
|
|
await this.credentialsService.delete(req.user, credential.id);
|
|
|
|
this.eventService.emit('credentials-deleted', {
|
|
user: req.user,
|
|
credentialType: credential.type,
|
|
credentialId: credential.id,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
@Licensed('feat:sharing')
|
|
@Put('/:credentialId/share')
|
|
async shareCredentials(req: CredentialRequest.Share) {
|
|
const { credentialId } = req.params;
|
|
const { shareWithIds } = req.body;
|
|
|
|
if (
|
|
!Array.isArray(shareWithIds) ||
|
|
!shareWithIds.every((userId) => typeof userId === 'string')
|
|
) {
|
|
throw new BadRequestError('Bad request');
|
|
}
|
|
|
|
const credential = await this.credentialsFinderService.findCredentialForUser(
|
|
credentialId,
|
|
req.user,
|
|
['credential:read'],
|
|
);
|
|
|
|
if (!credential) {
|
|
throw new ForbiddenError();
|
|
}
|
|
|
|
const currentProjectIds = credential.shared
|
|
.filter((sc) => sc.role === 'credential:user')
|
|
.map((sc) => sc.projectId);
|
|
const newProjectIds = shareWithIds;
|
|
|
|
const toShare = utils.rightDiff([currentProjectIds, (id) => id], [newProjectIds, (id) => id]);
|
|
const toUnshare = utils.rightDiff([newProjectIds, (id) => id], [currentProjectIds, (id) => id]);
|
|
|
|
if (toShare.length > 0) {
|
|
const canShare = await userHasScopes(req.user, ['credential:share'], false, {
|
|
credentialId,
|
|
});
|
|
if (!canShare) {
|
|
throw new ForbiddenError();
|
|
}
|
|
}
|
|
|
|
if (toUnshare.length > 0) {
|
|
const canUnshare = await userHasScopes(req.user, ['credential:unshare'], false, {
|
|
credentialId,
|
|
});
|
|
if (!canUnshare) {
|
|
throw new ForbiddenError();
|
|
}
|
|
}
|
|
|
|
let amountRemoved: number | null = null;
|
|
let newShareeIds: string[] = [];
|
|
|
|
const { manager: dbManager } = this.sharedCredentialsRepository;
|
|
await dbManager.transaction(async (trx) => {
|
|
const deleteResult = await trx.delete(SharedCredentials, {
|
|
credentialsId: credentialId,
|
|
projectId: In(toUnshare),
|
|
});
|
|
await this.enterpriseCredentialsService.shareWithProjects(
|
|
req.user,
|
|
credential.id,
|
|
toShare,
|
|
trx,
|
|
);
|
|
|
|
if (deleteResult.affected) {
|
|
amountRemoved = deleteResult.affected;
|
|
}
|
|
|
|
newShareeIds = toShare;
|
|
});
|
|
|
|
this.eventService.emit('credentials-shared', {
|
|
user: req.user,
|
|
credentialType: credential.type,
|
|
credentialId: credential.id,
|
|
userIdSharer: req.user.id,
|
|
userIdsShareesAdded: newShareeIds,
|
|
shareesRemoved: amountRemoved,
|
|
});
|
|
|
|
const projectsRelations = await this.projectRelationRepository.findBy({
|
|
projectId: In(newShareeIds),
|
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
|
});
|
|
|
|
await this.userManagementMailer.notifyCredentialsShared({
|
|
sharer: req.user,
|
|
newShareeIds: projectsRelations.map((pr) => pr.userId),
|
|
credentialsName: credential.name,
|
|
});
|
|
}
|
|
|
|
@Put('/:credentialId/transfer')
|
|
@ProjectScope('credential:move')
|
|
async transfer(req: CredentialRequest.Transfer) {
|
|
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
|
|
|
|
return await this.enterpriseCredentialsService.transferOne(
|
|
req.user,
|
|
req.params.credentialId,
|
|
body.destinationProjectId,
|
|
);
|
|
}
|
|
}
|