import { Credentials, UserSettings } from 'n8n-core'; import type { ICredentialDataDecryptedObject, ICredentialsDecrypted, ICredentialType, INodeCredentialTestResult, INodeProperties, } from 'n8n-workflow'; import { CREDENTIAL_EMPTY_VALUE, deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow'; import { Container } from 'typedi'; import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; import type { ICredentialsDb } from '@/Interfaces'; import { CredentialsHelper, createCredentialsFromCredentialsEntity } from '@/CredentialsHelper'; import { CREDENTIAL_BLANKING_VALUE, RESPONSE_ERROR_MESSAGES } from '@/constants'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; import type { User } from '@db/entities/User'; import type { CredentialRequest } from '@/requests'; import { CredentialTypes } from '@/CredentialTypes'; import { RoleService } from '@/services/role.service'; import { OwnershipService } from '@/services/ownership.service'; export class CredentialsService { static async get( where: FindOptionsWhere, options?: { relations: string[] }, ): Promise { return Db.collections.Credentials.findOne({ relations: options?.relations, where, }); } static async getMany(user: User, options?: { disableGlobalRole: boolean }) { type Select = Array; const select: Select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; const relations = ['shared', 'shared.role', 'shared.user']; const returnAll = user.globalRole.name === 'owner' && options?.disableGlobalRole !== true; const addOwnedByAndSharedWith = (c: CredentialsEntity) => Container.get(OwnershipService).addOwnedByAndSharedWith(c); if (returnAll) { const credentials = await Db.collections.Credentials.find({ select, relations }); return credentials.map(addOwnedByAndSharedWith); } const ids = await CredentialsService.getAccessibleCredentials(user.id); const credentials = await Db.collections.Credentials.find({ select, relations, where: { id: In(ids) }, }); return credentials.map(addOwnedByAndSharedWith); } /** * Get the IDs of all credentials owned by or shared with a user. */ private static async getAccessibleCredentials(userId: string) { const sharings = await Db.collections.SharedCredentials.find({ relations: ['role'], where: { userId, role: { name: In(['owner', 'user']), scope: 'credential' }, }, }); return sharings.map((s) => s.credentialsId); } static async getManyByIds(ids: string[], { withSharings } = { withSharings: false }) { const options: FindManyOptions = { where: { id: In(ids) } }; if (withSharings) { options.relations = ['shared', 'shared.user', 'shared.role']; } return Db.collections.Credentials.find(options); } /** * Retrieve the sharing that matches a user and a credential. */ static async getSharing( user: User, credentialId: string, relations: string[] = ['credentials'], { allowGlobalOwner } = { allowGlobalOwner: true }, ): Promise { const where: FindOptionsWhere = { credentialsId: credentialId }; // Omit user from where if the requesting user is the global // owner. This allows the global owner to view and delete // credentials they don't own. if (!allowGlobalOwner || user.globalRole.name !== 'owner') { Object.assign(where, { userId: user.id, role: { name: 'owner' }, }); if (!relations.includes('role')) { relations.push('role'); } } return Db.collections.SharedCredentials.findOne({ where, relations }); } static async prepareCreateData( data: CredentialRequest.CredentialProperties, ): Promise { const { id, ...rest } = data; // This saves us a merge but requires some type casting. These // types are compatible for this case. const newCredentials = Db.collections.Credentials.create(rest as ICredentialsDb); await validateEntity(newCredentials); // Add the date for newly added node access permissions for (const nodeAccess of newCredentials.nodesAccess) { nodeAccess.date = new Date(); } return newCredentials; } static async prepareUpdateData( data: CredentialRequest.CredentialProperties, decryptedData: ICredentialDataDecryptedObject, ): Promise { const mergedData = deepCopy(data); if (mergedData.data) { mergedData.data = this.unredact(mergedData.data, decryptedData); } // This saves us a merge but requires some type casting. These // types are compatible for this case. const updateData = Db.collections.Credentials.create(mergedData as ICredentialsDb); await validateEntity(updateData); // Add the date for newly added node access permissions for (const nodeAccess of updateData.nodesAccess) { if (!nodeAccess.date) { nodeAccess.date = new Date(); } } // Do not overwrite the oauth data else data like the access or refresh token would get lost // everytime anybody changes anything on the credentials even if it is just the name. if (decryptedData.oauthTokenData) { // @ts-ignore updateData.data.oauthTokenData = decryptedData.oauthTokenData; } return updateData; } static createEncryptedData( encryptionKey: string, credentialId: string | null, data: CredentialsEntity, ): ICredentialsDb { const credentials = new Credentials( { id: credentialId, name: data.name }, data.type, data.nodesAccess, ); credentials.setData(data.data as unknown as ICredentialDataDecryptedObject, encryptionKey); const newCredentialData = credentials.getDataToSave() as ICredentialsDb; // Add special database related data newCredentialData.updatedAt = new Date(); return newCredentialData; } static async getEncryptionKey(): Promise { try { return await UserSettings.getEncryptionKey(); } catch (error) { throw new ResponseHelper.InternalServerError(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); } } static async decrypt( encryptionKey: string, credential: CredentialsEntity, ): Promise { const coreCredential = createCredentialsFromCredentialsEntity(credential); const data = coreCredential.getData(encryptionKey); return data; } static async update( credentialId: string, newCredentialData: ICredentialsDb, ): Promise { await Container.get(ExternalHooks).run('credentials.update', [newCredentialData]); // Update the credentials in DB await Db.collections.Credentials.update(credentialId, newCredentialData); // We sadly get nothing back from "update". Neither if it updated a record // nor the new value. So query now the updated entry. return Db.collections.Credentials.findOneBy({ id: credentialId }); } static async save( credential: CredentialsEntity, encryptedData: ICredentialsDb, user: User, ): Promise { // To avoid side effects const newCredential = new CredentialsEntity(); Object.assign(newCredential, credential, encryptedData); await Container.get(ExternalHooks).run('credentials.create', [encryptedData]); const role = await Container.get(RoleService).findCredentialOwnerRole(); const result = await Db.transaction(async (transactionManager) => { const savedCredential = await transactionManager.save(newCredential); savedCredential.data = newCredential.data; const newSharedCredential = new SharedCredentials(); Object.assign(newSharedCredential, { role, user, credentials: savedCredential, }); await transactionManager.save(newSharedCredential); return savedCredential; }); LoggerProxy.verbose('New credential created', { credentialId: newCredential.id, ownerId: user.id, }); return result; } static async delete(credentials: CredentialsEntity): Promise { await Container.get(ExternalHooks).run('credentials.delete', [credentials.id]); await Db.collections.Credentials.remove(credentials); } static async test( user: User, encryptionKey: string, credentials: ICredentialsDecrypted, ): Promise { const helper = new CredentialsHelper(encryptionKey); return helper.testCredentials(user, credentials.type, credentials); } // Take data and replace all sensitive values with a sentinel value. // This will replace password fields and oauth data. static redact( data: ICredentialDataDecryptedObject, credential: CredentialsEntity, ): ICredentialDataDecryptedObject { const copiedData = deepCopy(data); const credTypes = Container.get(CredentialTypes); let credType: ICredentialType; try { credType = credTypes.getByName(credential.type); } catch { // This _should_ only happen when testing. If it does happen in // production it means it's either a mangled credential or a // credential for a removed community node. Either way, there's // no way to know what to redact. return data; } const getExtendedProps = (type: ICredentialType) => { const props: INodeProperties[] = []; for (const e of type.extends ?? []) { const extendsType = credTypes.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)) { // The frontend only cares that this value isn't falsy. if (dataKey === 'oauthTokenData') { 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; } // eslint-disable-next-line @typescript-eslint/no-explicit-any private static unredactRestoreValues(unmerged: any, replacement: any) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument for (const [key, value] of Object.entries(unmerged)) { if (value === CREDENTIAL_BLANKING_VALUE || value === CREDENTIAL_EMPTY_VALUE) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access unmerged[key] = replacement[key]; } else if ( typeof value === 'object' && value !== null && key in replacement && // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access typeof replacement[key] === 'object' && // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access replacement[key] !== null ) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access this.unredactRestoreValues(value, replacement[key]); } } } // Take unredacted data (probably from the DB) and merge it with // redacted data to create an unredacted version. static unredact( redactedData: ICredentialDataDecryptedObject, savedData: ICredentialDataDecryptedObject, ): ICredentialDataDecryptedObject { // Replace any blank sentinel values with their saved version const mergedData = deepCopy(redactedData); this.unredactRestoreValues(mergedData, savedData); return mergedData; } }