mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
fix(core): Assign credential ownership correctly in source control import (#8955)
This commit is contained in:
parent
160dfd383d
commit
260bc07ca9
|
@ -23,11 +23,12 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
||||||
return sharedCredential.credentials;
|
return sharedCredential.credentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByCredentialIds(credentialIds: string[]) {
|
async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
|
||||||
return await this.find({
|
return await this.find({
|
||||||
relations: ['credentials', 'user'],
|
relations: ['credentials', 'user'],
|
||||||
where: {
|
where: {
|
||||||
credentialsId: In(credentialIds),
|
credentialsId: In(credentialIds),
|
||||||
|
role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -230,7 +230,7 @@ export class SourceControlExportService {
|
||||||
const credentialIds = candidates.map((e) => e.id);
|
const credentialIds = candidates.map((e) => e.id);
|
||||||
const credentialsToBeExported = await Container.get(
|
const credentialsToBeExported = await Container.get(
|
||||||
SharedCredentialsRepository,
|
SharedCredentialsRepository,
|
||||||
).findByCredentialIds(credentialIds);
|
).findByCredentialIds(credentialIds, 'credential:owner');
|
||||||
let missingIds: string[] = [];
|
let missingIds: string[] = [];
|
||||||
if (credentialsToBeExported.length !== credentialIds.length) {
|
if (credentialsToBeExported.length !== credentialIds.length) {
|
||||||
const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId);
|
const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId);
|
||||||
|
@ -239,23 +239,26 @@ export class SourceControlExportService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
credentialsToBeExported.map(async (sharedCredential) => {
|
credentialsToBeExported.map(async (sharing) => {
|
||||||
const { name, type, nodesAccess, data, id } = sharedCredential.credentials;
|
const { name, type, nodesAccess, data, id } = sharing.credentials;
|
||||||
const credentialObject = new Credentials({ id, name }, type, nodesAccess, data);
|
const credentials = new Credentials({ id, name }, type, nodesAccess, data);
|
||||||
const plainData = credentialObject.getData();
|
|
||||||
const sanitizedData = this.replaceCredentialData(plainData);
|
const stub: ExportableCredential = {
|
||||||
const fileName = this.getCredentialsPath(sharedCredential.credentials.id);
|
id,
|
||||||
const sanitizedCredential: ExportableCredential = {
|
name,
|
||||||
id: sharedCredential.credentials.id,
|
type,
|
||||||
name: sharedCredential.credentials.name,
|
data: this.replaceCredentialData(credentials.getData()),
|
||||||
type: sharedCredential.credentials.type,
|
nodesAccess,
|
||||||
data: sanitizedData,
|
ownedBy: sharing.user.email,
|
||||||
nodesAccess: sharedCredential.credentials.nodesAccess,
|
|
||||||
};
|
};
|
||||||
this.logger.debug(`Writing credential ${sharedCredential.credentials.id} to ${fileName}`);
|
|
||||||
return await fsWriteFile(fileName, JSON.stringify(sanitizedCredential, null, 2));
|
const filePath = this.getCredentialsPath(id);
|
||||||
|
this.logger.debug(`Writing credentials stub "${name}" (ID ${id}) to: ${filePath}`);
|
||||||
|
|
||||||
|
return await fsWriteFile(filePath, JSON.stringify(stub, null, 2));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
count: credentialsToBeExported.length,
|
count: credentialsToBeExported.length,
|
||||||
folder: this.credentialExportFolder,
|
folder: this.credentialExportFolder,
|
||||||
|
|
|
@ -310,7 +310,10 @@ export class SourceControlImportService {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
|
public async importCredentialsFromWorkFolder(
|
||||||
|
candidates: SourceControlledFile[],
|
||||||
|
importingUserId: string,
|
||||||
|
) {
|
||||||
const candidateIds = candidates.map((c) => c.id);
|
const candidateIds = candidates.map((c) => c.id);
|
||||||
const existingCredentials = await Container.get(CredentialsRepository).find({
|
const existingCredentials = await Container.get(CredentialsRepository).find({
|
||||||
where: {
|
where: {
|
||||||
|
@ -335,9 +338,6 @@ export class SourceControlImportService {
|
||||||
const existingCredential = existingCredentials.find(
|
const existingCredential = existingCredentials.find(
|
||||||
(e) => e.id === credential.id && e.type === credential.type,
|
(e) => e.id === credential.id && e.type === credential.type,
|
||||||
);
|
);
|
||||||
const sharedOwner = existingSharedCredentials.find(
|
|
||||||
(e) => e.credentialsId === credential.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { name, type, data, id, nodesAccess } = credential;
|
const { name, type, data, id, nodesAccess } = credential;
|
||||||
const newCredentialObject = new Credentials({ id, name }, type, []);
|
const newCredentialObject = new Credentials({ id, name }, type, []);
|
||||||
|
@ -351,10 +351,23 @@ export class SourceControlImportService {
|
||||||
this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
|
this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
|
||||||
await Container.get(CredentialsRepository).upsert(newCredentialObject, ['id']);
|
await Container.get(CredentialsRepository).upsert(newCredentialObject, ['id']);
|
||||||
|
|
||||||
if (!sharedOwner) {
|
const isOwnedLocally = existingSharedCredentials.some(
|
||||||
|
(c) => c.credentialsId === credential.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOwnedLocally) {
|
||||||
|
const remoteOwnerId = credential.ownedBy
|
||||||
|
? await Container.get(UserRepository)
|
||||||
|
.findOne({
|
||||||
|
where: { email: credential.ownedBy },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
.then((user) => user?.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
const newSharedCredential = new SharedCredentials();
|
const newSharedCredential = new SharedCredentials();
|
||||||
newSharedCredential.credentialsId = newCredentialObject.id as string;
|
newSharedCredential.credentialsId = newCredentialObject.id as string;
|
||||||
newSharedCredential.userId = userId;
|
newSharedCredential.userId = remoteOwnerId ?? importingUserId;
|
||||||
newSharedCredential.role = 'credential:owner';
|
newSharedCredential.role = 'credential:owner';
|
||||||
|
|
||||||
await Container.get(SharedCredentialsRepository).upsert({ ...newSharedCredential }, [
|
await Container.get(SharedCredentialsRepository).upsert({ ...newSharedCredential }, [
|
||||||
|
|
|
@ -6,4 +6,10 @@ export interface ExportableCredential {
|
||||||
type: string;
|
type: string;
|
||||||
data: ICredentialDataDecryptedObject;
|
data: ICredentialDataDecryptedObject;
|
||||||
nodesAccess: ICredentialNodeAccess[];
|
nodesAccess: ICredentialNodeAccess[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email of the user who owns this credential at the source instance.
|
||||||
|
* Ownership is mirrored at target instance if user is also present there.
|
||||||
|
*/
|
||||||
|
ownedBy: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ export class SourceControlPullWorkFolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SourceControllPullOptions {
|
export class SourceControllPullOptions {
|
||||||
|
/** ID of user performing a source control pull. */
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
|
|
@ -0,0 +1,152 @@
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import Container from 'typedi';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import * as utils from 'n8n-workflow';
|
||||||
|
import { Cipher } from 'n8n-core';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { InstanceSettings } from 'n8n-core';
|
||||||
|
|
||||||
|
import * as testDb from '../shared/testDb';
|
||||||
|
import { SourceControlImportService } from '@/environments/sourceControl/sourceControlImport.service.ee';
|
||||||
|
import { createMember, getGlobalOwner } from '../shared/db/users';
|
||||||
|
import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository';
|
||||||
|
import { mockInstance } from '../../shared/mocking';
|
||||||
|
import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile';
|
||||||
|
import type { ExportableCredential } from '@/environments/sourceControl/types/exportableCredential';
|
||||||
|
|
||||||
|
describe('SourceControlImportService', () => {
|
||||||
|
let service: SourceControlImportService;
|
||||||
|
const cipher = mockInstance(Cipher);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
service = new SourceControlImportService(
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock<InstanceSettings>({ n8nFolder: '/some-path' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await testDb.truncate(['Credentials', 'SharedCredentials']);
|
||||||
|
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testDb.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importCredentialsFromWorkFolder()', () => {
|
||||||
|
describe('if user email specified by `ownedBy` exists at target instance', () => {
|
||||||
|
it('should assign credential ownership to original user', async () => {
|
||||||
|
const [importingUser, member] = await Promise.all([getGlobalOwner(), createMember()]);
|
||||||
|
|
||||||
|
fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content'));
|
||||||
|
|
||||||
|
const CREDENTIAL_ID = nanoid();
|
||||||
|
|
||||||
|
const stub: ExportableCredential = {
|
||||||
|
id: CREDENTIAL_ID,
|
||||||
|
name: 'My Credential',
|
||||||
|
type: 'someCredentialType',
|
||||||
|
data: {},
|
||||||
|
nodesAccess: [],
|
||||||
|
ownedBy: member.email, // user at source instance owns credential
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
|
||||||
|
|
||||||
|
cipher.encrypt.mockReturnValue('some-encrypted-data');
|
||||||
|
|
||||||
|
await service.importCredentialsFromWorkFolder(
|
||||||
|
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
|
||||||
|
importingUser.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
|
||||||
|
credentialsId: CREDENTIAL_ID,
|
||||||
|
userId: member.id,
|
||||||
|
role: 'credential:owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sharing).toBeTruthy(); // same user at target instance owns credential
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if user email specified by `ownedBy` is `null`', () => {
|
||||||
|
it('should assign credential ownership to importing user', async () => {
|
||||||
|
const importingUser = await getGlobalOwner();
|
||||||
|
|
||||||
|
fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content'));
|
||||||
|
|
||||||
|
const CREDENTIAL_ID = nanoid();
|
||||||
|
|
||||||
|
const stub: ExportableCredential = {
|
||||||
|
id: CREDENTIAL_ID,
|
||||||
|
name: 'My Credential',
|
||||||
|
type: 'someCredentialType',
|
||||||
|
data: {},
|
||||||
|
nodesAccess: [],
|
||||||
|
ownedBy: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
|
||||||
|
|
||||||
|
cipher.encrypt.mockReturnValue('some-encrypted-data');
|
||||||
|
|
||||||
|
await service.importCredentialsFromWorkFolder(
|
||||||
|
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
|
||||||
|
importingUser.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
|
||||||
|
credentialsId: CREDENTIAL_ID,
|
||||||
|
userId: importingUser.id,
|
||||||
|
role: 'credential:owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sharing).toBeTruthy(); // original user has no email, so importing user owns credential
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if user email specified by `ownedBy` does not exist at target instance', () => {
|
||||||
|
it('should assign credential ownership to importing user', async () => {
|
||||||
|
const importingUser = await getGlobalOwner();
|
||||||
|
|
||||||
|
fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content'));
|
||||||
|
|
||||||
|
const CREDENTIAL_ID = nanoid();
|
||||||
|
|
||||||
|
const stub: ExportableCredential = {
|
||||||
|
id: CREDENTIAL_ID,
|
||||||
|
name: 'My Credential',
|
||||||
|
type: 'someCredentialType',
|
||||||
|
data: {},
|
||||||
|
nodesAccess: [],
|
||||||
|
ownedBy: 'user@test.com', // user at source instance owns credential
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
|
||||||
|
|
||||||
|
cipher.encrypt.mockReturnValue('some-encrypted-data');
|
||||||
|
|
||||||
|
await service.importCredentialsFromWorkFolder(
|
||||||
|
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
|
||||||
|
importingUser.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharing = await Container.get(SharedCredentialsRepository).findOneBy({
|
||||||
|
credentialsId: CREDENTIAL_ID,
|
||||||
|
userId: importingUser.id,
|
||||||
|
role: 'credential:owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -137,3 +137,7 @@ export const getLdapIdentities = async () =>
|
||||||
where: { providerType: 'ldap' },
|
where: { providerType: 'ldap' },
|
||||||
relations: ['user'],
|
relations: ['user'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export async function getGlobalOwner() {
|
||||||
|
return await Container.get(UserRepository).findOneByOrFail({ role: 'global:owner' });
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue