mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat(core): Allow filtering executions and users by project in Public API (#10250)
This commit is contained in:
parent
ae50bb95a8
commit
7056e50b00
|
@ -29,6 +29,7 @@ export declare namespace ExecutionRequest {
|
||||||
includeData?: boolean;
|
includeData?: boolean;
|
||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
lastId?: string;
|
lastId?: string;
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
|
@ -95,9 +95,10 @@ export = {
|
||||||
status = undefined,
|
status = undefined,
|
||||||
includeData = false,
|
includeData = false,
|
||||||
workflowId = undefined,
|
workflowId = undefined,
|
||||||
|
projectId,
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read'], projectId);
|
||||||
|
|
||||||
// user does not have workflows hence no executions
|
// user does not have workflows hence no executions
|
||||||
// or the execution they are trying to access belongs to a workflow they do not own
|
// or the execution they are trying to access belongs to a workflow they do not own
|
||||||
|
|
|
@ -21,6 +21,14 @@ get:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: '1000'
|
example: '1000'
|
||||||
|
- name: projectId
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
explode: false
|
||||||
|
allowReserved: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: VmwOO9HeTEj20kxM
|
||||||
- $ref: '../../../../shared/spec/parameters/limit.yml'
|
- $ref: '../../../../shared/spec/parameters/limit.yml'
|
||||||
- $ref: '../../../../shared/spec/parameters/cursor.yml'
|
- $ref: '../../../../shared/spec/parameters/cursor.yml'
|
||||||
responses:
|
responses:
|
||||||
|
|
|
@ -9,6 +9,14 @@ get:
|
||||||
- $ref: '../../../../shared/spec/parameters/limit.yml'
|
- $ref: '../../../../shared/spec/parameters/limit.yml'
|
||||||
- $ref: '../../../../shared/spec/parameters/cursor.yml'
|
- $ref: '../../../../shared/spec/parameters/cursor.yml'
|
||||||
- $ref: '../schemas/parameters/includeRole.yml'
|
- $ref: '../schemas/parameters/includeRole.yml'
|
||||||
|
- name: projectId
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
explode: false
|
||||||
|
allowReserved: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: VmwOO9HeTEj20kxM
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Operation successful.
|
description: Operation successful.
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from '../../shared/middlewares/global.middleware';
|
} from '../../shared/middlewares/global.middleware';
|
||||||
import type { UserRequest } from '@/requests';
|
import type { UserRequest } from '@/requests';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { InvitationController } from '@/controllers/invitation.controller';
|
import { InvitationController } from '@/controllers/invitation.controller';
|
||||||
import { UsersController } from '@/controllers/users.controller';
|
import { UsersController } from '@/controllers/users.controller';
|
||||||
|
@ -51,12 +52,17 @@ export = {
|
||||||
validCursor,
|
validCursor,
|
||||||
globalScope(['user:list', 'user:read']),
|
globalScope(['user:list', 'user:read']),
|
||||||
async (req: UserRequest.Get, res: express.Response) => {
|
async (req: UserRequest.Get, res: express.Response) => {
|
||||||
const { offset = 0, limit = 100, includeRole = false } = req.query;
|
const { offset = 0, limit = 100, includeRole = false, projectId } = req.query;
|
||||||
|
|
||||||
|
const _in = projectId
|
||||||
|
? await Container.get(ProjectRelationRepository).findUserIdsByProjectId(projectId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [users, count] = await getAllUsersAndCount({
|
const [users, count] = await getAllUsersAndCount({
|
||||||
includeRole,
|
includeRole,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
|
in: _in,
|
||||||
});
|
});
|
||||||
|
|
||||||
const telemetryData = {
|
const telemetryData = {
|
||||||
|
|
|
@ -3,6 +3,8 @@ import { UserRepository } from '@db/repositories/user.repository';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import pick from 'lodash/pick';
|
import pick from 'lodash/pick';
|
||||||
import { validate as uuidValidate } from 'uuid';
|
import { validate as uuidValidate } from 'uuid';
|
||||||
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
|
import { In } from '@n8n/typeorm';
|
||||||
|
|
||||||
export async function getUser(data: {
|
export async function getUser(data: {
|
||||||
withIdentifier: string;
|
withIdentifier: string;
|
||||||
|
@ -25,9 +27,12 @@ export async function getAllUsersAndCount(data: {
|
||||||
includeRole?: boolean;
|
includeRole?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
in?: string[];
|
||||||
}): Promise<[User[], number]> {
|
}): Promise<[User[], number]> {
|
||||||
|
const { in: _in } = data;
|
||||||
|
|
||||||
const users = await Container.get(UserRepository).find({
|
const users = await Container.get(UserRepository).find({
|
||||||
where: {},
|
where: { ...(_in && { id: In(_in) }) },
|
||||||
skip: data.offset,
|
skip: data.offset,
|
||||||
take: data.limit,
|
take: data.limit,
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,15 +17,21 @@ function insertIf(condition: boolean, elements: string[]): string[] {
|
||||||
return condition ? elements : [];
|
return condition ? elements : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise<string[]> {
|
export async function getSharedWorkflowIds(
|
||||||
|
user: User,
|
||||||
|
scopes: Scope[],
|
||||||
|
projectId?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
if (Container.get(License).isSharingEnabled()) {
|
if (Container.get(License).isSharingEnabled()) {
|
||||||
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
|
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
|
||||||
scopes,
|
scopes,
|
||||||
|
projectId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
|
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
|
||||||
workflowRoles: ['workflow:owner'],
|
workflowRoles: ['workflow:owner'],
|
||||||
projectRoles: ['project:personalOwner'],
|
projectRoles: ['project:personalOwner'],
|
||||||
|
projectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,4 +52,13 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
|
||||||
{} as Record<ProjectRole, number>,
|
{} as Record<ProjectRole, number>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findUserIdsByProjectId(projectId: string): Promise<string[]> {
|
||||||
|
const rows = await this.find({
|
||||||
|
select: ['userId'],
|
||||||
|
where: { projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...new Set(rows.map((r) => r.userId))];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -306,7 +306,7 @@ export declare namespace UserRequest {
|
||||||
{ id: string; email: string; identifier: string },
|
{ id: string; email: string; identifier: string },
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{ limit?: number; offset?: number; cursor?: string; includeRole?: boolean }
|
{ limit?: number; offset?: number; cursor?: string; includeRole?: boolean; projectId?: string }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
|
export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
|
||||||
|
|
|
@ -27,11 +27,16 @@ export class WorkflowSharingService {
|
||||||
async getSharedWorkflowIds(
|
async getSharedWorkflowIds(
|
||||||
user: User,
|
user: User,
|
||||||
options:
|
options:
|
||||||
| { scopes: Scope[] }
|
| { scopes: Scope[]; projectId?: string }
|
||||||
| { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[] },
|
| { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[]; projectId?: string },
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
|
const { projectId } = options;
|
||||||
|
|
||||||
if (user.hasGlobalScope('workflow:read')) {
|
if (user.hasGlobalScope('workflow:read')) {
|
||||||
const sharedWorkflows = await this.sharedWorkflowRepository.find({ select: ['workflowId'] });
|
const sharedWorkflows = await this.sharedWorkflowRepository.find({
|
||||||
|
select: ['workflowId'],
|
||||||
|
...(projectId && { where: { projectId } }),
|
||||||
|
});
|
||||||
return sharedWorkflows.map(({ workflowId }) => workflowId);
|
return sharedWorkflows.map(({ workflowId }) => workflowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,8 @@ import {
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
|
import { createTeamProject } from '@test-integration/db/projects';
|
||||||
|
import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity';
|
||||||
|
|
||||||
let owner: User;
|
let owner: User;
|
||||||
let user1: User;
|
let user1: User;
|
||||||
|
@ -447,6 +449,42 @@ describe('GET /executions', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return executions filtered by project ID', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
const [firstProject, secondProject] = await Promise.all([
|
||||||
|
createTeamProject(),
|
||||||
|
createTeamProject(),
|
||||||
|
]);
|
||||||
|
const [firstWorkflow, secondWorkflow] = await Promise.all([
|
||||||
|
createWorkflow({}, firstProject),
|
||||||
|
createWorkflow({}, secondProject),
|
||||||
|
]);
|
||||||
|
const [firstExecution, secondExecution, _] = await Promise.all([
|
||||||
|
createExecution({}, firstWorkflow),
|
||||||
|
createExecution({}, firstWorkflow),
|
||||||
|
createExecution({}, secondWorkflow),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await authOwnerAgent.get('/executions').query({
|
||||||
|
projectId: firstProject.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body.data.length).toBe(2);
|
||||||
|
expect(response.body.nextCursor).toBeNull();
|
||||||
|
expect(response.body.data.map((execution: ExecutionEntity) => execution.id)).toEqual(
|
||||||
|
expect.arrayContaining([firstExecution.id, secondExecution.id]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('owner should retrieve all executions regardless of ownership', async () => {
|
test('owner should retrieve all executions regardless of ownership', async () => {
|
||||||
const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1);
|
const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1);
|
||||||
await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution);
|
await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution);
|
||||||
|
|
|
@ -7,8 +7,10 @@ import { mockInstance } from '../../shared/mocking';
|
||||||
import { randomApiKey } from '../shared/random';
|
import { randomApiKey } from '../shared/random';
|
||||||
import * as utils from '../shared/utils/';
|
import * as utils from '../shared/utils/';
|
||||||
import * as testDb from '../shared/testDb';
|
import * as testDb from '../shared/testDb';
|
||||||
import { createUser, createUserShell } from '../shared/db/users';
|
import { createOwner, createUser, createUserShell } from '../shared/db/users';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
|
import { createTeamProject, linkUserToProject } from '@test-integration/db/projects';
|
||||||
|
import type { User } from '@/databases/entities/User';
|
||||||
|
|
||||||
mockInstance(License, {
|
mockInstance(License, {
|
||||||
getUsersLimit: jest.fn().mockReturnValue(-1),
|
getUsersLimit: jest.fn().mockReturnValue(-1),
|
||||||
|
@ -84,6 +86,46 @@ describe('With license unlimited quota:users', () => {
|
||||||
expect(updatedAt).toBeDefined();
|
expect(updatedAt).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return users filtered by project ID', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
const [owner, firstMember, secondMember, thirdMember] = await Promise.all([
|
||||||
|
createOwner({ withApiKey: true }),
|
||||||
|
createUser({ role: 'global:member' }),
|
||||||
|
createUser({ role: 'global:member' }),
|
||||||
|
createUser({ role: 'global:member' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [firstProject, secondProject] = await Promise.all([
|
||||||
|
createTeamProject(),
|
||||||
|
createTeamProject(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
linkUserToProject(firstMember, firstProject, 'project:admin'),
|
||||||
|
linkUserToProject(secondMember, firstProject, 'project:viewer'),
|
||||||
|
linkUserToProject(thirdMember, secondProject, 'project:admin'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer.publicApiAgentFor(owner).get('/users').query({
|
||||||
|
projectId: firstProject.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.data.length).toBe(2);
|
||||||
|
expect(response.body.nextCursor).toBeNull();
|
||||||
|
expect(response.body.data.map((user: User) => user.id)).toEqual(
|
||||||
|
expect.arrayContaining([firstMember.id, secondMember.id]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /users/:id', () => {
|
describe('GET /users/:id', () => {
|
||||||
|
|
Loading…
Reference in a new issue