feat(core): Add list query middleware to credentials (#7041)

This commit is contained in:
Iván Ovejero 2023-09-04 15:00:25 +02:00 committed by GitHub
parent 413e0bccb4
commit fd78021b68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 402 additions and 27 deletions

View file

@ -40,7 +40,7 @@ import type { RoleNames } from '@db/entities/Role';
import { RoleService } from './services/role.service'; import { RoleService } from './services/role.service';
import { ExecutionRepository, RoleRepository } from './databases/repositories'; import { ExecutionRepository, RoleRepository } from './databases/repositories';
import { VariablesService } from './environments/variables/variables.service'; import { VariablesService } from './environments/variables/variables.service';
import type { Credentials } from './requests'; import type { CredentialsEntity } from './databases/entities/CredentialsEntity';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -539,7 +539,7 @@ export function getNodesWithInaccessibleCreds(workflow: WorkflowEntity, userCred
export function validateWorkflowCredentialUsage( export function validateWorkflowCredentialUsage(
newWorkflowVersion: WorkflowEntity, newWorkflowVersion: WorkflowEntity,
previousWorkflowVersion: WorkflowEntity, previousWorkflowVersion: WorkflowEntity,
credentialsUserHasAccessTo: Credentials.WithOwnedByAndSharedWith[], credentialsUserHasAccessTo: CredentialsEntity[],
) { ) {
/** /**
* We only need to check nodes that use credentials the current user cannot access, * We only need to check nodes that use credentials the current user cannot access,

View file

@ -10,9 +10,10 @@ import { EECredentialsController } from './credentials.controller.ee';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
import type { ICredentialsDb } from '@/Interfaces'; import type { ICredentialsDb } from '@/Interfaces';
import type { CredentialRequest } from '@/requests'; import type { CredentialRequest, ListQuery } from '@/requests';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { listQueryMiddleware } from '@/middlewares';
export const credentialsController = express.Router(); export const credentialsController = express.Router();
@ -35,8 +36,9 @@ credentialsController.use('/', EECredentialsController);
*/ */
credentialsController.get( credentialsController.get(
'/', '/',
ResponseHelper.send(async (req: CredentialRequest.GetAll) => { listQueryMiddleware,
return CredentialsService.getMany(req.user); ResponseHelper.send(async (req: ListQuery.Request) => {
return CredentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions });
}), }),
); );

View file

@ -9,7 +9,7 @@ import type {
import { CREDENTIAL_EMPTY_VALUE, deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow'; import { CREDENTIAL_EMPTY_VALUE, deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import type { FindManyOptions, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm'; import { In, Like } from 'typeorm';
import * as Db from '@/Db'; import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
@ -21,7 +21,7 @@ import { SharedCredentials } from '@db/entities/SharedCredentials';
import { validateEntity } from '@/GenericHelpers'; import { validateEntity } from '@/GenericHelpers';
import { ExternalHooks } from '@/ExternalHooks'; import { ExternalHooks } from '@/ExternalHooks';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import type { CredentialRequest } from '@/requests'; import type { CredentialRequest, ListQuery } from '@/requests';
import { CredentialTypes } from '@/CredentialTypes'; import { CredentialTypes } from '@/CredentialTypes';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
@ -37,33 +37,70 @@ export class CredentialsService {
}); });
} }
static async getMany(user: User, options?: { disableGlobalRole: boolean }) { private static toFindManyOptions(listQueryOptions?: ListQuery.Options) {
type Select = Array<keyof ICredentialsDb>; const findManyOptions: FindManyOptions<CredentialsEntity> = {};
const select: Select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; type Select = Array<keyof CredentialsEntity>;
const relations = ['shared', 'shared.role', 'shared.user']; const defaultRelations = ['shared', 'shared.role', 'shared.user'];
const defaultSelect: Select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'];
const returnAll = user.globalRole.name === 'owner' && options?.disableGlobalRole !== true; if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations };
const addOwnedByAndSharedWith = (c: CredentialsEntity) => const { filter, select, take, skip } = listQueryOptions;
Container.get(OwnershipService).addOwnedByAndSharedWith(c);
if (returnAll) { if (typeof filter?.name === 'string' && filter?.name !== '') {
const credentials = await Db.collections.Credentials.find({ select, relations }); filter.name = Like(`%${filter.name}%`);
return credentials.map(addOwnedByAndSharedWith);
} }
const ids = await CredentialsService.getAccessibleCredentials(user.id); if (typeof filter?.type === 'string' && filter?.type !== '') {
filter.type = Like(`%${filter.type}%`);
}
if (filter) findManyOptions.where = filter;
if (select) findManyOptions.select = select;
if (take) findManyOptions.take = take;
if (skip) findManyOptions.skip = skip;
if (take && select && !select?.id) {
findManyOptions.select = { ...findManyOptions.select, id: true }; // pagination requires id
}
if (!findManyOptions.select) {
findManyOptions.select = defaultSelect;
findManyOptions.relations = defaultRelations;
}
return findManyOptions;
}
private static addOwnedByAndSharedWith(credentials: CredentialsEntity[]) {
return credentials.map((c) => Container.get(OwnershipService).addOwnedByAndSharedWith(c));
}
static async getMany(
user: User,
options: { listQueryOptions?: ListQuery.Options; onlyOwn?: boolean } = {},
) {
const findManyOptions = this.toFindManyOptions(options.listQueryOptions);
const returnAll = user.globalRole.name === 'owner' && !options.onlyOwn;
const isDefaultSelect = !options.listQueryOptions?.select;
if (returnAll) {
const credentials = await Db.collections.Credentials.find(findManyOptions);
return isDefaultSelect ? this.addOwnedByAndSharedWith(credentials) : credentials;
}
const ids = await this.getAccessibleCredentials(user.id);
const credentials = await Db.collections.Credentials.find({ const credentials = await Db.collections.Credentials.find({
select, ...findManyOptions,
relations, where: { ...findManyOptions.where, id: In(ids) }, // only accessible credentials
where: { id: In(ids) },
}); });
return credentials.map(addOwnedByAndSharedWith); return isDefaultSelect ? this.addOwnedByAndSharedWith(credentials) : credentials;
} }
/** /**

View file

@ -0,0 +1,19 @@
import { IsOptional, IsString } from 'class-validator';
import { Expose } from 'class-transformer';
import { BaseFilter } from './base.filter.dto';
export class CredentialsFilter extends BaseFilter {
@IsString()
@IsOptional()
@Expose()
name?: string;
@IsString()
@IsOptional()
@Expose()
type?: string;
static async fromString(rawFilter: string) {
return this.toFilter(rawFilter, CredentialsFilter);
}
}

View file

@ -0,0 +1,15 @@
import { BaseSelect } from './base.select.dto';
export class CredentialsSelect extends BaseSelect {
static get selectableFields() {
return new Set([
'id', // always included downstream
'name',
'type',
]);
}
static fromString(rawFilter: string) {
return this.toSelect(rawFilter, CredentialsSelect);
}
}

View file

@ -2,6 +2,7 @@
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import { WorkflowFilter } from './dtos/workflow.filter.dto'; import { WorkflowFilter } from './dtos/workflow.filter.dto';
import { CredentialsFilter } from './dtos/credentials.filter.dto';
import { UserFilter } from './dtos/user.filter.dto'; import { UserFilter } from './dtos/user.filter.dto';
import { toError } from '@/utils'; import { toError } from '@/utils';
@ -21,6 +22,8 @@ export const filterListQueryMiddleware = async (
if (req.baseUrl.endsWith('workflows')) { if (req.baseUrl.endsWith('workflows')) {
Filter = WorkflowFilter; Filter = WorkflowFilter;
} else if (req.baseUrl.endsWith('credentials')) {
Filter = CredentialsFilter;
} else if (req.baseUrl.endsWith('users')) { } else if (req.baseUrl.endsWith('users')) {
Filter = UserFilter; Filter = UserFilter;
} else { } else {

View file

@ -2,6 +2,7 @@
import { WorkflowSelect } from './dtos/workflow.select.dto'; import { WorkflowSelect } from './dtos/workflow.select.dto';
import { UserSelect } from './dtos/user.select.dto'; import { UserSelect } from './dtos/user.select.dto';
import { CredentialsSelect } from './dtos/credentials.select.dto';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import { toError } from '@/utils'; import { toError } from '@/utils';
@ -17,6 +18,8 @@ export const selectListQueryMiddleware: RequestHandler = (req: ListQuery.Request
if (req.baseUrl.endsWith('workflows')) { if (req.baseUrl.endsWith('workflows')) {
Select = WorkflowSelect; Select = WorkflowSelect;
} else if (req.baseUrl.endsWith('credentials')) {
Select = CredentialsSelect;
} else if (req.baseUrl.endsWith('users')) { } else if (req.baseUrl.endsWith('users')) {
Select = UserSelect; Select = UserSelect;
} else { } else {

View file

@ -16,6 +16,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import { NodeOperationError } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import Container from 'typedi'; import Container from 'typedi';
import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import type { Credentials } from '@/requests'; import type { Credentials } from '@/requests';
export class EEWorkflowsService extends WorkflowsService { export class EEWorkflowsService extends WorkflowsService {
@ -106,9 +107,7 @@ export class EEWorkflowsService extends WorkflowsService {
currentUser: User, currentUser: User,
): Promise<void> { ): Promise<void> {
workflow.usedCredentials = []; workflow.usedCredentials = [];
const userCredentials = await CredentialsService.getMany(currentUser, { const userCredentials = await CredentialsService.getMany(currentUser, { onlyOwn: true });
disableGlobalRole: 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) {
@ -151,7 +150,7 @@ export class EEWorkflowsService extends WorkflowsService {
static validateCredentialPermissionsToUser( static validateCredentialPermissionsToUser(
workflow: WorkflowEntity, workflow: WorkflowEntity,
allowedCredentials: Credentials.WithOwnedByAndSharedWith[], allowedCredentials: CredentialsEntity[],
) { ) {
workflow.nodes.forEach((node) => { workflow.nodes.forEach((node) => {
if (!node.credentials) { if (!node.credentials) {

View file

@ -0,0 +1,297 @@
import * as testDb from './shared/testDb';
import * as utils from './shared/utils/';
import { randomCredentialPayload as payload } from './shared/random';
import type { Credentials } from '@/requests';
import type { User } from '@db/entities/User';
const { any } = expect;
const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] });
let owner: User;
let member: User;
beforeEach(async () => {
await testDb.truncate(['SharedCredentials', 'Credentials']);
owner = await testDb.createOwner();
member = await testDb.createMember();
});
type GetAllResponse = { body: { data: Credentials.WithOwnedByAndSharedWith[] } };
describe('GET /credentials', () => {
describe('should return', () => {
test('all credentials for owner', async () => {
const role = await testDb.getCredentialOwnerRole();
const { id: id1 } = await testDb.saveCredential(payload(), { user: owner, role });
const { id: id2 } = await testDb.saveCredential(payload(), { user: member, role });
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.expect(200);
expect(response.body.data).toHaveLength(2);
response.body.data.forEach(validateCredential);
const savedIds = [id1, id2];
const returnedIds = response.body.data.map((c) => c.id);
expect(savedIds).toEqual(returnedIds);
});
test('only own credentials for member', async () => {
const role = await testDb.getCredentialOwnerRole();
const firstMember = member;
const secondMember = await testDb.createMember();
const c1 = await testDb.saveCredential(payload(), { user: firstMember, role });
const c2 = await testDb.saveCredential(payload(), { user: secondMember, role });
const response: GetAllResponse = await testServer
.authAgentFor(firstMember)
.get('/credentials')
.expect(200);
expect(response.body.data).toHaveLength(1);
const [firstMemberCred] = response.body.data;
validateCredential(firstMemberCred);
expect(firstMemberCred.id).toBe(c1.id);
expect(firstMemberCred.id).not.toBe(c2.id);
});
});
describe('filter', () => {
test('should filter credentials by field: name - full match', async () => {
const role = await testDb.getCredentialOwnerRole();
const savedCred = await testDb.saveCredential(payload(), { user: owner, role });
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.query(`filter={ "name": "${savedCred.name}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
const [returnedCred] = response.body.data;
expect(returnedCred.name).toBe(savedCred.name);
const _response = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('filter={ "name": "Non-Existing Credential" }')
.expect(200);
expect(_response.body.data).toHaveLength(0);
});
test('should filter credentials by field: name - partial match', async () => {
const role = await testDb.getCredentialOwnerRole();
const savedCred = await testDb.saveCredential(payload(), { user: owner, role });
const partialName = savedCred.name.slice(3);
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.query(`filter={ "name": "${partialName}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
const [returnedCred] = response.body.data;
expect(returnedCred.name).toBe(savedCred.name);
const _response = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('filter={ "name": "Non-Existing Credential" }')
.expect(200);
expect(_response.body.data).toHaveLength(0);
});
test('should filter credentials by field: type - full match', async () => {
const role = await testDb.getCredentialOwnerRole();
const savedCred = await testDb.saveCredential(payload(), { user: owner, role });
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.query(`filter={ "type": "${savedCred.type}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
const [returnedCred] = response.body.data;
expect(returnedCred.type).toBe(savedCred.type);
const _response = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('filter={ "type": "Non-Existing Credential" }')
.expect(200);
expect(_response.body.data).toHaveLength(0);
});
test('should filter credentials by field: type - partial match', async () => {
const role = await testDb.getCredentialOwnerRole();
const savedCred = await testDb.saveCredential(payload(), { user: owner, role });
const partialType = savedCred.type.slice(3);
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.query(`filter={ "type": "${partialType}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
const [returnedCred] = response.body.data;
expect(returnedCred.type).toBe(savedCred.type);
const _response = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('filter={ "type": "Non-Existing Credential" }')
.expect(200);
expect(_response.body.data).toHaveLength(0);
});
});
describe('select', () => {
test('should select credential field: id', async () => {
const role = await testDb.getCredentialOwnerRole();
await testDb.saveCredential(payload(), { user: owner, role });
await testDb.saveCredential(payload(), { user: owner, role });
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('select=["id"]')
.expect(200);
expect(response.body).toEqual({
data: [{ id: any(String) }, { id: any(String) }],
});
});
test('should select credential field: name', async () => {
const role = await testDb.getCredentialOwnerRole();
await testDb.saveCredential(payload(), { user: owner, role });
await testDb.saveCredential(payload(), { user: owner, role });
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('select=["name"]')
.expect(200);
expect(response.body).toEqual({
data: [{ name: any(String) }, { name: any(String) }],
});
});
test('should select credential field: type', async () => {
const role = await testDb.getCredentialOwnerRole();
await testDb.saveCredential(payload(), { user: owner, role });
await testDb.saveCredential(payload(), { user: owner, role });
const response: GetAllResponse = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('select=["type"]')
.expect(200);
expect(response.body).toEqual({
data: [{ type: any(String) }, { type: any(String) }],
});
});
});
describe('take', () => {
test('should return n credentials or less, without skip', async () => {
const role = await testDb.getCredentialOwnerRole();
await testDb.saveCredential(payload(), { user: owner, role });
await testDb.saveCredential(payload(), { user: owner, role });
const response = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('take=2')
.expect(200);
expect(response.body.data).toHaveLength(2);
response.body.data.forEach(validateCredential);
const _response = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('take=1')
.expect(200);
expect(_response.body.data).toHaveLength(1);
_response.body.data.forEach(validateCredential);
});
test('should return n credentials or less, with skip', async () => {
const role = await testDb.getCredentialOwnerRole();
await testDb.saveCredential(payload(), { user: owner, role });
await testDb.saveCredential(payload(), { user: owner, role });
const response = await testServer
.authAgentFor(owner)
.get('/credentials')
.query('take=1&skip=1')
.expect(200);
expect(response.body.data).toHaveLength(1);
response.body.data.forEach(validateCredential);
});
});
});
function validateCredential(credential: Credentials.WithOwnedByAndSharedWith) {
const { name, type, nodesAccess, sharedWith, ownedBy } = credential;
expect(typeof name).toBe('string');
expect(typeof type).toBe('string');
expect(typeof nodesAccess[0].nodeType).toBe('string');
expect('data' in credential).toBe(false);
if (sharedWith) expect(Array.isArray(sharedWith)).toBe(true);
if (ownedBy) {
const { id, email, firstName, lastName } = ownedBy;
expect(typeof id).toBe('string');
expect(typeof email).toBe('string');
expect(typeof firstName).toBe('string');
expect(typeof lastName).toBe('string');
}
}