refactor(core): Move workflow sharing service to backend services (no-changelog) (#40157)

This commit is contained in:
Charlie Kolb 2026-10-02 11:24:08 +00:00 committed by GitHub
parent aae8016c84
commit 69595f5821
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 261 additions and 82 deletions

View File

@ -14,6 +14,10 @@ export {
type RoleDeletionChecker,
} from './services/role-deletion-check-proxy.service';
export { RoleService } from './services/role.service';
export {
WorkflowSharingService,
type ShareWorkflowOptions,
} from './services/workflow-sharing.service';
export { FolderFinderService } from './services/folder-finder.service';
export { UrlService } from './services/url.service';
export {

View File

@ -1,9 +1,13 @@
import type { Logger, LicenseState } from '@n8n/backend-common';
import type { ProjectRelationRepository, SharedWorkflowRepository, UserRepository } from '@n8n/db';
import type {
ProjectRelationRepository,
SharedWorkflowRepository,
User,
UserRepository,
} from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { RoleService } from '@n8n/backend-services';
import type { RoleService } from '../role.service';
import { WorkflowSharingService } from '../workflow-sharing.service';
describe('WorkflowSharingService', () => {
@ -22,10 +26,73 @@ describe('WorkflowSharingService', () => {
userRepository,
logger,
);
const globalUser: User = mock<User>({
id: 'global-user',
role: { scopes: [{ slug: 'workflow:read' }] },
});
const member: User = mock<User>({ id: 'member', role: { scopes: [] } });
beforeEach(() => {
vi.clearAllMocks();
});
describe('getSharedWorkflowIds', () => {
it('returns project workflows directly for users with global access', async () => {
sharedWorkflowRepository.findWorkflowIdsForGlobalAccess.mockResolvedValue(['workflow-1']);
const result = await service.getSharedWorkflowIds(globalUser, {
scopes: ['workflow:read'],
projectId: 'project-1',
});
expect(sharedWorkflowRepository.findWorkflowIdsForGlobalAccess).toHaveBeenCalledWith(
'project-1',
);
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(result).toEqual(['workflow-1']);
});
it('resolves roles before querying workflows for other users', async () => {
roleService.rolesWithScope.mockImplementation(async (namespace) =>
namespace === 'project' ? ['project:viewer'] : ['workflow:owner'],
);
sharedWorkflowRepository.findWorkflowIdsAccessibleToUser.mockResolvedValue(['workflow-1']);
const result = await service.getSharedWorkflowIds(member, {
scopes: ['workflow:read'],
});
expect(sharedWorkflowRepository.findWorkflowIdsAccessibleToUser).toHaveBeenCalledWith(
member.id,
['workflow:owner'],
['project:viewer'],
);
expect(result).toEqual(['workflow-1']);
});
});
it('gets workflow IDs shared with the user from the repository', async () => {
sharedWorkflowRepository.findWorkflowIdsSharedWithUser.mockResolvedValue(['workflow-1']);
await expect(service.getSharedWithMeIds(member)).resolves.toEqual(['workflow-1']);
expect(sharedWorkflowRepository.findWorkflowIdsSharedWithUser).toHaveBeenCalledWith(member.id);
});
it('gets workflow IDs owned in the personal project from the repository', async () => {
sharedWorkflowRepository.findOwnedWorkflowIdsInPersonalProject.mockResolvedValue([
'workflow-1',
]);
await expect(service.getOwnedWorkflowsInPersonalProject(member.id)).resolves.toEqual([
'workflow-1',
]);
expect(sharedWorkflowRepository.findOwnedWorkflowIdsInPersonalProject).toHaveBeenCalledWith(
member.id,
);
});
describe('getUserIdsWithAccessToWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
roleService.rolesWithScope.mockImplementation(async (namespace) => {
if (namespace === 'global') return ['global:owner', 'global:admin'];
if (namespace === 'project') return ['project:editor'];

View File

@ -9,9 +9,8 @@ import {
type Scope,
PROJECT_OWNER_ROLE_SLUG,
} from '@n8n/permissions';
import { In } from '@n8n/typeorm';
import { RoleService } from '@n8n/backend-services';
import { RoleService } from './role.service';
export type ShareWorkflowOptions =
| { scopes: Scope[]; projectId?: string }
@ -40,11 +39,7 @@ export class WorkflowSharingService {
const { projectId } = options;
if (hasGlobalScope(user, 'workflow:read')) {
const sharedWorkflows = await this.sharedWorkflowRepository.find({
select: ['workflowId'],
...(projectId && { where: { projectId } }),
});
return sharedWorkflows.map(({ workflowId }) => workflowId);
return await this.sharedWorkflowRepository.findWorkflowIdsForGlobalAccess(projectId);
}
const projectRoles =
@ -56,20 +51,11 @@ export class WorkflowSharingService {
? await this.roleService.rolesWithScope('workflow', options.scopes)
: options.workflowRoles;
const sharedWorkflows = await this.sharedWorkflowRepository.find({
where: {
role: In(workflowRoles),
project: {
projectRelations: {
userId: user.id,
role: In(projectRoles),
},
},
},
select: ['workflowId'],
});
return sharedWorkflows.map(({ workflowId }) => workflowId);
return await this.sharedWorkflowRepository.findWorkflowIdsAccessibleToUser(
user.id,
workflowRoles,
projectRoles,
);
}
/**
@ -134,20 +120,7 @@ export class WorkflowSharingService {
}
async getSharedWithMeIds(user: User) {
const sharedWithMeWorkflows = await this.sharedWorkflowRepository.find({
select: ['workflowId'],
where: {
role: 'workflow:editor',
project: {
projectRelations: {
userId: user.id,
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
},
},
});
return sharedWithMeWorkflows.map(({ workflowId }) => workflowId);
return await this.sharedWorkflowRepository.findWorkflowIdsSharedWithUser(user.id);
}
async getSharedWorkflowScopes(
@ -175,19 +148,7 @@ export class WorkflowSharingService {
}
async getOwnedWorkflowsInPersonalProject(userId: string): Promise<string[]> {
const sharedWorkflows = await this.sharedWorkflowRepository.find({
select: ['workflowId'],
where: {
role: 'workflow:owner',
project: {
projectRelations: {
userId,
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
},
},
});
return sharedWorkflows.map(({ workflowId }) => workflowId);
return await this.sharedWorkflowRepository.findOwnedWorkflowIdsInPersonalProject(userId);
}
/**

View File

@ -26,6 +26,99 @@ describe('SharedWorkflowRepository', () => {
vi.spyOn(sharedWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
});
describe('workflow access IDs', () => {
const rows = [
mock<SharedWorkflow>({ workflowId: 'workflow-1' }),
mock<SharedWorkflow>({ workflowId: 'workflow-2' }),
];
it('finds all workflow IDs for global access', async () => {
entityManager.find.mockResolvedValue(rows);
const result = await sharedWorkflowRepository.findWorkflowIdsForGlobalAccess();
expect(entityManager.find).toHaveBeenCalledWith(SharedWorkflow, {
select: ['workflowId'],
});
expect(result).toEqual(['workflow-1', 'workflow-2']);
});
it('limits global access workflow IDs to a project', async () => {
entityManager.find.mockResolvedValue(rows);
await sharedWorkflowRepository.findWorkflowIdsForGlobalAccess('project-1');
expect(entityManager.find).toHaveBeenCalledWith(SharedWorkflow, {
select: ['workflowId'],
where: { projectId: 'project-1' },
});
});
it('finds workflow IDs accessible through project and workflow roles', async () => {
entityManager.find.mockResolvedValue(rows);
const result = await sharedWorkflowRepository.findWorkflowIdsAccessibleToUser(
'user-1',
['workflow:owner'],
['project:viewer'],
);
expect(entityManager.find).toHaveBeenCalledWith(SharedWorkflow, {
select: ['workflowId'],
where: {
role: In(['workflow:owner']),
project: {
projectRelations: {
userId: 'user-1',
role: { slug: In(['project:viewer']) },
},
},
},
});
expect(result).toEqual(['workflow-1', 'workflow-2']);
});
it('finds workflow IDs shared with a user', async () => {
entityManager.find.mockResolvedValue(rows);
const result = await sharedWorkflowRepository.findWorkflowIdsSharedWithUser('user-1');
expect(entityManager.find).toHaveBeenCalledWith(SharedWorkflow, {
select: ['workflowId'],
where: {
role: 'workflow:editor',
project: {
projectRelations: {
userId: 'user-1',
role: { slug: 'project:personalOwner' },
},
},
},
});
expect(result).toEqual(['workflow-1', 'workflow-2']);
});
it('finds workflow IDs owned in a personal project', async () => {
entityManager.find.mockResolvedValue(rows);
const result = await sharedWorkflowRepository.findOwnedWorkflowIdsInPersonalProject('user-1');
expect(entityManager.find).toHaveBeenCalledWith(SharedWorkflow, {
select: ['workflowId'],
where: {
role: 'workflow:owner',
project: {
projectRelations: {
userId: 'user-1',
role: { slug: 'project:personalOwner' },
},
},
},
});
expect(result).toEqual(['workflow-1', 'workflow-2']);
});
});
describe('findOwnedWorkflowRemovalCandidates', () => {
const rootWorkflow = mock<WorkflowEntity>({
id: 'root',

View File

@ -25,6 +25,66 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
super(SharedWorkflow, dataSource.manager, transactionRunner);
}
async findWorkflowIdsForGlobalAccess(projectId?: string): Promise<string[]> {
const rows = await this.find({
select: ['workflowId'],
...(projectId ? { where: { projectId } } : {}),
});
return rows.map(({ workflowId }) => workflowId);
}
async findWorkflowIdsAccessibleToUser(
userId: string,
workflowRoleSlugs: string[],
projectRoleSlugs: string[],
): Promise<string[]> {
const rows = await this.find({
where: {
role: In(workflowRoleSlugs),
project: {
projectRelations: {
userId,
role: { slug: In(projectRoleSlugs) },
},
},
},
select: ['workflowId'],
});
return rows.map(({ workflowId }) => workflowId);
}
async findWorkflowIdsSharedWithUser(userId: string): Promise<string[]> {
const rows = await this.find({
select: ['workflowId'],
where: {
role: 'workflow:editor',
project: {
projectRelations: {
userId,
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
},
},
});
return rows.map(({ workflowId }) => workflowId);
}
async findOwnedWorkflowIdsInPersonalProject(userId: string): Promise<string[]> {
const rows = await this.find({
select: ['workflowId'],
where: {
role: 'workflow:owner',
project: {
projectRelations: {
userId,
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
},
},
});
return rows.map(({ workflowId }) => workflowId);
}
async findOwnedWorkflowRemovalCandidates(
projectId: string,
workflowIds: string[],

View File

@ -311,7 +311,6 @@ export default defineConfig(
// workflows/
'./src/workflows/workflow-finder.service.ts',
'./src/workflows/workflow-history/workflow-history.service.ts',
'./src/workflows/workflow-sharing.service.ts',
'./src/workflows/workflow-validation.service.ts',
'./src/workflows/workflow.service.ee.ts',
'./src/workflows/workflow.service.ts',

View File

@ -312,7 +312,6 @@ export default defineConfig({
// workflows/
'./src/workflows/workflow-finder.service.ts',
'./src/workflows/workflow-history/workflow-history.service.ts',
'./src/workflows/workflow-sharing.service.ts',
'./src/workflows/workflow-validation.service.ts',
'./src/workflows/workflow.service.ee.ts',
'./src/workflows/workflow.service.ts',

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { Logger } from '@n8n/backend-common';
import type { EventService } from '@n8n/backend-services';
import type { EventService, WorkflowSharingService } from '@n8n/backend-services';
import { mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig, WorkflowsConfig } from '@n8n/config';
import type { Project, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
@ -50,7 +50,6 @@ import type { PollCursorService } from '@/workflows/triggers/poll-cursor.service
import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory';
import type { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
describe('ActiveWorkflowManager', () => {

View File

@ -1,7 +1,7 @@
import type { BinaryDataRepository, ExecutionRepository, User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
import { BinaryDataAccessService } from '../binary-data-access.service';

View File

@ -3,7 +3,7 @@ import { BinaryDataRepository, ExecutionRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { parseExecutionFileId, TEMP_EXECUTION_ID } from 'n8n-core';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { WorkflowSharingService } from '@n8n/backend-services';
/**
* Workflow that a binary derives its access from. Normally the workflow of the

View File

@ -3,8 +3,7 @@ import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
import type { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
import type { RoleService } from '@n8n/backend-services';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { RoleService, WorkflowSharingService } from '@n8n/backend-services';
import type { ExecutionCursor } from '../execution-cursor';
import { COMPLETED_STATUSES, type ExecutionListV1Service } from '../execution-list-v1.service';

View File

@ -36,7 +36,7 @@ import { Push } from '@/push';
import { OwnershipService } from '@/services/ownership.service';
import { WorkflowPublicationNotifier } from '@/workflows/publication/workflow-publication-notifier';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { WorkflowSharingService } from '@n8n/backend-services';
import { createExecution } from '@test-integration/db/executions';
import { IN_PROGRESS_EXECUTION_DATA, OOM_WORKFLOW } from './constants';

View File

@ -8,7 +8,7 @@ import type { ExecutionListService } from '@/executions/execution-list.service';
import type { ExecutionService } from '@/executions/execution.service';
import type { ExecutionRequest } from '@/executions/execution.types';
import { ExecutionsController } from '@/executions/executions.controller';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
const V2_EXECUTION_ID = '01a038ae-c4a8-7799-8a3e-e3c2ca055cfa';

View File

@ -3,8 +3,7 @@ import { Container, Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service';
import { RoleService } from '@n8n/backend-services';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { RoleService, WorkflowSharingService } from '@n8n/backend-services';
import { hasPosition, type ExecutionCursor } from './execution-cursor';
import {

View File

@ -7,7 +7,7 @@ import type { Response } from 'express';
import { BadRequestError, NotFoundError, NotImplementedError } from '@n8n/errors';
import { License } from '@/license';
import { isPositiveInteger } from '@/utils';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { WorkflowSharingService } from '@n8n/backend-services';
import { isExecutionIdV2 } from './execution-id';
import { ExecutionListService } from './execution-list.service';

View File

@ -6,7 +6,7 @@ import { DateTime } from 'luxon';
import { mock } from 'vitest-mock-extended';
import { BadRequestError, ForbiddenError } from '@n8n/errors';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { WorkflowSharingService } from '@n8n/backend-services';
import { TypeToNumber, type TypeUnitNumber } from '../database/entities/insights-shared';
import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';

View File

@ -19,7 +19,7 @@ import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { ForbiddenError } from '@n8n/errors';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
import { createMember } from '@test-integration/db/users';
import { createCompactedInsightsEvent } from '../database/entities/__tests__/db-utils';

View File

@ -6,7 +6,7 @@ import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
import { TypeToNumber, type TypeUnitNumber } from '../database/entities/insights-shared';
import type { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';

View File

@ -4,6 +4,7 @@ import {
type RestrictedInsightsByTime,
} from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common';
import { WorkflowSharingService } from '@n8n/backend-services';
import type { User } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { DateTime } from 'luxon';
@ -12,7 +13,6 @@ import { UserError } from 'n8n-workflow';
import { ForbiddenError } from '@n8n/errors';
import { userHasScopes } from '@/permissions.ee/check-access';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { PeriodUnit, TypeUnit, ByTimeInsightType } from './database/entities/insights-shared';
import { NumberToType } from './database/entities/insights-shared';

View File

@ -15,7 +15,7 @@ import {
StoppedExecutionsPublicDto,
TagIdsPublicDto,
} from '@n8n/api-types';
import { EventService } from '@n8n/backend-services';
import { EventService, WorkflowSharingService } from '@n8n/backend-services';
import { ExecutionsConfig } from '@n8n/config';
import type { AuthenticatedRequest, IExecutionBase, IExecutionResponse } from '@n8n/db';
import {
@ -48,7 +48,6 @@ import { ExecutionService } from '@/executions/execution.service';
import type { StopResult } from '@/executions/execution.types';
import type { TracingContext } from '@/modules/otel/tracing-context';
import { decodeCursor, encodeNextCursor } from '@/public-api/v1/shared/services/pagination.service';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
type PublicExecution = IExecutionBase & Partial<IExecutionResponse>;

View File

@ -7,7 +7,7 @@ import { BadRequestError } from '@n8n/errors';
import type { ProjectScopeService } from '@/permissions.ee/project-scope.service';
import { ActiveWorkflowsService } from '@/services/active-workflows.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
describe('ActiveWorkflowsService', () => {
const user = mock<User>();

View File

@ -7,7 +7,7 @@ import { hasGlobalScope } from '@n8n/permissions';
import { ActivationErrorsService } from '@/activation-errors.service';
import { BadRequestError } from '@n8n/errors';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { WorkflowSharingService } from '@n8n/backend-services';
@Service()
export class ActiveWorkflowsService {

View File

@ -2,7 +2,7 @@ import { mock } from 'vitest-mock-extended';
import type { Push } from '@/push';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
describe('WorkflowPushNotifier', () => {
const push = mock<Push>();

View File

@ -16,7 +16,7 @@ import type { Push } from '@/push';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { PublicationStatusReporter } from '@/workflows/publication/publication-status-reporter';
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { WorkflowSharingService } from '@n8n/backend-services';
describe('PublicationStatusReporter', () => {
const logger = mock<Logger>();

View File

@ -2,7 +2,7 @@ import type { PushMessage } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { Push } from '@/push';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { WorkflowSharingService } from '@n8n/backend-services';
/** Sends a push message only to users who can currently read the workflow it concerns. */
@Service()

View File

@ -1131,7 +1131,7 @@ describe('WorkflowRepository', () => {
// ARRANGE
const { createMember } = await import('../../shared/db/users.js');
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
const { WorkflowSharingService } = await import('@/workflows/workflow-sharing.service.js');
const { WorkflowSharingService } = await import('@n8n/backend-services');
const { RoleService } = await import('@n8n/backend-services');
const member = await createMember();
@ -1176,7 +1176,7 @@ describe('WorkflowRepository', () => {
// ARRANGE
const { createOwner } = await import('../../shared/db/users.js');
const { getPersonalProject } = await import('@n8n/backend-test-utils');
const { WorkflowSharingService } = await import('@/workflows/workflow-sharing.service.js');
const { WorkflowSharingService } = await import('@n8n/backend-services');
const owner = await createOwner();
const personalProject = await getPersonalProject(owner);
@ -1217,7 +1217,7 @@ describe('WorkflowRepository', () => {
// ARRANGE
const { createOwner } = await import('../../shared/db/users.js');
const { getPersonalProject } = await import('@n8n/backend-test-utils');
const { WorkflowSharingService } = await import('@/workflows/workflow-sharing.service.js');
const { WorkflowSharingService } = await import('@n8n/backend-services');
const owner = await createOwner();
const personalProject = await getPersonalProject(owner);
@ -1269,7 +1269,7 @@ describe('WorkflowRepository', () => {
// ARRANGE
const { createMember } = await import('../../shared/db/users.js');
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
const { WorkflowSharingService } = await import('@/workflows/workflow-sharing.service.js');
const { WorkflowSharingService } = await import('@n8n/backend-services');
const { RoleService } = await import('@n8n/backend-services');
const member = await createMember();
@ -1351,7 +1351,7 @@ describe('WorkflowRepository', () => {
// ARRANGE
const { createMember } = await import('../../shared/db/users.js');
const { createTeamProject, linkUserToProject } = await import('@n8n/backend-test-utils');
const { WorkflowSharingService } = await import('@/workflows/workflow-sharing.service.js');
const { WorkflowSharingService } = await import('@n8n/backend-services');
const { RoleService } = await import('@n8n/backend-services');
// Create two separate users

View File

@ -5,7 +5,7 @@ import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
import { ProjectService } from '@/services/project.service.ee';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { WorkflowSharingService } from '@n8n/backend-services';
import { createUser } from '../shared/db/users';