| import type { CreateCredentialDto } from '@n8n/api-types'; |
| import { Logger } from '@n8n/backend-common'; |
| import type { Project, User, ICredentialsDb, ScopesField } from '@n8n/db'; |
| import { |
| CredentialsEntity, |
| SharedCredentials, |
| CredentialsRepository, |
| ProjectRepository, |
| SharedCredentialsRepository, |
| UserRepository, |
| } from '@n8n/db'; |
| import { Service } from '@n8n/di'; |
| import { hasGlobalScope, type Scope } from '@n8n/permissions'; |
| |
| import { |
| In, |
| type EntityManager, |
| type FindOptionsRelations, |
| type FindOptionsWhere, |
| } from '@n8n/typeorm'; |
| import { CredentialDataError, Credentials, ErrorReporter } from 'n8n-core'; |
| import type { |
| ICredentialDataDecryptedObject, |
| ICredentialsDecrypted, |
| ICredentialType, |
| INodeProperties, |
| } from 'n8n-workflow'; |
| import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers, UnexpectedError } from 'n8n-workflow'; |
|
|
| import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; |
| import { CredentialTypes } from '@/credential-types'; |
| import { createCredentialsFromCredentialsEntity } from '@/credentials-helper'; |
| import { BadRequestError } from '@/errors/response-errors/bad-request.error'; |
| import { NotFoundError } from '@/errors/response-errors/not-found.error'; |
| import { ExternalHooks } from '@/external-hooks'; |
| import { validateEntity } from '@/generic-helpers'; |
| import { userHasScopes } from '@/permissions.ee/check-access'; |
| import type { CredentialRequest, ListQuery } from '@/requests'; |
| import { CredentialsTester } from '@/services/credentials-tester.service'; |
| import { OwnershipService } from '@/services/ownership.service'; |
| |
| import { ProjectService } from '@/services/project.service.ee'; |
| import { RoleService } from '@/services/role.service'; |
|
|
| import { CredentialsFinderService } from './credentials-finder.service'; |
|
|
| export type CredentialsGetSharedOptions = |
| | { allowGlobalScope: true; globalScope: Scope } |
| | { allowGlobalScope: false }; |
|
|
| type CreateCredentialOptions = CreateCredentialDto & { |
| isManaged: boolean; |
| }; |
|
|
| @Service() |
| export class CredentialsService { |
| constructor( |
| private readonly credentialsRepository: CredentialsRepository, |
| private readonly sharedCredentialsRepository: SharedCredentialsRepository, |
| private readonly ownershipService: OwnershipService, |
| private readonly logger: Logger, |
| private readonly errorReporter: ErrorReporter, |
| private readonly credentialsTester: CredentialsTester, |
| private readonly externalHooks: ExternalHooks, |
| private readonly credentialTypes: CredentialTypes, |
| private readonly projectRepository: ProjectRepository, |
| private readonly projectService: ProjectService, |
| private readonly roleService: RoleService, |
| private readonly userRepository: UserRepository, |
| private readonly credentialsFinderService: CredentialsFinderService, |
| ) {} |
|
|
| async getMany( |
| user: User, |
| { |
| listQueryOptions = {}, |
| includeScopes = false, |
| includeData = false, |
| onlySharedWithMe = false, |
| }: { |
| listQueryOptions?: ListQuery.Options & { includeData?: boolean }; |
| includeScopes?: boolean; |
| includeData?: boolean; |
| onlySharedWithMe?: boolean; |
| } = {}, |
| ) { |
| const returnAll = hasGlobalScope(user, 'credential:list'); |
| const isDefaultSelect = !listQueryOptions.select; |
| const projectId = |
| typeof listQueryOptions.filter?.projectId === 'string' |
| ? listQueryOptions.filter.projectId |
| : undefined; |
|
|
| if (onlySharedWithMe) { |
| listQueryOptions.filter = { |
| ...listQueryOptions.filter, |
| withRole: 'credential:user', |
| user, |
| }; |
| } |
|
|
| if (includeData) { |
| |
| |
| |
| |
| includeScopes = true; |
| listQueryOptions.includeData = true; |
| } |
|
|
| if (returnAll) { |
| let project: Project | undefined; |
|
|
| if (projectId) { |
| try { |
| project = await this.projectService.getProject(projectId); |
| } catch {} |
| } |
|
|
| if (project?.type === 'personal') { |
| listQueryOptions.filter = { |
| ...listQueryOptions.filter, |
| withRole: 'credential:owner', |
| }; |
| } |
|
|
| let credentials = await this.credentialsRepository.findMany(listQueryOptions); |
|
|
| if (isDefaultSelect) { |
| |
| |
| |
| |
| |
| if ( |
| (listQueryOptions.filter?.shared as { projectId?: string })?.projectId ?? |
| onlySharedWithMe |
| ) { |
| const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( |
| credentials.map((c) => c.id), |
| ); |
| credentials.forEach((c) => { |
| c.shared = relations.filter((r) => r.credentialsId === c.id); |
| }); |
| } |
| credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); |
| } |
|
|
| if (includeScopes) { |
| const projectRelations = await this.projectService.getProjectRelationsForUser(user); |
| credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations)); |
| } |
|
|
| if (includeData) { |
| credentials = credentials.map((c: CredentialsEntity & ScopesField) => { |
| const data = c.scopes.includes('credential:update') ? this.decrypt(c) : undefined; |
| |
| |
| if (data?.oauthTokenData) { |
| data.oauthTokenData = true; |
| } |
|
|
| return { |
| ...c, |
| data, |
| } as unknown as CredentialsEntity; |
| }); |
| } |
|
|
| return credentials; |
| } |
|
|
| const ids = await this.credentialsFinderService.getCredentialIdsByUserAndRole([user.id], { |
| scopes: ['credential:read'], |
| }); |
|
|
| let credentials = await this.credentialsRepository.findMany( |
| listQueryOptions, |
| ids, |
| ); |
|
|
| if (isDefaultSelect) { |
| |
| |
| |
| |
| |
| if ( |
| (listQueryOptions.filter?.shared as { projectId?: string })?.projectId ?? |
| onlySharedWithMe |
| ) { |
| const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( |
| credentials.map((c) => c.id), |
| ); |
| credentials.forEach((c) => { |
| c.shared = relations.filter((r) => r.credentialsId === c.id); |
| }); |
| } |
|
|
| credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); |
| } |
|
|
| if (includeScopes) { |
| const projectRelations = await this.projectService.getProjectRelationsForUser(user); |
| credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations)); |
| } |
|
|
| if (includeData) { |
| credentials = credentials.map((c: CredentialsEntity & ScopesField) => { |
| return { |
| ...c, |
| data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, |
| } as unknown as CredentialsEntity; |
| }); |
| } |
|
|
| return credentials; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async getCredentialsAUserCanUseInAWorkflow( |
| user: User, |
| options: { workflowId: string } | { projectId: string }, |
| ) { |
| |
| const projectRelations = await this.projectService.getProjectRelationsForUser(user); |
|
|
| |
| const allCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [ |
| 'credential:read', |
| ]); |
|
|
| |
| const allCredentialsForWorkflow = |
| 'workflowId' in options |
| ? (await this.findAllCredentialIdsForWorkflow(options.workflowId)).map((c) => c.id) |
| : (await this.findAllCredentialIdsForProject(options.projectId)).map((c) => c.id); |
|
|
| |
| |
| const intersection = allCredentials.filter((c) => allCredentialsForWorkflow.includes(c.id)); |
|
|
| return intersection |
| .map((c) => this.roleService.addScopes(c, user, projectRelations)) |
| .map((c) => ({ |
| id: c.id, |
| name: c.name, |
| type: c.type, |
| scopes: c.scopes, |
| isManaged: c.isManaged, |
| })); |
| } |
|
|
| async findAllCredentialIdsForWorkflow(workflowId: string): Promise<CredentialsEntity[]> { |
| |
| |
| const user = await this.userRepository.findPersonalOwnerForWorkflow(workflowId); |
| if (user && hasGlobalScope(user, 'credential:read')) { |
| return await this.credentialsRepository.findAllPersonalCredentials(); |
| } |
|
|
| |
| |
| return await this.credentialsRepository.findAllCredentialsForWorkflow(workflowId); |
| } |
|
|
| async findAllCredentialIdsForProject(projectId: string): Promise<CredentialsEntity[]> { |
| |
| |
| |
| const user = await this.userRepository.findPersonalOwnerForProject(projectId); |
| if (user && hasGlobalScope(user, 'credential:read')) { |
| return await this.credentialsRepository.findAllPersonalCredentials(); |
| } |
|
|
| |
| return await this.credentialsRepository.findAllCredentialsForProject(projectId); |
| } |
|
|
| |
| |
| |
| |
| async getSharing( |
| user: User, |
| credentialId: string, |
| globalScopes: Scope[], |
| relations: FindOptionsRelations<SharedCredentials> = { credentials: true }, |
| ): Promise<SharedCredentials | null> { |
| let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId }; |
|
|
| if (!hasGlobalScope(user, globalScopes, { mode: 'allOf' })) { |
| where = { |
| ...where, |
| role: 'credential:owner', |
| project: { |
| projectRelations: { |
| role: 'project:personalOwner', |
| userId: user.id, |
| }, |
| }, |
| }; |
| } |
|
|
| return await this.sharedCredentialsRepository.findOne({ |
| where, |
| relations, |
| }); |
| } |
|
|
| async prepareUpdateData( |
| data: CredentialRequest.CredentialProperties, |
| decryptedData: ICredentialDataDecryptedObject, |
| ): Promise<CredentialsEntity> { |
| const mergedData = deepCopy(data); |
| if (mergedData.data) { |
| mergedData.data = this.unredact(mergedData.data, decryptedData); |
| } |
|
|
| |
| |
| const updateData = this.credentialsRepository.create(mergedData as ICredentialsDb); |
|
|
| await validateEntity(updateData); |
|
|
| |
| |
| if (decryptedData.oauthTokenData) { |
| |
| updateData.data.oauthTokenData = decryptedData.oauthTokenData; |
| } |
| return updateData; |
| } |
|
|
| createEncryptedData(credential: { |
| id: string | null; |
| name: string; |
| type: string; |
| data: ICredentialDataDecryptedObject; |
| }): ICredentialsDb { |
| const credentials = new Credentials( |
| { id: credential.id, name: credential.name }, |
| credential.type, |
| ); |
|
|
| credentials.setData(credential.data); |
|
|
| const newCredentialData = credentials.getDataToSave() as ICredentialsDb; |
|
|
| |
| newCredentialData.updatedAt = new Date(); |
|
|
| return newCredentialData; |
| } |
|
|
| |
| |
| |
| |
| |
| decrypt(credential: CredentialsEntity, includeRawData = false) { |
| const coreCredential = createCredentialsFromCredentialsEntity(credential); |
| try { |
| const data = coreCredential.getData(); |
| if (includeRawData) { |
| return data; |
| } |
| return this.redact(data, credential); |
| } catch (error) { |
| if (error instanceof CredentialDataError) { |
| this.errorReporter.error(error, { |
| level: 'error', |
| extra: { credentialId: credential.id }, |
| tags: { credentialType: credential.type }, |
| }); |
| return {}; |
| } |
| throw error; |
| } |
| } |
|
|
| async update(credentialId: string, newCredentialData: ICredentialsDb) { |
| await this.externalHooks.run('credentials.update', [newCredentialData]); |
|
|
| |
| |
| await this.credentialsRepository.update(credentialId, newCredentialData); |
|
|
| |
| |
| return await this.credentialsRepository.findOneBy({ id: credentialId }); |
| } |
|
|
| async save( |
| credential: CredentialsEntity, |
| encryptedData: ICredentialsDb, |
| user: User, |
| projectId?: string, |
| ) { |
| |
| const newCredential = new CredentialsEntity(); |
| Object.assign(newCredential, credential, encryptedData); |
|
|
| await this.externalHooks.run('credentials.create', [encryptedData]); |
|
|
| const { manager: dbManager } = this.credentialsRepository; |
| const result = await dbManager.transaction(async (transactionManager) => { |
| const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential); |
|
|
| savedCredential.data = newCredential.data; |
|
|
| const project = |
| projectId === undefined |
| ? await this.projectRepository.getPersonalProjectForUserOrFail( |
| user.id, |
| transactionManager, |
| ) |
| : await this.projectService.getProjectWithScope( |
| user, |
| projectId, |
| ['credential:create'], |
| transactionManager, |
| ); |
|
|
| if (typeof projectId === 'string' && project === null) { |
| throw new BadRequestError( |
| "You don't have the permissions to save the credential in this project.", |
| ); |
| } |
|
|
| |
| if (project === null) { |
| throw new UnexpectedError('No personal project found'); |
| } |
|
|
| const newSharedCredential = this.sharedCredentialsRepository.create({ |
| role: 'credential:owner', |
| credentials: savedCredential, |
| projectId: project.id, |
| }); |
|
|
| await transactionManager.save<SharedCredentials>(newSharedCredential); |
|
|
| return savedCredential; |
| }); |
| this.logger.debug('New credential created', { |
| credentialId: newCredential.id, |
| ownerId: user.id, |
| }); |
| return result; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async delete(user: User, credentialId: string) { |
| await this.externalHooks.run('credentials.delete', [credentialId]); |
|
|
| const credential = await this.credentialsFinderService.findCredentialForUser( |
| credentialId, |
| user, |
| ['credential:delete'], |
| ); |
|
|
| if (!credential) { |
| return; |
| } |
|
|
| await this.credentialsRepository.remove(credential); |
| } |
|
|
| async test(userId: User['id'], credentials: ICredentialsDecrypted) { |
| return await this.credentialsTester.testCredentials(userId, credentials.type, credentials); |
| } |
|
|
| |
| |
| redact(data: ICredentialDataDecryptedObject, credential: CredentialsEntity) { |
| const copiedData = deepCopy(data); |
|
|
| let credType: ICredentialType; |
| try { |
| credType = this.credentialTypes.getByName(credential.type); |
| } catch { |
| |
| |
| |
| |
| return data; |
| } |
|
|
| const getExtendedProps = (type: ICredentialType) => { |
| const props: INodeProperties[] = []; |
| for (const e of type.extends ?? []) { |
| const extendsType = this.credentialTypes.getByName(e); |
| const extendedProps = getExtendedProps(extendsType); |
| NodeHelpers.mergeNodeProperties(props, extendedProps); |
| } |
| NodeHelpers.mergeNodeProperties(props, type.properties); |
| return props; |
| }; |
| const properties = getExtendedProps(credType); |
|
|
| for (const dataKey of Object.keys(copiedData)) { |
| |
| if (dataKey === 'oauthTokenData' || dataKey === 'csrfSecret') { |
| if (copiedData[dataKey].toString().length > 0) { |
| copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; |
| } else { |
| copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE; |
| } |
| continue; |
| } |
| const prop = properties.find((v) => v.name === dataKey); |
| if (!prop) { |
| continue; |
| } |
| if ( |
| prop.typeOptions?.password && |
| (!(copiedData[dataKey] as string).startsWith('={{') || prop.noDataExpression) |
| ) { |
| if (copiedData[dataKey].toString().length > 0) { |
| copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; |
| } else { |
| copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE; |
| } |
| } |
| } |
|
|
| return copiedData; |
| } |
|
|
| private unredactRestoreValues(unmerged: any, replacement: any) { |
| |
| for (const [key, value] of Object.entries(unmerged)) { |
| if (value === CREDENTIAL_BLANKING_VALUE || value === CREDENTIAL_EMPTY_VALUE) { |
| |
| unmerged[key] = replacement[key]; |
| } else if ( |
| typeof value === 'object' && |
| value !== null && |
| key in replacement && |
| |
| typeof replacement[key] === 'object' && |
| |
| replacement[key] !== null |
| ) { |
| |
| this.unredactRestoreValues(value, replacement[key]); |
| } |
| } |
| } |
|
|
| |
| |
| unredact( |
| redactedData: ICredentialDataDecryptedObject, |
| savedData: ICredentialDataDecryptedObject, |
| ) { |
| |
| const mergedData = deepCopy(redactedData); |
| this.unredactRestoreValues(mergedData, savedData); |
| return mergedData; |
| } |
|
|
| async getOne(user: User, credentialId: string, includeDecryptedData: boolean) { |
| let sharing: SharedCredentials | null = null; |
| let decryptedData: ICredentialDataDecryptedObject | null = null; |
|
|
| sharing = includeDecryptedData |
| ? |
| |
| await this.getSharing(user, credentialId, [ |
| 'credential:read', |
| |
| |
| |
| ]) |
| : null; |
|
|
| if (sharing) { |
| |
| |
| decryptedData = this.decrypt(sharing.credentials); |
| } else { |
| |
| |
| sharing = await this.getSharing(user, credentialId, ['credential:read']); |
| } |
|
|
| if (!sharing) { |
| throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`); |
| } |
|
|
| const { credentials: credential } = sharing; |
|
|
| const { data: _, ...rest } = credential; |
|
|
| if (decryptedData) { |
| |
| |
| if (decryptedData?.oauthTokenData) { |
| decryptedData.oauthTokenData = true; |
| } |
| return { data: decryptedData, ...rest }; |
| } |
| return { ...rest }; |
| } |
|
|
| async getCredentialScopes(user: User, credentialId: string): Promise<Scope[]> { |
| const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); |
| const shared = await this.sharedCredentialsRepository.find({ |
| where: { |
| projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]), |
| credentialsId: credentialId, |
| }, |
| }); |
| return this.roleService.combineResourceScopes('credential', user, shared, userProjectRelations); |
| } |
|
|
| |
| |
| |
| |
| |
| async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) { |
| trx = trx ?? this.credentialsRepository.manager; |
|
|
| |
| const allSharedCredentials = await trx.findBy(SharedCredentials, { |
| projectId: In([fromProjectId, toProjectId]), |
| }); |
|
|
| const sharedCredentialsOfFromProject = allSharedCredentials.filter( |
| (sc) => sc.projectId === fromProjectId, |
| ); |
|
|
| |
| |
| |
| |
| const ownedCredentialIds = sharedCredentialsOfFromProject |
| .filter((sc) => sc.role === 'credential:owner') |
| .map((sc) => sc.credentialsId); |
|
|
| await this.sharedCredentialsRepository.makeOwner(ownedCredentialIds, toProjectId, trx); |
|
|
| |
| await this.sharedCredentialsRepository.deleteByIds(ownedCredentialIds, fromProjectId, trx); |
|
|
| |
| |
| |
| const sharedCredentialIdsOfTransferee = allSharedCredentials |
| .filter((sc) => sc.projectId === toProjectId) |
| .map((sc) => sc.credentialsId); |
|
|
| |
| |
| const sharedCredentialsToTransfer = sharedCredentialsOfFromProject.filter( |
| (sc) => |
| sc.role !== 'credential:owner' && |
| !sharedCredentialIdsOfTransferee.includes(sc.credentialsId), |
| ); |
|
|
| await trx.insert( |
| SharedCredentials, |
| sharedCredentialsToTransfer.map((sc) => ({ |
| credentialsId: sc.credentialsId, |
| projectId: toProjectId, |
| role: sc.role, |
| })), |
| ); |
| } |
|
|
| async replaceCredentialContentsForSharee( |
| user: User, |
| credential: CredentialsEntity, |
| decryptedData: ICredentialDataDecryptedObject, |
| mergedCredentials: ICredentialsDecrypted, |
| ) { |
| |
| |
| |
| |
| if ( |
| !(await userHasScopes(user, ['credential:update'], false, { credentialId: credential.id })) |
| ) { |
| mergedCredentials.data = decryptedData; |
| } |
| } |
|
|
| |
| |
| |
| |
| async createUnmanagedCredential(dto: CreateCredentialDto, user: User) { |
| return await this.createCredential({ ...dto, isManaged: false }, user); |
| } |
|
|
| |
| |
| |
| |
| async createManagedCredential(dto: CreateCredentialDto, user: User) { |
| return await this.createCredential({ ...dto, isManaged: true }, user); |
| } |
|
|
| private async createCredential(opts: CreateCredentialOptions, user: User) { |
| const encryptedCredential = this.createEncryptedData({ |
| id: null, |
| name: opts.name, |
| type: opts.type, |
| data: opts.data as ICredentialDataDecryptedObject, |
| }); |
|
|
| const credentialEntity = this.credentialsRepository.create({ |
| ...encryptedCredential, |
| isManaged: opts.isManaged, |
| }); |
|
|
| const { shared, ...credential } = await this.save( |
| credentialEntity, |
| encryptedCredential, |
| user, |
| opts.projectId, |
| ); |
|
|
| const scopes = await this.getCredentialScopes(user, credential.id); |
|
|
| return { ...credential, scopes }; |
| } |
| } |
|
|