refactor: Workflow sharing bug bash fixes (#4888)

* fix: Prevent workflows with only manual trigger from being activated

* fix: Fix workflow id when sharing from workflows list

* fix: Update sharing modal translations

* fix: Allow sharees to disable workflows and fix issue with unique key when removing a user

* refactor: Improve error messages and change logging level to be less verbose

* fix: Broken user removal transfer issue

* feat: Implement workflow sharing BE telemetry

* chore: temporarily add sharing env vars

* feat: Implement BE telemetry for workflow sharing

* fix: Prevent issues with possibly missing workflow id

* feat: Replace WorkflowSharing flag references (no-changelog) (#4918)

* ci: Block all external network calls in tests (no-changelog) (#4930)

* setup nock to prevent tests from making any external requests

* mock all calls to posthog sdk

* feat: Replace WorkflowSharing flag references (no-changelog)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com>

* refactor: Remove temporary feature flag for workflow sharing

* refactor: add sharing_role to both manual and node executions

* refactor: Allow changing name, position and disabled of read only nodes

* feat: Overhaul dynamic translations for local and cloud (#4943)

* feat: Overhaul dynamic translations for local and cloud

* fix: remove type casting

* chore: remove unused translations

* fix: fix workflow sharing translation

* test: Fix broken test

* refactor: remove unnecessary import

* refactor: Minor code improvements

* refactor: rename dynamicTranslations to contextBasedTranslationKeys

* fix: fix type imports

* refactor: Consolidate sharing feature check

* feat: update cred sharing unavailable translations

* feat: update upgrade message when user management not available

* fix: rename plan names to Pro and Power

* feat: update translations to no longer contain plan names

* wip: subworkflow permissions

* feat: add workflowsFromSameOwner caller policy

* feat: Fix subworkflow permissions

* shared entites should check for role when deleting users

* refactor: remove circular dependency

* role filter shouldn't be an array

* fixed role issue

* fix: Corrected behavior when removing users

* feat: show instance owner credential sharing message only if isnt sharee

* feat: update workflow caller policy caller ids labels

* feat: update upgrade plan links to contain instance ids

* fix: show check errors below creds message only to owner

* fix(editor): Hide usage page on cloud

* fix: update credential validation error message for sharee

* fix(core): Remove duplicate import

* fix(editor): Extending deployment types

* feat: Overhaul contextual translations (#4992)

feat: update how contextual translations work

* refactor: improve messageing for subworkflow permissions

* test: Fix issue with user deletion and transfer

* fix: Explicitly throw error message so it can be displayed in UI

Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com>
Co-authored-by: freyamade <freya@n8n.io>
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
This commit is contained in:
Omar Ajoue
2022-12-21 16:42:07 +01:00
committed by GitHub
parent e225c3190e
commit 25e9f0817a
43 changed files with 597 additions and 344 deletions

View File

@@ -458,7 +458,11 @@ export interface IN8nUISettings {
saveManualExecutions: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
workflowCallerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList';
workflowCallerPolicyDefaultOption:
| 'any'
| 'none'
| 'workflowsFromAList'
| 'workflowsFromSameOwner';
oauthCallbackUrls: {
oauth1: string;
oauth2: string;
@@ -498,7 +502,6 @@ export interface IN8nUISettings {
};
enterprise: {
sharing: boolean;
workflowSharing: boolean;
};
hideUsagePage: boolean;
license: {

View File

@@ -17,6 +17,7 @@ import {
IExecutionTrackProperties,
} from '@/Interfaces';
import { Telemetry } from '@/telemetry';
import { RoleService } from './role/role.service';
export class InternalHooksClass implements IInternalHooksClass {
private versionCli: string;
@@ -111,6 +112,14 @@ export class InternalHooksClass implements IInternalHooksClass {
(note) => note.overlapping,
).length;
let userRole: 'owner' | 'sharee' | undefined = undefined;
if (userId && workflow.id) {
const role = await RoleService.getUserRoleForWorkflow(userId, workflow.id.toString());
if (role) {
userRole = role.name === 'owner' ? 'owner' : 'sharee';
}
}
return this.telemetry.track(
'User saved workflow',
{
@@ -122,6 +131,7 @@ export class InternalHooksClass implements IInternalHooksClass {
version_cli: this.versionCli,
num_tags: workflow.tags?.length ?? 0,
public_api: publicApi,
sharing_role: userRole,
},
{ withPostHog: true },
);
@@ -196,6 +206,14 @@ export class InternalHooksClass implements IInternalHooksClass {
nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
}
let userRole: 'owner' | 'sharee' | undefined = undefined;
if (userId) {
const role = await RoleService.getUserRoleForWorkflow(userId, workflow.id.toString());
if (role) {
userRole = role.name === 'owner' ? 'owner' : 'sharee';
}
}
const manualExecEventProperties: ITelemetryTrackProperties = {
user_id: userId,
workflow_id: workflow.id.toString(),
@@ -205,6 +223,7 @@ export class InternalHooksClass implements IInternalHooksClass {
node_graph_string: properties.node_graph_string as string,
error_node_id: properties.error_node_id as string,
webhook_domain: null,
sharing_role: userRole,
};
if (!manualExecEventProperties.node_graph_string) {
@@ -254,6 +273,16 @@ export class InternalHooksClass implements IInternalHooksClass {
]).then(() => {});
}
async onWorkflowSharingUpdate(workflowId: string, userId: string, userList: string[]) {
const properties: ITelemetryTrackProperties = {
workflow_id: workflowId,
user_id_sharer: userId,
user_id_list: userList,
};
return this.telemetry.track('User updated workflow sharing', properties, { withPostHog: true });
}
async onN8nStop(): Promise<void> {
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {

View File

@@ -356,7 +356,6 @@ class App {
},
enterprise: {
sharing: false,
workflowSharing: false,
},
hideUsagePage: config.getEnv('hideUsagePage'),
license: {
@@ -389,7 +388,6 @@ class App {
// refresh enterprise status
Object.assign(this.frontendSettings.enterprise, {
sharing: isSharingEnabled(),
workflowSharing: config.getEnv('enterprise.workflowSharingEnabled'),
});
if (config.get('nodes.packagesMissing').length > 0) {
@@ -1003,7 +1001,7 @@ class App {
});
if (!shared) {
LoggerProxy.info('User attempted to access workflow errors without permissions', {
LoggerProxy.verbose('User attempted to access workflow errors without permissions', {
workflowId,
userId: req.user.id,
});

View File

@@ -1,9 +1,17 @@
import { INode, NodeOperationError, Workflow } from 'n8n-workflow';
import {
INode,
NodeOperationError,
SubworkflowOperationError,
Workflow,
WorkflowOperationError,
} from 'n8n-workflow';
import { FindManyOptions, In, ObjectLiteral } from 'typeorm';
import * as Db from '@/Db';
import config from '@/config';
import type { SharedCredentials } from '@db/entities/SharedCredentials';
import { getRole } from './UserManagementHelper';
import { getRole, getWorkflowOwner, isSharingEnabled } from './UserManagementHelper';
import { WorkflowsService } from '@/workflows/workflows.services';
import { UserService } from '@/user/user.service';
export class PermissionChecker {
/**
@@ -31,7 +39,7 @@ export class PermissionChecker {
let workflowUserIds = [userId];
if (workflow.id && config.getEnv('enterprise.workflowSharingEnabled')) {
if (workflow.id && isSharingEnabled()) {
const workflowSharings = await Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: { workflow: { id: Number(workflow.id) } },
@@ -44,7 +52,7 @@ export class PermissionChecker {
where: { user: In(workflowUserIds) },
};
if (!config.getEnv('enterprise.features.sharing')) {
if (!isSharingEnabled()) {
// If credential sharing is not enabled, get only credentials owned by this user
credentialsWhereCondition.where.role = await getRole('credential', 'owner');
}
@@ -68,6 +76,72 @@ export class PermissionChecker {
});
}
static async checkSubworkflowExecutePolicy(
subworkflow: Workflow,
userId: string,
parentWorkflowId?: string,
) {
/**
* Important considerations: both the current workflow and the parent can have empty IDs.
* This happens when a user is executing an unsaved workflow manually running a workflow
* loaded from a file or code, for instance.
* This is an important topic to keep in mind for all security checks
*/
if (!subworkflow.id) {
// It's a workflow from code and not loaded from DB
// No checks are necessary since it doesn't have any sort of settings
return;
}
let policy =
subworkflow.settings?.callerPolicy ?? config.getEnv('workflows.callerPolicyDefaultOption');
if (!isSharingEnabled()) {
// Community version allows only same owner workflows
policy = 'workflowsFromSameOwner';
}
const subworkflowOwner = await getWorkflowOwner(subworkflow.id);
const errorToThrow = new SubworkflowOperationError(
`Target workflow ID ${subworkflow.id ?? ''} may not be called`,
subworkflowOwner.id === userId
? 'Change the settings of the sub-workflow so it can be called by this one.'
: `${subworkflowOwner.firstName} (${subworkflowOwner.email}) can make this change. You may need to tell them the ID of this workflow, which is ${subworkflow.id}`,
);
if (policy === 'none') {
throw errorToThrow;
}
if (policy === 'workflowsFromAList') {
if (parentWorkflowId === undefined) {
throw errorToThrow;
}
const allowedCallerIds = (subworkflow.settings.callerIds as string | undefined)
?.split(',')
.map((id) => id.trim())
.filter((id) => id !== '');
if (!allowedCallerIds?.includes(parentWorkflowId)) {
throw errorToThrow;
}
}
if (policy === 'workflowsFromSameOwner') {
const user = await UserService.get({ id: userId });
if (!user) {
throw new WorkflowOperationError(
'Fatal error: user not found. Please contact the system administrator.',
);
}
const sharing = await WorkflowsService.getSharing(user, subworkflow.id, ['role', 'user']);
if (!sharing || sharing.role.name !== 'owner') {
throw errorToThrow;
}
}
}
private static mapCredIdsToNodes(workflow: Workflow) {
return Object.values(workflow.nodes).reduce<{ [credentialId: string]: INode[] }>(
(map, node) => {

View File

@@ -15,10 +15,13 @@ import config from '@/config';
import { getWebhookBaseUrl } from '../WebhookHelpers';
import { getLicense } from '@/License';
import { WhereClause } from '@/Interfaces';
import { RoleService } from '@/role/role.service';
export async function getWorkflowOwner(workflowId: string | number): Promise<User> {
const workflowOwnerRole = await RoleService.get({ name: 'owner', scope: 'workflow' });
const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({
where: { workflow: { id: workflowId } },
where: { workflow: { id: workflowId }, role: workflowOwnerRole },
relations: ['user', 'user.globalRole'],
});

View File

@@ -25,6 +25,7 @@ import {
import config from '@/config';
import { issueCookie } from '../auth/jwt';
import { InternalHooksManager } from '@/InternalHooksManager';
import { RoleService } from '@/role/role.service';
export function usersNamespace(this: N8nApp): void {
/**
@@ -403,33 +404,94 @@ export function usersNamespace(this: N8nApp): void {
const userToDelete = users.find((user) => user.id === req.params.id) as User;
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
const [workflowOwnerRole, credentialOwnerRole] = await Promise.all([
RoleService.get({ name: 'owner', scope: 'workflow' }),
RoleService.get({ name: 'owner', scope: 'credential' }),
]);
if (transferId) {
const transferee = users.find((user) => user.id === transferId);
await Db.transaction(async (transactionManager) => {
// Get all workflow ids belonging to user to delete
const sharedWorkflows = await transactionManager.getRepository(SharedWorkflow).find({
where: { user: userToDelete, role: workflowOwnerRole },
});
const sharedWorkflowIds = sharedWorkflows.map((sharedWorkflow) =>
sharedWorkflow.workflowId.toString(),
);
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedWorkflow, {
user: transferee,
workflow: In(sharedWorkflowIds.map((sharedWorkflowId) => ({ id: sharedWorkflowId }))),
});
// Transfer ownership of owned workflows
await transactionManager.update(
SharedWorkflow,
{ user: userToDelete },
{ user: userToDelete, role: workflowOwnerRole },
{ user: transferee },
);
// Now do the same for creds
// Get all workflow ids belonging to user to delete
const sharedCredentials = await transactionManager.getRepository(SharedCredentials).find({
where: { user: userToDelete, role: credentialOwnerRole },
});
const sharedCredentialIds = sharedCredentials.map((sharedCredential) =>
sharedCredential.credentialId.toString(),
);
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedCredentials, {
user: transferee,
credentials: In(
sharedCredentialIds.map((sharedCredentialId) => ({ id: sharedCredentialId })),
),
});
// Transfer ownership of owned credentials
await transactionManager.update(
SharedCredentials,
{ user: userToDelete },
{ user: userToDelete, role: credentialOwnerRole },
{ user: transferee },
);
// This will remove all shared workflows and credentials not owned
await transactionManager.delete(User, { id: userToDelete.id });
});
void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false);
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: { user: userToDelete },
where: { user: userToDelete, role: workflowOwnerRole },
}),
Db.collections.SharedCredentials.find({
relations: ['credentials'],
where: { user: userToDelete },
where: { user: userToDelete, role: credentialOwnerRole },
}),
]);
@@ -450,22 +512,8 @@ export function usersNamespace(this: N8nApp): void {
await transactionManager.delete(User, { id: userToDelete.id });
});
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false);
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}),
);

View File

@@ -65,6 +65,7 @@ import * as WorkflowHelpers from '@/WorkflowHelpers';
import { getUserById, getWorkflowOwner, whereClause } from '@/UserManagement/UserManagementHelper';
import { findSubworkflowStart } from '@/utils';
import { PermissionChecker } from './UserManagement/PermissionChecker';
import { WorkflowsService } from './workflows/workflows.services';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@@ -779,34 +780,6 @@ export async function getRunData(
): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated';
const policy =
workflowData.settings?.callerPolicy ?? config.getEnv('workflows.callerPolicyDefaultOption');
if (policy === 'none') {
throw new SubworkflowOperationError(
`Target workflow ID ${workflowData.id} may not be called by other workflows.`,
'Please update the settings of the target workflow or ask its owner to do so.',
);
}
if (
policy === 'workflowsFromAList' &&
typeof workflowData.settings?.callerIds === 'string' &&
parentWorkflowId !== undefined
) {
const allowedCallerIds = workflowData.settings.callerIds
.split(',')
.map((id) => id.trim())
.filter((id) => id !== '');
if (!allowedCallerIds.includes(parentWorkflowId)) {
throw new SubworkflowOperationError(
`Target workflow ID ${workflowData.id} may only be called by a list of workflows, which does not include current workflow ID ${parentWorkflowId}.`,
'Please update the settings of the target workflow or ask its owner to do so.',
);
}
}
const startingNode = findSubworkflowStart(workflowData.nodes);
// Always start with empty data if no inputData got supplied
@@ -852,7 +825,6 @@ export async function getRunData(
export async function getWorkflowData(
workflowInfo: IExecuteWorkflowInfo,
userId: string,
parentWorkflowId?: string,
parentWorkflowSettings?: IWorkflowSettings,
): Promise<IWorkflowBase> {
@@ -869,23 +841,15 @@ export async function getWorkflowData(
// to get initialized first
await Db.init();
}
const user = await getUserById(userId);
let relations = ['workflow', 'workflow.tags'];
if (config.getEnv('workflowTagsDisabled')) {
relations = relations.filter((relation) => relation !== 'workflow.tags');
}
const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags'];
const shared = await Db.collections.SharedWorkflow.findOne({
relations,
where: whereClause({
user,
entityType: 'workflow',
entityId: workflowInfo.id,
}),
});
workflowData = shared?.workflow;
workflowData = await WorkflowsService.get(
{ id: parseInt(workflowInfo.id, 10) },
{
relations,
},
);
if (workflowData === undefined) {
throw new Error(`The workflow with the id "${workflowInfo.id}" does not exist.`);
@@ -911,7 +875,7 @@ export async function getWorkflowData(
async function executeWorkflow(
workflowInfo: IExecuteWorkflowInfo,
additionalData: IWorkflowExecuteAdditionalData,
options?: {
options: {
parentWorkflowId?: string;
inputData?: INodeExecutionData[];
parentExecutionId?: string;
@@ -926,13 +890,8 @@ async function executeWorkflow(
const nodeTypes = NodeTypes();
const workflowData =
options?.loadedWorkflowData ??
(await getWorkflowData(
workflowInfo,
additionalData.userId,
options?.parentWorkflowId,
options?.parentWorkflowSettings,
));
options.loadedWorkflowData ??
(await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings));
const workflowName = workflowData ? workflowData.name : undefined;
const workflow = new Workflow({
@@ -947,23 +906,28 @@ async function executeWorkflow(
});
const runData =
options?.loadedRunData ??
(await getRunData(workflowData, additionalData.userId, options?.inputData));
options.loadedRunData ??
(await getRunData(workflowData, additionalData.userId, options.inputData));
let executionId;
if (options?.parentExecutionId !== undefined) {
executionId = options?.parentExecutionId;
if (options.parentExecutionId !== undefined) {
executionId = options.parentExecutionId;
} else {
executionId =
options?.parentExecutionId !== undefined
? options?.parentExecutionId
options.parentExecutionId !== undefined
? options.parentExecutionId
: await ActiveExecutions.getInstance().add(runData);
}
let data;
try {
await PermissionChecker.check(workflow, additionalData.userId);
await PermissionChecker.checkSubworkflowExecutePolicy(
workflow,
additionalData.userId,
options.parentWorkflowId,
);
// Create new additionalData to have different workflow loaded and to call
// different webhooks
@@ -1005,7 +969,7 @@ async function executeWorkflow(
runData.executionMode,
runExecutionData,
);
if (options?.parentExecutionId !== undefined) {
if (options.parentExecutionId !== undefined) {
// Must be changed to become typed
return {
startedAt: new Date(),
@@ -1049,6 +1013,7 @@ async function executeWorkflow(
throw {
...error,
stack: error.stack,
message: error.message,
};
}

View File

@@ -24,6 +24,7 @@ import config from '@/config';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { User } from '@db/entities/User';
import { getWorkflowOwner, whereClause } from '@/UserManagement/UserManagementHelper';
import omit from 'lodash.omit';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@@ -558,15 +559,16 @@ export function validateWorkflowCredentialUsage(
nodesWithCredentialsUserDoesNotHaveAccessTo.forEach((node) => {
if (isTamperingAttempt(node.id)) {
Logger.info('Blocked workflow update due to tampering attempt', {
Logger.verbose('Blocked workflow update due to tampering attempt', {
nodeType: node.type,
nodeName: node.name,
nodeId: node.id,
nodeCredentials: node.credentials,
});
// Node is new, so this is probably a tampering attempt. Throw an error
throw new Error(
'Workflow contains new nodes with credentials the user does not have access to',
throw new NodeOperationError(
node,
`You don't have access to the credentials in the '${node.name}' node. Ask the owner to share them with you.`,
);
}
// Replace the node with the previous version of the node
@@ -580,9 +582,14 @@ export function validateWorkflowCredentialUsage(
nodeName: node.name,
nodeId: node.id,
});
newWorkflowVersion.nodes[nodeIdx] = previousWorkflowVersion.nodes.find(
const previousNodeVersion = previousWorkflowVersion.nodes.find(
(previousNode) => previousNode.id === node.id,
)!;
);
// Allow changing only name, position and disabled status for read-only nodes
Object.assign(
newWorkflowVersion.nodes[nodeIdx],
omit(previousNodeVersion, ['name', 'position', 'disabled']),
);
});
return newWorkflowVersion;

View File

@@ -48,6 +48,7 @@ import { InternalHooksManager } from '@/InternalHooksManager';
import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
import { initErrorHandling } from '@/ErrorReporting';
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import { getLicense } from './License';
class WorkflowRunnerProcess {
data: IWorkflowExecutionDataProcessWithExecution | undefined;
@@ -118,48 +119,11 @@ class WorkflowRunnerProcess {
const binaryDataConfig = config.getEnv('binaryDataManager');
await BinaryDataManager.init(binaryDataConfig);
// Credentials should now be loaded from database.
// We check if any node uses credentials. If it does, then
// init database.
let shouldInitializeDb = false;
// eslint-disable-next-line array-callback-return
inputData.workflowData.nodes.map((node) => {
if (Object.keys(node.credentials === undefined ? {} : node.credentials).length > 0) {
shouldInitializeDb = true;
}
if (node.type === 'n8n-nodes-base.executeWorkflow') {
// With UM, child workflows from arbitrary JSON
// Should be persisted by the child process,
// so DB needs to be initialized
shouldInitializeDb = true;
}
});
// Init db since we need to read the license.
await Db.init();
// This code has been split into 4 ifs just to make it easier to understand
// Can be made smaller but in the end it will make it impossible to read.
if (shouldInitializeDb) {
// initialize db as we need to load credentials
await Db.init();
} else if (
inputData.workflowData.settings !== undefined &&
inputData.workflowData.settings.saveExecutionProgress === true
) {
// Workflow settings specifying it should save
await Db.init();
} else if (
inputData.workflowData.settings !== undefined &&
inputData.workflowData.settings.saveExecutionProgress !== false &&
config.getEnv('executions.saveExecutionProgress')
) {
// Workflow settings not saying anything about saving but default settings says so
await Db.init();
} else if (
inputData.workflowData.settings === undefined &&
config.getEnv('executions.saveExecutionProgress')
) {
// Workflow settings not saying anything about saving but default settings says so
await Db.init();
}
const license = getLicense();
await license.init(instanceId, cli);
// Start timeout for the execution
let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default
@@ -245,7 +209,6 @@ class WorkflowRunnerProcess {
): Promise<Array<INodeExecutionData[] | null> | IRun> => {
const workflowData = await WorkflowExecuteAdditionalData.getWorkflowData(
workflowInfo,
userId,
options?.parentWorkflowId,
options?.parentWorkflowSettings,
);

View File

@@ -28,7 +28,7 @@ async function checkWorkflowId(workflowId: string, user: User): Promise<boolean>
});
if (!shared) {
LoggerProxy.info('User attempted to read a workflow without permissions', {
LoggerProxy.verbose('User attempted to read a workflow without permissions', {
workflowId,
userId: user.id,
});

View File

@@ -27,7 +27,6 @@ const config = convict(schema);
if (inE2ETests) {
config.set('enterprise.features.sharing', true);
config.set('enterprise.workflowSharingEnabled', true);
}
config.getEnv = config.get;

View File

@@ -217,8 +217,8 @@ export const schema = {
},
callerPolicyDefaultOption: {
doc: 'Default option for which workflows may call the current workflow',
format: ['any', 'none', 'workflowsFromAList'] as const,
default: 'any',
format: ['any', 'none', 'workflowsFromAList', 'workflowsFromSameOwner'] as const,
default: 'workflowsFromSameOwner',
env: 'N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION',
},
},
@@ -885,12 +885,6 @@ export const schema = {
default: false,
},
},
// This is a temporary flag (acting as feature toggle)
// Will be removed when feature goes live
workflowSharingEnabled: {
format: Boolean,
default: false,
},
},
hiringBanner: {

View File

@@ -1,5 +1,4 @@
import express from 'express';
import config from '@/config';
import {
IExecutionFlattedResponse,
IExecutionResponse,
@@ -14,7 +13,7 @@ import { EEExecutionsService } from './executions.service.ee';
export const EEExecutionsController = express.Router();
EEExecutionsController.use((req, res, next) => {
if (!isSharingEnabled() || !config.getEnv('enterprise.workflowSharingEnabled')) {
if (!isSharingEnabled()) {
// skip ee router and use free one
next('router');
return;

View File

@@ -10,4 +10,15 @@ export class RoleService {
static async trxGet(transaction: EntityManager, role: Partial<Role>) {
return transaction.findOne(Role, role);
}
static async getUserRoleForWorkflow(userId: string, workflowId: string) {
const shared = await Db.collections.SharedWorkflow.findOne({
where: {
workflow: { id: workflowId },
user: { id: userId },
},
relations: ['role'],
});
return shared?.role;
}
}

View File

@@ -4,7 +4,9 @@ import { User } from '@db/entities/User';
export class UserService {
static async get(user: Partial<User>): Promise<User | undefined> {
return Db.collections.User.findOne(user);
return Db.collections.User.findOne(user, {
relations: ['globalRole'],
});
}
static async getByIds(transaction: EntityManager, ids: string[]) {

View File

@@ -22,7 +22,7 @@ import * as GenericHelpers from '@/GenericHelpers';
export const EEWorkflowController = express.Router();
EEWorkflowController.use((req, res, next) => {
if (!isSharingEnabled() || !config.getEnv('enterprise.workflowSharingEnabled')) {
if (!isSharingEnabled()) {
// skip ee router and use free one
next('router');
return;
@@ -73,6 +73,12 @@ EEWorkflowController.put(
await EEWorkflows.share(trx, workflow, newShareeIds);
}
});
void InternalHooksManager.getInstance().onWorkflowSharingUpdate(
workflowId,
req.user.id,
shareWithIds,
);
}),
);
@@ -94,7 +100,7 @@ EEWorkflowController.get(
if (!userSharing && req.user.globalRole.name !== 'owner') {
throw new ResponseHelper.UnauthorizedError(
'It looks like you cannot access this workflow. Ask the owner to share it with you.',
'You do not have permission to access this workflow. Ask the owner to share it with you',
);
}

View File

@@ -213,7 +213,7 @@ workflowsController.get(
});
if (!shared) {
LoggerProxy.info('User attempted to access a workflow without permissions', {
LoggerProxy.verbose('User attempted to access a workflow without permissions', {
workflowId,
userId: req.user.id,
});
@@ -286,7 +286,7 @@ workflowsController.delete(
});
if (!shared) {
LoggerProxy.info('User attempted to delete a workflow without permissions', {
LoggerProxy.verbose('User attempted to delete a workflow without permissions', {
workflowId,
userId: req.user.id,
});

View File

@@ -15,6 +15,7 @@ import type {
} from './workflows.types';
import { EECredentialsService as EECredentials } from '@/credentials/credentials.service.ee';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { NodeOperationError } from 'n8n-workflow';
export class EEWorkflowsService extends WorkflowsService {
static async getWorkflowIdsForUser(user: User) {
@@ -189,6 +190,9 @@ export class EEWorkflowsService extends WorkflowsService {
allCredentials,
);
} catch (error) {
if (error instanceof NodeOperationError) {
throw new ResponseHelper.BadRequestError(error.message);
}
throw new ResponseHelper.BadRequestError(
'Invalid workflow credentials - make sure you have access to all credentials and try again.',
);

View File

@@ -22,7 +22,7 @@ import { WorkflowRunner } from '@/WorkflowRunner';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import * as TestWebhooks from '@/TestWebhooks';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { whereClause } from '@/UserManagement/UserManagementHelper';
import { isSharingEnabled, whereClause } from '@/UserManagement/UserManagementHelper';
export interface IGetWorkflowsQueryFilter {
id?: number | string;
@@ -158,20 +158,26 @@ export class WorkflowsService {
return [];
}
const fields: Array<keyof WorkflowEntity> = ['id', 'name', 'active', 'createdAt', 'updatedAt'];
const fields: Array<keyof WorkflowEntity> = [
'id',
'name',
'active',
'createdAt',
'updatedAt',
'nodes',
];
const relations: string[] = [];
if (!config.getEnv('workflowTagsDisabled')) {
relations.push('tags');
}
const isSharingEnabled = config.getEnv('enterprise.features.sharing');
if (isSharingEnabled) {
if (isSharingEnabled()) {
relations.push('shared', 'shared.user', 'shared.role');
}
const query: FindManyOptions<WorkflowEntity> = {
select: isSharingEnabled ? [...fields, 'nodes', 'versionId'] : fields,
select: isSharingEnabled() ? [...fields, 'versionId'] : fields,
relations,
where: {
id: In(sharedWorkflowIds),
@@ -210,7 +216,7 @@ export class WorkflowsService {
});
if (!shared) {
LoggerProxy.info('User attempted to update a workflow without permissions', {
LoggerProxy.verbose('User attempted to update a workflow without permissions', {
workflowId,
userId: user.id,
});
@@ -351,7 +357,7 @@ export class WorkflowsService {
updatedWorkflow.active = false;
// Now return the original error for UI to display
throw error;
throw new ResponseHelper.BadRequestError((error as Error).message);
}
}

View File

@@ -161,13 +161,13 @@ test('DELETE /users/:id should delete the user', async () => {
const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
relations: ['user'],
where: { user: userToDelete },
where: { user: userToDelete, role: workflowOwnerRole },
});
expect(sharedWorkflow).toBeUndefined(); // deleted
const sharedCredential = await Db.collections.SharedCredentials.findOne({
relations: ['user'],
where: { user: userToDelete },
where: { user: userToDelete, role: credentialOwnerRole },
});
expect(sharedCredential).toBeUndefined(); // deleted

View File

@@ -47,8 +47,6 @@ beforeAll(async () => {
isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
config.set('enterprise.workflowSharingEnabled', true); // @TODO: Remove once temp flag is removed
await utils.initNodeTypes();
workflowRunner = await utils.initActiveWorkflowRunner();
@@ -666,7 +664,7 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
expect(response.statusCode).toBe(400);
});
it('Should succeed but prevent modifying nodes that are read-only for the requester', async () => {
it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => {
const member1 = await testDb.createUser({ globalRole: globalMemberRole });
const member2 = await testDb.createUser({ globalRole: globalMemberRole });
@@ -676,7 +674,9 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
{
id: 'uuid-1234',
name: 'Start',
parameters: {},
parameters: {
firstParam: 123,
},
position: [-20, 260],
type: 'n8n-nodes-base.start',
typeVersion: 1,
@@ -693,8 +693,10 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
{
id: 'uuid-1234',
name: 'End',
parameters: {},
position: [-20, 260],
parameters: {
firstParam: 456,
},
position: [-20, 555],
type: 'n8n-nodes-base.no-op',
typeVersion: 1,
credentials: {
@@ -703,6 +705,27 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
name: 'fake credential',
},
},
disabled: true,
},
];
const expectedNodes: INode[] = [
{
id: 'uuid-1234',
name: 'End',
parameters: {
firstParam: 123,
},
position: [-20, 555],
type: 'n8n-nodes-base.start',
typeVersion: 1,
credentials: {
default: {
id: savedCredential.id.toString(),
name: savedCredential.name,
},
},
disabled: true,
},
];
@@ -726,7 +749,7 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
});
expect(response.statusCode).toBe(200);
expect(response.body.data.nodes).toMatchObject(originalNodes);
expect(response.body.data.nodes).toMatchObject(expectedNodes);
});
});