fix(core): Assign credential ownership correctly in source control import (#8955)

This commit is contained in:
Iván Ovejero 2024-03-26 17:18:41 +01:00 committed by GitHub
parent 160dfd383d
commit 260bc07ca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 202 additions and 22 deletions

View file

@ -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,
}, },
}); });
} }

View file

@ -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,

View file

@ -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 }, [

View file

@ -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;
} }

View file

@ -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;

View file

@ -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
});
});
});
});

View file

@ -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' });
}