refactor(core): Modernize credentials controllers and services (no-changelog) (#8488)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Iván Ovejero 2024-01-31 09:48:48 +01:00 committed by GitHub
parent 0febe62ad0
commit dac511b710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 330 additions and 441 deletions

View file

@ -34,7 +34,7 @@ import {
N8N_VERSION, N8N_VERSION,
TEMPLATES_DIR, TEMPLATES_DIR,
} from '@/constants'; } from '@/constants';
import { credentialsController } from '@/credentials/credentials.controller'; import { CredentialsController } from '@/credentials/credentials.controller';
import type { CurlHelper } from '@/requests'; import type { CurlHelper } from '@/requests';
import { registerController } from '@/decorators'; import { registerController } from '@/decorators';
import { AuthController } from '@/controllers/auth.controller'; import { AuthController } from '@/controllers/auth.controller';
@ -229,6 +229,7 @@ export class Server extends AbstractServer {
ActiveWorkflowsController, ActiveWorkflowsController,
WorkflowsController, WorkflowsController,
ExecutionsController, ExecutionsController,
CredentialsController,
]; ];
if ( if (
@ -347,8 +348,6 @@ export class Server extends AbstractServer {
await this.registerControllers(ignoredEndpoints); await this.registerControllers(ignoredEndpoints);
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
// ---------------------------------------- // ----------------------------------------
// SAML // SAML
// ---------------------------------------- // ----------------------------------------

View file

@ -1,195 +0,0 @@
import express from 'express';
import type { INodeCredentialTestResult } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import type { CredentialRequest } from '@/requests';
import { License } from '@/License';
import { EECredentialsService as EECredentials } from './credentials.service.ee';
import { OwnershipService } from '@/services/ownership.service';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import * as utils from '@/utils';
import { UserManagementMailer } from '@/UserManagement/email';
export const EECredentialsController = express.Router();
EECredentialsController.use((req, res, next) => {
if (!Container.get(License).isSharingEnabled()) {
// skip ee router and use free one
next('router');
return;
}
// use ee router
next();
});
/**
* GET /credentials/:id
*/
EECredentialsController.get(
'/:id(\\w+)',
(req, res, next) => (req.params.id === 'new' ? next('router') : next()), // skip ee router and use free one for naming
ResponseHelper.send(async (req: CredentialRequest.Get) => {
const { id: credentialId } = req.params;
const includeDecryptedData = req.query.includeData === 'true';
let credential = await Container.get(CredentialsRepository).findOne({
where: { id: credentialId },
relations: ['shared', 'shared.user'],
});
if (!credential) {
throw new NotFoundError(
'Could not load the credential. If you think this is an error, ask the owner to share it with you again',
);
}
const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id);
if (!userSharing && !req.user.hasGlobalScope('credential:read')) {
throw new UnauthorizedError('Forbidden.');
}
credential = Container.get(OwnershipService).addOwnedByAndSharedWith(credential);
if (!includeDecryptedData || !userSharing || userSharing.role !== 'credential:owner') {
const { data: _, ...rest } = credential;
return { ...rest };
}
const { data: _, ...rest } = credential;
const decryptedData = EECredentials.redact(EECredentials.decrypt(credential), credential);
return { data: decryptedData, ...rest };
}),
);
/**
* POST /credentials/test
*
* Test if a credential is valid.
*/
EECredentialsController.post(
'/test',
ResponseHelper.send(async (req: CredentialRequest.Test): Promise<INodeCredentialTestResult> => {
const { credentials } = req.body;
const credentialId = credentials.id;
const { ownsCredential } = await EECredentials.isOwned(req.user, credentialId);
const sharing = await EECredentials.getSharing(req.user, credentialId, {
allowGlobalScope: true,
globalScope: 'credential:read',
});
if (!ownsCredential) {
if (!sharing) {
throw new UnauthorizedError('Forbidden');
}
const decryptedData = EECredentials.decrypt(sharing.credentials);
Object.assign(credentials, { data: decryptedData });
}
const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = EECredentials.decrypt(sharing.credentials);
mergedCredentials.data = EECredentials.unredact(mergedCredentials.data, decryptedData);
}
return await EECredentials.test(req.user, mergedCredentials);
}),
);
/**
* (EE) PUT /credentials/:id/share
*
* Grant or remove users' access to a credential.
*/
EECredentialsController.put(
'/:credentialId/share',
ResponseHelper.send(async (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 isOwnedRes = await EECredentials.isOwned(req.user, credentialId);
const { ownsCredential } = isOwnedRes;
let { credential } = isOwnedRes;
if (!ownsCredential || !credential) {
credential = undefined;
// Allow owners/admins to share
if (req.user.hasGlobalScope('credential:share')) {
const sharedRes = await EECredentials.getSharing(req.user, credentialId, {
allowGlobalScope: true,
globalScope: 'credential:share',
});
credential = sharedRes?.credentials;
}
if (!credential) {
throw new UnauthorizedError('Forbidden');
}
}
const ownerIds = (
await EECredentials.getSharings(Db.getConnection().createEntityManager(), credentialId, [
'shared',
])
)
.filter((e) => e.role === 'credential:owner')
.map((e) => e.userId);
let amountRemoved: number | null = null;
let newShareeIds: string[] = [];
await Db.transaction(async (trx) => {
// remove all sharings that are not supposed to exist anymore
const { affected } = await Container.get(CredentialsRepository).pruneSharings(
trx,
credentialId,
[...ownerIds, ...shareWithIds],
);
if (affected) amountRemoved = affected;
const sharings = await EECredentials.getSharings(trx, credentialId);
// extract the new sharings that need to be added
newShareeIds = utils.rightDiff(
[sharings, (sharing) => sharing.userId],
[shareWithIds, (shareeId) => shareeId],
);
if (newShareeIds.length) {
await EECredentials.share(trx, credential!, newShareeIds);
}
});
void Container.get(InternalHooks).onUserSharedCredentials({
user: req.user,
credential_name: credential.name,
credential_type: credential.type,
credential_id: credential.id,
user_id_sharer: req.user.id,
user_ids_sharees_added: newShareeIds,
sharees_removed: amountRemoved,
});
await Container.get(UserManagementMailer).notifyCredentialsShared({
sharer: req.user,
newShareeIds,
credentialsName: credential.name,
});
}),
);

View file

@ -1,62 +1,100 @@
import express from 'express';
import type { INodeCredentialTestResult } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow';
import * as ResponseHelper from '@/ResponseHelper';
import config from '@/config'; import config from '@/config';
import { EECredentialsController } from './credentials.controller.ee';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
import { CredentialRequest, ListQuery } from '@/requests';
import type { ICredentialsDb } from '@/Interfaces';
import type { CredentialRequest, ListQuery } from '@/requests';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { listQueryMiddleware } from '@/middlewares';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { NamingService } from '@/services/naming.service'; import { NamingService } from '@/services/naming.service';
import { License } from '@/License';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { OwnershipService } from '@/services/ownership.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
import { Authorized, Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db';
import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares';
export const credentialsController = express.Router(); @Authorized()
credentialsController.use('/', EECredentialsController); @RestController('/credentials')
export class CredentialsController {
constructor(
private readonly credentialsService: CredentialsService,
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
private readonly credentialsRepository: CredentialsRepository,
private readonly namingService: NamingService,
private readonly license: License,
private readonly logger: Logger,
private readonly ownershipService: OwnershipService,
private readonly internalHooks: InternalHooks,
private readonly userManagementMailer: UserManagementMailer,
) {}
/** @Get('/', { middlewares: listQueryMiddleware })
* GET /credentials async getMany(req: ListQuery.Request) {
*/ return await this.credentialsService.getMany(req.user, {
credentialsController.get( listQueryOptions: req.listQueryOptions,
'/', });
listQueryMiddleware, }
ResponseHelper.send(async (req: ListQuery.Request) => {
return await CredentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions });
}),
);
/** @Get('/new')
* GET /credentials/new async generateUniqueName(req: CredentialRequest.NewName) {
*
* Generate a unique credential name.
*/
credentialsController.get(
'/new',
ResponseHelper.send(async (req: CredentialRequest.NewName) => {
const requestedName = req.query.name ?? config.getEnv('credentials.defaultName'); const requestedName = req.query.name ?? config.getEnv('credentials.defaultName');
return { return {
name: await Container.get(NamingService).getUniqueCredentialName(requestedName), name: await this.namingService.getUniqueCredentialName(requestedName),
}; };
}), }
);
@Get('/:id')
async getOne(req: CredentialRequest.Get) {
if (this.license.isSharingEnabled()) {
const { id: credentialId } = req.params;
const includeDecryptedData = req.query.includeData === 'true';
let credential = await this.credentialsRepository.findOne({
where: { id: credentialId },
relations: ['shared', 'shared.user'],
});
if (!credential) {
throw new NotFoundError(
'Could not load the credential. If you think this is an error, ask the owner to share it with you again',
);
}
const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id);
if (!userSharing && !req.user.hasGlobalScope('credential:read')) {
throw new UnauthorizedError('Forbidden.');
}
credential = this.ownershipService.addOwnedByAndSharedWith(credential);
if (!includeDecryptedData || !userSharing || userSharing.role !== 'credential:owner') {
const { data: _, ...rest } = credential;
return { ...rest };
}
const { data: _, ...rest } = credential;
const decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
);
return { data: decryptedData, ...rest };
}
// non-enterprise
/**
* GET /credentials/:id
*/
credentialsController.get(
'/:id(\\w+)',
ResponseHelper.send(async (req: CredentialRequest.Get) => {
const { id: credentialId } = req.params; const { id: credentialId } = req.params;
const includeDecryptedData = req.query.includeData === 'true'; const includeDecryptedData = req.query.includeData === 'true';
const sharing = await CredentialsService.getSharing( const sharing = await this.credentialsService.getSharing(
req.user, req.user,
credentialId, credentialId,
{ allowGlobalScope: true, globalScope: 'credential:read' }, { allowGlobalScope: true, globalScope: 'credential:read' },
@ -75,52 +113,79 @@ credentialsController.get(
return { ...rest }; return { ...rest };
} }
const decryptedData = CredentialsService.redact( const decryptedData = this.credentialsService.redact(
CredentialsService.decrypt(credential), this.credentialsService.decrypt(credential),
credential, credential,
); );
return { data: decryptedData, ...rest }; return { data: decryptedData, ...rest };
}), }
);
@Post('/test')
async testCredentials(req: CredentialRequest.Test) {
if (this.license.isSharingEnabled()) {
const { credentials } = req.body;
const credentialId = credentials.id;
const { ownsCredential } = await this.enterpriseCredentialsService.isOwned(
req.user,
credentialId,
);
const sharing = await this.enterpriseCredentialsService.getSharing(req.user, credentialId, {
allowGlobalScope: true,
globalScope: 'credential:read',
});
if (!ownsCredential) {
if (!sharing) {
throw new UnauthorizedError('Forbidden');
}
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
Object.assign(credentials, { data: decryptedData });
}
const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
);
}
return await this.credentialsService.test(req.user, mergedCredentials);
}
// non-enterprise
/**
* POST /credentials/test
*
* Test if a credential is valid.
*/
credentialsController.post(
'/test',
ResponseHelper.send(async (req: CredentialRequest.Test): Promise<INodeCredentialTestResult> => {
const { credentials } = req.body; const { credentials } = req.body;
const sharing = await CredentialsService.getSharing(req.user, credentials.id, { const sharing = await this.credentialsService.getSharing(req.user, credentials.id, {
allowGlobalScope: true, allowGlobalScope: true,
globalScope: 'credential:read', globalScope: 'credential:read',
}); });
const mergedCredentials = deepCopy(credentials); const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) { if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = CredentialsService.decrypt(sharing.credentials); const decryptedData = this.credentialsService.decrypt(sharing.credentials);
mergedCredentials.data = CredentialsService.unredact(mergedCredentials.data, decryptedData); mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
);
} }
return await CredentialsService.test(req.user, mergedCredentials); return await this.credentialsService.test(req.user, mergedCredentials);
}), }
);
/** @Post('/')
* POST /credentials async createCredentials(req: CredentialRequest.Create) {
*/ const newCredential = await this.credentialsService.prepareCreateData(req.body);
credentialsController.post(
'/',
ResponseHelper.send(async (req: CredentialRequest.Create) => {
const newCredential = await CredentialsService.prepareCreateData(req.body);
const encryptedData = CredentialsService.createEncryptedData(null, newCredential); const encryptedData = this.credentialsService.createEncryptedData(null, newCredential);
const credential = await CredentialsService.save(newCredential, encryptedData, req.user); const credential = await this.credentialsService.save(newCredential, encryptedData, req.user);
void Container.get(InternalHooks).onUserCreatedCredentials({ void this.internalHooks.onUserCreatedCredentials({
user: req.user, user: req.user,
credential_name: newCredential.name, credential_name: newCredential.name,
credential_type: credential.type, credential_type: credential.type,
@ -129,18 +194,13 @@ credentialsController.post(
}); });
return credential; return credential;
}), }
);
/** @Patch('/:id')
* PATCH /credentials/:id async updateCredentials(req: CredentialRequest.Update) {
*/
credentialsController.patch(
'/:id(\\w+)',
ResponseHelper.send(async (req: CredentialRequest.Update): Promise<ICredentialsDb> => {
const { id: credentialId } = req.params; const { id: credentialId } = req.params;
const sharing = await CredentialsService.getSharing( const sharing = await this.credentialsService.getSharing(
req.user, req.user,
credentialId, credentialId,
{ {
@ -151,42 +211,36 @@ credentialsController.patch(
); );
if (!sharing) { if (!sharing) {
Container.get(Logger).info( this.logger.info('Attempt to update credential blocked due to lack of permissions', {
'Attempt to update credential blocked due to lack of permissions', credentialId,
{ userId: req.user.id,
credentialId, });
userId: req.user.id,
},
);
throw new NotFoundError( throw new NotFoundError(
'Credential to be updated not found. You can only update credentials owned by you', 'Credential to be updated not found. You can only update credentials owned by you',
); );
} }
if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) { if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) {
Container.get(Logger).info( this.logger.info('Attempt to update credential blocked due to lack of permissions', {
'Attempt to update credential blocked due to lack of permissions', credentialId,
{ userId: req.user.id,
credentialId, });
userId: req.user.id,
},
);
throw new UnauthorizedError('You can only update credentials owned by you'); throw new UnauthorizedError('You can only update credentials owned by you');
} }
const { credentials: credential } = sharing; const { credentials: credential } = sharing;
const decryptedData = CredentialsService.decrypt(credential); const decryptedData = this.credentialsService.decrypt(credential);
const preparedCredentialData = await CredentialsService.prepareUpdateData( const preparedCredentialData = await this.credentialsService.prepareUpdateData(
req.body, req.body,
decryptedData, decryptedData,
); );
const newCredentialData = CredentialsService.createEncryptedData( const newCredentialData = this.credentialsService.createEncryptedData(
credentialId, credentialId,
preparedCredentialData, preparedCredentialData,
); );
const responseData = await CredentialsService.update(credentialId, newCredentialData); const responseData = await this.credentialsService.update(credentialId, newCredentialData);
if (responseData === null) { if (responseData === null) {
throw new NotFoundError(`Credential ID "${credentialId}" could not be found to be updated.`); throw new NotFoundError(`Credential ID "${credentialId}" could not be found to be updated.`);
@ -195,21 +249,16 @@ credentialsController.patch(
// Remove the encrypted data as it is not needed in the frontend // Remove the encrypted data as it is not needed in the frontend
const { data: _, ...rest } = responseData; const { data: _, ...rest } = responseData;
Container.get(Logger).verbose('Credential updated', { credentialId }); this.logger.verbose('Credential updated', { credentialId });
return { ...rest }; return { ...rest };
}), }
);
/** @Delete('/:id')
* DELETE /credentials/:id async deleteCredentials(req: CredentialRequest.Delete) {
*/
credentialsController.delete(
'/:id(\\w+)',
ResponseHelper.send(async (req: CredentialRequest.Delete) => {
const { id: credentialId } = req.params; const { id: credentialId } = req.params;
const sharing = await CredentialsService.getSharing( const sharing = await this.credentialsService.getSharing(
req.user, req.user,
credentialId, credentialId,
{ {
@ -220,33 +269,112 @@ credentialsController.delete(
); );
if (!sharing) { if (!sharing) {
Container.get(Logger).info( this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
'Attempt to delete credential blocked due to lack of permissions', credentialId,
{ userId: req.user.id,
credentialId, });
userId: req.user.id,
},
);
throw new NotFoundError( throw new NotFoundError(
'Credential to be deleted not found. You can only removed credentials owned by you', 'Credential to be deleted not found. You can only removed credentials owned by you',
); );
} }
if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:delete')) { if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:delete')) {
Container.get(Logger).info( this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
'Attempt to delete credential blocked due to lack of permissions', credentialId,
{ userId: req.user.id,
credentialId, });
userId: req.user.id,
},
);
throw new UnauthorizedError('You can only remove credentials owned by you'); throw new UnauthorizedError('You can only remove credentials owned by you');
} }
const { credentials: credential } = sharing; const { credentials: credential } = sharing;
await CredentialsService.delete(credential); await this.credentialsService.delete(credential);
return true; return true;
}), }
);
@Licensed('feat:sharing')
@Put('/:id/share')
async shareCredentials(req: CredentialRequest.Share) {
const { id: credentialId } = req.params;
const { shareWithIds } = req.body;
if (
!Array.isArray(shareWithIds) ||
!shareWithIds.every((userId) => typeof userId === 'string')
) {
throw new BadRequestError('Bad request');
}
const isOwnedRes = await this.enterpriseCredentialsService.isOwned(req.user, credentialId);
const { ownsCredential } = isOwnedRes;
let { credential } = isOwnedRes;
if (!ownsCredential || !credential) {
credential = undefined;
// Allow owners/admins to share
if (req.user.hasGlobalScope('credential:share')) {
const sharedRes = await this.enterpriseCredentialsService.getSharing(
req.user,
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:share',
},
);
credential = sharedRes?.credentials;
}
if (!credential) {
throw new UnauthorizedError('Forbidden');
}
}
const ownerIds = (
await this.enterpriseCredentialsService.getSharings(
Db.getConnection().createEntityManager(),
credentialId,
['shared'],
)
)
.filter((e) => e.role === 'credential:owner')
.map((e) => e.userId);
let amountRemoved: number | null = null;
let newShareeIds: string[] = [];
await Db.transaction(async (trx) => {
// remove all sharings that are not supposed to exist anymore
const { affected } = await this.credentialsRepository.pruneSharings(trx, credentialId, [
...ownerIds,
...shareWithIds,
]);
if (affected) amountRemoved = affected;
const sharings = await this.enterpriseCredentialsService.getSharings(trx, credentialId);
// extract the new sharings that need to be added
newShareeIds = utils.rightDiff(
[sharings, (sharing) => sharing.userId],
[shareWithIds, (shareeId) => shareeId],
);
if (newShareeIds.length) {
await this.enterpriseCredentialsService.share(trx, credential!, newShareeIds);
}
});
void this.internalHooks.onUserSharedCredentials({
user: req.user,
credential_name: credential.name,
credential_type: credential.type,
credential_id: credential.id,
user_id_sharer: req.user.id,
user_ids_sharees_added: newShareeIds,
sharees_removed: amountRemoved,
});
await this.userManagementMailer.notifyCredentialsShared({
sharer: req.user,
newShareeIds,
credentialsName: credential.name,
});
}
}

View file

@ -1,17 +1,20 @@
import { Container } from 'typedi';
import type { EntityManager, FindOptionsWhere } from 'typeorm'; import type { EntityManager, FindOptionsWhere } from 'typeorm';
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { SharedCredentials } from '@db/entities/SharedCredentials';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { CredentialsService, type CredentialsGetSharedOptions } from './credentials.service'; import { type CredentialsGetSharedOptions } from './credentials.service';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import { Service } from 'typedi';
export class EECredentialsService extends CredentialsService { @Service()
static async isOwned( export class EnterpriseCredentialsService {
user: User, constructor(
credentialId: string, private readonly userRepository: UserRepository,
): Promise<{ ownsCredential: boolean; credential?: CredentialsEntity }> { private readonly sharedCredentialsRepository: SharedCredentialsRepository,
) {}
async isOwned(user: User, credentialId: string) {
const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [ const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [
'credentials', 'credentials',
]); ]);
@ -26,12 +29,12 @@ export class EECredentialsService extends CredentialsService {
/** /**
* Retrieve the sharing that matches a user and a credential. * Retrieve the sharing that matches a user and a credential.
*/ */
static async getSharing( async getSharing(
user: User, user: User,
credentialId: string, credentialId: string,
options: CredentialsGetSharedOptions, options: CredentialsGetSharedOptions,
relations: string[] = ['credentials'], relations: string[] = ['credentials'],
): Promise<SharedCredentials | null> { ) {
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId }; const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
// Omit user from where if the requesting user has relevant // Omit user from where if the requesting user has relevant
@ -41,35 +44,28 @@ export class EECredentialsService extends CredentialsService {
where.userId = user.id; where.userId = user.id;
} }
return await Container.get(SharedCredentialsRepository).findOne({ return await this.sharedCredentialsRepository.findOne({
where, where,
relations, relations,
}); });
} }
static async getSharings( async getSharings(transaction: EntityManager, credentialId: string, relations = ['shared']) {
transaction: EntityManager,
credentialId: string,
relations = ['shared'],
): Promise<SharedCredentials[]> {
const credential = await transaction.findOne(CredentialsEntity, { const credential = await transaction.findOne(CredentialsEntity, {
where: { id: credentialId }, where: { id: credentialId },
relations, relations,
}); });
return credential?.shared ?? []; return credential?.shared ?? [];
} }
static async share( async share(transaction: EntityManager, credential: CredentialsEntity, shareWithIds: string[]) {
transaction: EntityManager, const users = await this.userRepository.getByIds(transaction, shareWithIds);
credential: CredentialsEntity,
shareWithIds: string[],
): Promise<SharedCredentials[]> {
const users = await Container.get(UserRepository).getByIds(transaction, shareWithIds);
const newSharedCredentials = users const newSharedCredentials = users
.filter((user) => !user.isPending) .filter((user) => !user.isPending)
.map((user) => .map((user) =>
Container.get(SharedCredentialsRepository).create({ this.sharedCredentialsRepository.create({
credentialsId: credential.id, credentialsId: credential.id,
userId: user.id, userId: user.id,
role: 'credential:user', role: 'credential:user',

View file

@ -3,15 +3,11 @@ import type {
ICredentialDataDecryptedObject, ICredentialDataDecryptedObject,
ICredentialsDecrypted, ICredentialsDecrypted,
ICredentialType, ICredentialType,
INodeCredentialTestResult,
INodeProperties, INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow';
import { Container } from 'typedi';
import type { FindOptionsWhere } from 'typeorm'; import type { FindOptionsWhere } from 'typeorm';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import * as Db from '@/Db'; import * as Db from '@/Db';
import type { ICredentialsDb } from '@/Interfaces'; import type { ICredentialsDb } from '@/Interfaces';
import { CredentialsHelper, createCredentialsFromCredentialsEntity } from '@/CredentialsHelper'; import { CredentialsHelper, createCredentialsFromCredentialsEntity } from '@/CredentialsHelper';
@ -27,20 +23,32 @@ import { OwnershipService } from '@/services/ownership.service';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { Service } from 'typedi';
export type CredentialsGetSharedOptions = export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: true; globalScope: Scope }
| { allowGlobalScope: false }; | { allowGlobalScope: false };
@Service()
export class CredentialsService { export class CredentialsService {
static async get(where: FindOptionsWhere<ICredentialsDb>, options?: { relations: string[] }) { constructor(
return await Container.get(CredentialsRepository).findOne({ private readonly credentialsRepository: CredentialsRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly ownershipService: OwnershipService,
private readonly logger: Logger,
private readonly credenntialsHelper: CredentialsHelper,
private readonly externalHooks: ExternalHooks,
private readonly credentialTypes: CredentialTypes,
) {}
async get(where: FindOptionsWhere<ICredentialsDb>, options?: { relations: string[] }) {
return await this.credentialsRepository.findOne({
relations: options?.relations, relations: options?.relations,
where, where,
}); });
} }
static async getMany( async getMany(
user: User, user: User,
options: { listQueryOptions?: ListQuery.Options; onlyOwn?: boolean } = {}, options: { listQueryOptions?: ListQuery.Options; onlyOwn?: boolean } = {},
) { ) {
@ -48,31 +56,29 @@ export class CredentialsService {
const isDefaultSelect = !options.listQueryOptions?.select; const isDefaultSelect = !options.listQueryOptions?.select;
if (returnAll) { if (returnAll) {
const credentials = await Container.get(CredentialsRepository).findMany( const credentials = await this.credentialsRepository.findMany(options.listQueryOptions);
options.listQueryOptions,
);
return isDefaultSelect return isDefaultSelect
? credentials.map((c) => Container.get(OwnershipService).addOwnedByAndSharedWith(c)) ? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c))
: credentials; : credentials;
} }
const ids = await Container.get(SharedCredentialsRepository).getAccessibleCredentials(user.id); const ids = await this.sharedCredentialsRepository.getAccessibleCredentials(user.id);
const credentials = await Container.get(CredentialsRepository).findMany( const credentials = await this.credentialsRepository.findMany(
options.listQueryOptions, options.listQueryOptions,
ids, // only accessible credentials ids, // only accessible credentials
); );
return isDefaultSelect return isDefaultSelect
? credentials.map((c) => Container.get(OwnershipService).addOwnedByAndSharedWith(c)) ? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c))
: credentials; : credentials;
} }
/** /**
* Retrieve the sharing that matches a user and a credential. * Retrieve the sharing that matches a user and a credential.
*/ */
static async getSharing( async getSharing(
user: User, user: User,
credentialId: string, credentialId: string,
options: CredentialsGetSharedOptions, options: CredentialsGetSharedOptions,
@ -88,17 +94,17 @@ export class CredentialsService {
where.role = 'credential:owner'; where.role = 'credential:owner';
} }
return await Container.get(SharedCredentialsRepository).findOne({ where, relations }); return await this.sharedCredentialsRepository.findOne({ where, relations });
} }
static async prepareCreateData( async prepareCreateData(
data: CredentialRequest.CredentialProperties, data: CredentialRequest.CredentialProperties,
): Promise<CredentialsEntity> { ): Promise<CredentialsEntity> {
const { id, ...rest } = data; const { id, ...rest } = data;
// This saves us a merge but requires some type casting. These // This saves us a merge but requires some type casting. These
// types are compatible for this case. // types are compatible for this case.
const newCredentials = Container.get(CredentialsRepository).create(rest as ICredentialsDb); const newCredentials = this.credentialsRepository.create(rest as ICredentialsDb);
await validateEntity(newCredentials); await validateEntity(newCredentials);
@ -110,7 +116,7 @@ export class CredentialsService {
return newCredentials; return newCredentials;
} }
static async prepareUpdateData( async prepareUpdateData(
data: CredentialRequest.CredentialProperties, data: CredentialRequest.CredentialProperties,
decryptedData: ICredentialDataDecryptedObject, decryptedData: ICredentialDataDecryptedObject,
): Promise<CredentialsEntity> { ): Promise<CredentialsEntity> {
@ -121,7 +127,7 @@ export class CredentialsService {
// This saves us a merge but requires some type casting. These // This saves us a merge but requires some type casting. These
// types are compatible for this case. // types are compatible for this case.
const updateData = Container.get(CredentialsRepository).create(mergedData as ICredentialsDb); const updateData = this.credentialsRepository.create(mergedData as ICredentialsDb);
await validateEntity(updateData); await validateEntity(updateData);
@ -141,7 +147,7 @@ export class CredentialsService {
return updateData; return updateData;
} }
static createEncryptedData(credentialId: string | null, data: CredentialsEntity): ICredentialsDb { createEncryptedData(credentialId: string | null, data: CredentialsEntity): ICredentialsDb {
const credentials = new Credentials( const credentials = new Credentials(
{ id: credentialId, name: data.name }, { id: credentialId, name: data.name },
data.type, data.type,
@ -158,35 +164,28 @@ export class CredentialsService {
return newCredentialData; return newCredentialData;
} }
static decrypt(credential: CredentialsEntity): ICredentialDataDecryptedObject { decrypt(credential: CredentialsEntity) {
const coreCredential = createCredentialsFromCredentialsEntity(credential); const coreCredential = createCredentialsFromCredentialsEntity(credential);
return coreCredential.getData(); return coreCredential.getData();
} }
static async update( async update(credentialId: string, newCredentialData: ICredentialsDb) {
credentialId: string, await this.externalHooks.run('credentials.update', [newCredentialData]);
newCredentialData: ICredentialsDb,
): Promise<ICredentialsDb | null> {
await Container.get(ExternalHooks).run('credentials.update', [newCredentialData]);
// Update the credentials in DB // Update the credentials in DB
await Container.get(CredentialsRepository).update(credentialId, newCredentialData); await this.credentialsRepository.update(credentialId, newCredentialData);
// We sadly get nothing back from "update". Neither if it updated a record // We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the updated entry. // nor the new value. So query now the updated entry.
return await Container.get(CredentialsRepository).findOneBy({ id: credentialId }); return await this.credentialsRepository.findOneBy({ id: credentialId });
} }
static async save( async save(credential: CredentialsEntity, encryptedData: ICredentialsDb, user: User) {
credential: CredentialsEntity,
encryptedData: ICredentialsDb,
user: User,
): Promise<CredentialsEntity> {
// To avoid side effects // To avoid side effects
const newCredential = new CredentialsEntity(); const newCredential = new CredentialsEntity();
Object.assign(newCredential, credential, encryptedData); Object.assign(newCredential, credential, encryptedData);
await Container.get(ExternalHooks).run('credentials.create', [encryptedData]); await this.externalHooks.run('credentials.create', [encryptedData]);
const result = await Db.transaction(async (transactionManager) => { const result = await Db.transaction(async (transactionManager) => {
const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential); const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential);
@ -205,39 +204,31 @@ export class CredentialsService {
return savedCredential; return savedCredential;
}); });
Container.get(Logger).verbose('New credential created', { this.logger.verbose('New credential created', {
credentialId: newCredential.id, credentialId: newCredential.id,
ownerId: user.id, ownerId: user.id,
}); });
return result; return result;
} }
static async delete(credentials: CredentialsEntity): Promise<void> { async delete(credentials: CredentialsEntity) {
await Container.get(ExternalHooks).run('credentials.delete', [credentials.id]); await this.externalHooks.run('credentials.delete', [credentials.id]);
await Container.get(CredentialsRepository).remove(credentials); await this.credentialsRepository.remove(credentials);
} }
static async test( async test(user: User, credentials: ICredentialsDecrypted) {
user: User, return await this.credenntialsHelper.testCredentials(user, credentials.type, credentials);
credentials: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const helper = Container.get(CredentialsHelper);
return await helper.testCredentials(user, credentials.type, credentials);
} }
// Take data and replace all sensitive values with a sentinel value. // Take data and replace all sensitive values with a sentinel value.
// This will replace password fields and oauth data. // This will replace password fields and oauth data.
static redact( redact(data: ICredentialDataDecryptedObject, credential: CredentialsEntity) {
data: ICredentialDataDecryptedObject,
credential: CredentialsEntity,
): ICredentialDataDecryptedObject {
const copiedData = deepCopy(data); const copiedData = deepCopy(data);
const credTypes = Container.get(CredentialTypes);
let credType: ICredentialType; let credType: ICredentialType;
try { try {
credType = credTypes.getByName(credential.type); credType = this.credentialTypes.getByName(credential.type);
} catch { } catch {
// This _should_ only happen when testing. If it does happen in // This _should_ only happen when testing. If it does happen in
// production it means it's either a mangled credential or a // production it means it's either a mangled credential or a
@ -249,7 +240,7 @@ export class CredentialsService {
const getExtendedProps = (type: ICredentialType) => { const getExtendedProps = (type: ICredentialType) => {
const props: INodeProperties[] = []; const props: INodeProperties[] = [];
for (const e of type.extends ?? []) { for (const e of type.extends ?? []) {
const extendsType = credTypes.getByName(e); const extendsType = this.credentialTypes.getByName(e);
const extendedProps = getExtendedProps(extendsType); const extendedProps = getExtendedProps(extendsType);
NodeHelpers.mergeNodeProperties(props, extendedProps); NodeHelpers.mergeNodeProperties(props, extendedProps);
} }
@ -287,7 +278,7 @@ export class CredentialsService {
return copiedData; return copiedData;
} }
private static unredactRestoreValues(unmerged: any, replacement: any) { private unredactRestoreValues(unmerged: any, replacement: any) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
for (const [key, value] of Object.entries(unmerged)) { for (const [key, value] of Object.entries(unmerged)) {
if (value === CREDENTIAL_BLANKING_VALUE || value === CREDENTIAL_EMPTY_VALUE) { if (value === CREDENTIAL_BLANKING_VALUE || value === CREDENTIAL_EMPTY_VALUE) {
@ -310,10 +301,10 @@ export class CredentialsService {
// Take unredacted data (probably from the DB) and merge it with // Take unredacted data (probably from the DB) and merge it with
// redacted data to create an unredacted version. // redacted data to create an unredacted version.
static unredact( unredact(
redactedData: ICredentialDataDecryptedObject, redactedData: ICredentialDataDecryptedObject,
savedData: ICredentialDataDecryptedObject, savedData: ICredentialDataDecryptedObject,
): ICredentialDataDecryptedObject { ) {
// Replace any blank sentinel values with their saved version // Replace any blank sentinel values with their saved version
const mergedData = deepCopy(redactedData); const mergedData = deepCopy(redactedData);
this.unredactRestoreValues(mergedData, savedData); this.unredactRestoreValues(mergedData, savedData);

View file

@ -161,7 +161,7 @@ export declare namespace CredentialRequest {
type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>;
type Share = AuthenticatedRequest<{ credentialId: string }, {}, { shareWithIds: string[] }>; type Share = AuthenticatedRequest<{ id: string }, {}, { shareWithIds: string[] }>;
} }
// ---------------------------------- // ----------------------------------

View file

@ -24,6 +24,7 @@ export class EnterpriseWorkflowService {
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly credentialsRepository: CredentialsRepository, private readonly credentialsRepository: CredentialsRepository,
private readonly credentialsService: CredentialsService,
) {} ) {}
async isOwned( async isOwned(
@ -70,7 +71,7 @@ export class EnterpriseWorkflowService {
currentUser: User, currentUser: User,
): Promise<void> { ): Promise<void> {
workflow.usedCredentials = []; workflow.usedCredentials = [];
const userCredentials = await CredentialsService.getMany(currentUser, { onlyOwn: true }); const userCredentials = await this.credentialsService.getMany(currentUser, { onlyOwn: true });
const credentialIdsUsedByWorkflow = new Set<string>(); const credentialIdsUsedByWorkflow = new Set<string>();
workflow.nodes.forEach((node) => { workflow.nodes.forEach((node) => {
if (!node.credentials) { if (!node.credentials) {
@ -139,7 +140,7 @@ export class EnterpriseWorkflowService {
throw new NotFoundError('Workflow not found'); throw new NotFoundError('Workflow not found');
} }
const allCredentials = await CredentialsService.getMany(user); const allCredentials = await this.credentialsService.getMany(user);
try { try {
return this.validateWorkflowCredentialUsage(workflow, previousVersion, allCredentials); return this.validateWorkflowCredentialUsage(workflow, previousVersion, allCredentials);

View file

@ -1,4 +1,3 @@
import { Service } from 'typedi';
import express from 'express'; import express from 'express';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import axios from 'axios'; import axios from 'axios';
@ -40,7 +39,6 @@ import { WorkflowExecutionService } from './workflowExecution.service';
import { WorkflowSharingService } from './workflowSharing.service'; import { WorkflowSharingService } from './workflowSharing.service';
import { UserManagementMailer } from '@/UserManagement/email'; import { UserManagementMailer } from '@/UserManagement/email';
@Service()
@Authorized() @Authorized()
@RestController('/workflows') @RestController('/workflows')
export class WorkflowsController { export class WorkflowsController {
@ -62,6 +60,7 @@ export class WorkflowsController {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly license: License, private readonly license: License,
private readonly mailer: UserManagementMailer, private readonly mailer: UserManagementMailer,
private readonly credentialsService: CredentialsService,
) {} ) {}
@Post('/') @Post('/')
@ -92,7 +91,7 @@ export class WorkflowsController {
// This is a new workflow, so we simply check if the user has access to // This is a new workflow, so we simply check if the user has access to
// all used workflows // all used workflows
const allCredentials = await CredentialsService.getMany(req.user); const allCredentials = await this.credentialsService.getMany(req.user);
try { try {
this.enterpriseWorkflowService.validateCredentialPermissionsToUser( this.enterpriseWorkflowService.validateCredentialPermissionsToUser(

View file

@ -6,7 +6,6 @@ import type { IUser } from 'n8n-workflow';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { License } from '@/License';
import { randomCredentialPayload } from './shared/random'; import { randomCredentialPayload } from './shared/random';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
@ -19,8 +18,10 @@ import { UserManagementMailer } from '@/UserManagement/email';
import { mockInstance } from '../shared/mocking'; import { mockInstance } from '../shared/mocking';
import config from '@/config'; import config from '@/config';
const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({
const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); endpointGroups: ['credentials'],
enabledFeatures: ['feat:sharing'],
});
let owner: User; let owner: User;
let member: User; let member: User;
@ -49,38 +50,6 @@ afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
// ----------------------------------------
// dynamic router switching
// ----------------------------------------
describe('router should switch based on flag', () => {
let savedCredentialId: string;
beforeEach(async () => {
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
savedCredentialId = savedCredential.id;
});
test('when sharing is disabled', async () => {
sharingSpy.mockReturnValueOnce(false);
await authOwnerAgent
.put(`/credentials/${savedCredentialId}/share`)
.send({ shareWithIds: [member.id] })
.expect(404);
await authOwnerAgent.get(`/credentials/${savedCredentialId}`).send().expect(200);
});
test('when sharing is enabled', async () => {
await authOwnerAgent
.put(`/credentials/${savedCredentialId}/share`)
.send({ shareWithIds: [member.id] })
.expect(200);
await authOwnerAgent.get(`/credentials/${savedCredentialId}`).send().expect(200);
});
});
// ---------------------------------------- // ----------------------------------------
// GET /credentials - fetch all credentials // GET /credentials - fetch all credentials
// ---------------------------------------- // ----------------------------------------
@ -521,6 +490,7 @@ describe('PUT /credentials/:id/share', () => {
responses.forEach((response) => expect(response.statusCode).toBe(400)); responses.forEach((response) => expect(response.statusCode).toBe(400));
expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0);
}); });
test('should unshare the credential', async () => { test('should unshare the credential', async () => {
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });

View file

@ -123,8 +123,8 @@ export const setupTestServer = ({
for (const group of endpointGroups) { for (const group of endpointGroups) {
switch (group) { switch (group) {
case 'credentials': case 'credentials':
const { credentialsController } = await import('@/credentials/credentials.controller'); const { CredentialsController } = await import('@/credentials/credentials.controller');
app.use(`/${REST_PATH_SEGMENT}/credentials`, credentialsController); registerController(app, CredentialsController);
break; break;
case 'workflows': case 'workflows':