feat(core, editor): introduce workflow caller policy (#4368)

*  Create env `N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION`

* 👕 Adjust BE settings interface

* 👕 Adjust FE settings interface

*  Send policy along with settings

*  Enforce policy

*  Create `SubworkflowOperationError`

*  Add policy to Vuex store

*  Add setting to FE

*  Trim caller IDs on BE

*  Hide new UI behind `isWorkflowSharingEnabled`

* ✏️ Copy updates

* 👕 Fix lint
This commit is contained in:
Iván Ovejero 2022-10-26 14:59:54 +02:00 committed by GitHub
parent dd3c59677b
commit e8935de3b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 138 additions and 0 deletions

View file

@ -185,6 +185,12 @@ export const schema = {
default: false,
env: 'N8N_ONBOARDING_FLOW_DISABLED',
},
callerPolicyDefaultOption: {
doc: 'Default option for which workflows may call the current workflow',
format: ['any', 'none', 'workflowsFromAList'] as const,
default: 'any',
env: 'N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION',
},
},
executions: {

View file

@ -485,6 +485,7 @@ export interface IN8nUISettings {
saveManualExecutions: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
workflowCallerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList';
oauthCallbackUrls: {
oauth1: string;
oauth2: string;

View file

@ -284,6 +284,7 @@ class App {
saveManualExecutions: this.saveManualExecutions,
executionTimeout: this.executionTimeout,
maxExecutionTimeout: this.maxExecutionTimeout,
workflowCallerPolicyDefaultOption: config.getEnv('workflows.callerPolicyDefaultOption'),
timezone: this.timezone,
urlBaseWebhook,
urlBaseEditor: instanceBaseUrl,

View file

@ -747,9 +747,38 @@ export async function getRunData(
workflowData: IWorkflowBase,
userId: string,
inputData?: INodeExecutionData[],
parentWorkflowId?: string,
): 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

View file

@ -291,6 +291,7 @@ export class WorkflowRunnerProcess {
workflowData,
additionalData.userId,
options?.inputData,
options?.parentWorkflowId,
);
await sendToParentProcess('startExecution', { runData });
const executionId: string = await new Promise((resolve) => {

View file

@ -719,12 +719,15 @@ export interface ITemplatesCategory {
name: string;
}
export type WorkflowCallerPolicyDefaultOption = 'any' | 'none' | 'workflowsFromAList';
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
saveManualExecutions: boolean;
workflowCallerPolicyDefaultOption: WorkflowCallerPolicyDefaultOption;
timezone: string;
executionTimeout: number;
maxExecutionTimeout: number;
@ -768,6 +771,7 @@ export interface IN8nUISettings {
deployment?: {
type: string;
};
isWorkflowSharingEnabled: boolean;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -777,6 +781,8 @@ export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
saveManualExecutions?: boolean;
timezone?: string;
executionTimeout?: number;
callerIds?: string;
callerPolicy?: WorkflowCallerPolicyDefaultOption;
}
export interface ITimeoutHMS {

View file

@ -123,6 +123,45 @@
</n8n-select>
</el-col>
</el-row>
<div v-if="isWorkflowSharingEnabled">
<el-row>
<el-col :span="10" class="setting-name">
{{ $locale.baseText('workflowSettings.callerPolicy') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-text="helpTexts.workflowCallerPolicy"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<n8n-select v-model="workflowSettings.callerPolicy" :placeholder="$locale.baseText('workflowSettings.selectOption')" size="medium" filterable :limit-popper-width="true">
<n8n-option
v-for="option of workflowCallerPolicyOptions"
:key="option.key"
:label="option.value"
:value="option.key">
</n8n-option>
</n8n-select>
</el-col>
</el-row>
<el-row v-if="workflowSettings.callerPolicy === 'workflowsFromAList'">
<el-col :span="10" class="setting-name">
{{ $locale.baseText('workflowSettings.callerIds') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-text="helpTexts.workflowCallerIds"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="14">
<n8n-input
type="text"
size="medium"
v-model="workflowSettings.callerIds"
@input="onCallerIdsInput"
/>
</el-col>
</el-row>
</div>
<el-row>
<el-col :span="10" class="setting-name">
{{ $locale.baseText('workflowSettings.timeoutWorkflow') + ":" }}
@ -216,6 +255,8 @@ export default mixins(
saveManualExecutions: this.$locale.baseText('workflowSettings.helpTexts.saveManualExecutions'),
executionTimeoutToggle: this.$locale.baseText('workflowSettings.helpTexts.executionTimeoutToggle'),
executionTimeout: this.$locale.baseText('workflowSettings.helpTexts.executionTimeout'),
workflowCallerPolicy: this.$locale.baseText('workflowSettings.helpTexts.workflowCallerPolicy'),
workflowCallerIds: this.$locale.baseText('workflowSettings.helpTexts.workflowCallerIds'),
},
defaultValues: {
timezone: 'America/New_York',
@ -223,7 +264,9 @@ export default mixins(
saveDataSuccessExecution: 'all',
saveExecutionProgress: false,
saveManualExecutions: false,
workflowCallerPolicy: '',
},
workflowCallerPolicyOptions: [] as Array<{ key: string, value: string }>,
saveDataErrorExecutionOptions: [] as Array<{ key: string, value: string }>,
saveDataSuccessExecutionOptions: [] as Array<{ key: string, value: string }>,
saveExecutionProgressOptions: [] as Array<{ key: string | boolean, value: string }>,
@ -241,6 +284,9 @@ export default mixins(
computed: {
...mapGetters(['workflowName', 'workflowId']),
isWorkflowSharingEnabled(): boolean {
return this.$store.getters['settings/isWorkflowSharingEnabled'];
},
},
async mounted () {
@ -259,6 +305,7 @@ export default mixins(
this.defaultValues.saveDataSuccessExecution = this.$store.getters.saveDataSuccessExecution;
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
this.defaultValues.timezone = this.$store.getters.timezone;
this.defaultValues.workflowCallerPolicy = this.$store.getters['settings/workflowCallerPolicyDefaultOption'];
this.isLoading = true;
const promises = [];
@ -268,6 +315,7 @@ export default mixins(
promises.push(this.loadSaveExecutionProgressOptions());
promises.push(this.loadSaveManualOptions());
promises.push(this.loadTimezones());
promises.push(this.loadWorkflowCallerPolicyOptions());
try {
await Promise.all(promises);
@ -292,6 +340,9 @@ export default mixins(
if (workflowSettings.saveManualExecutions === undefined) {
workflowSettings.saveManualExecutions = 'DEFAULT';
}
if (workflowSettings.callerPolicy === undefined) {
workflowSettings.callerPolicy = this.defaultValues.workflowCallerPolicy;
}
if (workflowSettings.executionTimeout === undefined) {
workflowSettings.executionTimeout = this.$store.getters.executionTimeout;
}
@ -307,6 +358,11 @@ export default mixins(
this.$telemetry.track('User opened workflow settings', { workflow_id: this.$store.getters.workflowId });
},
methods: {
onCallerIdsInput(str: string) {
this.workflowSettings.callerIds = /^[0-9,\s]+$/.test(str)
? str
: str.replace(/[^0-9,\s]/g, '');
},
closeDialog () {
this.modalBus.$emit('close');
this.$externalHooks().run('workflowSettings.dialogVisibleChanged', { dialogVisible: false });
@ -319,6 +375,22 @@ export default mixins(
[key]: time,
};
},
async loadWorkflowCallerPolicyOptions () {
this.workflowCallerPolicyOptions = [
{
key: 'any',
value: this.$locale.baseText('workflowSettings.callerPolicy.options.any'),
},
{
key: 'none',
value: this.$locale.baseText('workflowSettings.callerPolicy.options.none'),
},
{
key: 'workflowsFromAList',
value: this.$locale.baseText('workflowSettings.callerPolicy.options.workflowsFromAList'),
},
];
},
async loadSaveDataErrorExecutionOptions () {
this.saveDataErrorExecutionOptions.length = 0;
this.saveDataErrorExecutionOptions.push.apply( // eslint-disable-line no-useless-call

View file

@ -6,6 +6,7 @@ import {
IN8nValueSurveyData,
IRootState,
ISettingsState,
WorkflowCallerPolicyDefaultOption,
} from '../Interface';
import { getPromptsData, submitValueSurvey, submitContactInfo, getSettings } from '../api/settings';
import Vue from 'vue';
@ -108,6 +109,12 @@ const module: Module<ISettingsState, IRootState> = {
isQueueModeEnabled: (state): boolean => {
return state.settings.executionMode === 'queue';
},
workflowCallerPolicyDefaultOption: (state): WorkflowCallerPolicyDefaultOption => {
return state.settings.workflowCallerPolicyDefaultOption;
},
isWorkflowSharingEnabled: (state): boolean => {
return state.settings.isWorkflowSharingEnabled;
},
},
mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) {
@ -132,6 +139,12 @@ const module: Module<ISettingsState, IRootState> = {
setCommunityNodesFeatureEnabled(state: ISettingsState, isEnabled: boolean) {
state.settings.communityNodesEnabled = isEnabled;
},
setIsWorkflowSharingEnabled(state: ISettingsState, enabled: boolean) {
state.settings.isWorkflowSharingEnabled = enabled;
},
setWorkflowCallerPolicyDefaultOption(state: ISettingsState, defaultOption: WorkflowCallerPolicyDefaultOption) {
state.settings.workflowCallerPolicyDefaultOption = defaultOption;
},
setAllowedModules(state, allowedModules: { builtIn?: string, external?: string }) {
state.settings.allowedModules = {
...(allowedModules.builtIn && { builtIn: allowedModules.builtIn.split(',') }),
@ -164,6 +177,8 @@ const module: Module<ISettingsState, IRootState> = {
context.commit('versions/setVersionNotificationSettings', settings.versionNotifications, {root: true});
context.commit('setCommunityNodesFeatureEnabled', settings.communityNodesEnabled === true);
context.commit('settings/setAllowedModules', settings.allowedModules, {root: true});
context.commit('settings/setWorkflowCallerPolicyDefaultOption', settings.workflowCallerPolicyDefaultOption, {root: true});
context.commit('settings/setIsWorkflowSharingEnabled', settings.enterprise.workflowSharing, {root: true});
},
async fetchPromptsData(context: ActionContext<ISettingsState, IRootState>) {
if (!context.getters.isTelemetryEnabled) {

View file

@ -1267,6 +1267,11 @@
"workflowRun.showError.title": "Problem running workflow",
"workflowRun.showMessage.message": "Please fix them before executing",
"workflowRun.showMessage.title": "Workflow has issues",
"workflowSettings.callerIds": "Caller IDs",
"workflowSettings.callerPolicy": "This workflow can be called by",
"workflowSettings.callerPolicy.options.any": "Any workflow",
"workflowSettings.callerPolicy.options.workflowsFromAList": "List of specific workflow IDs",
"workflowSettings.callerPolicy.options.none": "No workflows can call one",
"workflowSettings.defaultTimezone": "Default - {defaultTimezoneValue}",
"workflowSettings.defaultTimezoneNotValid": "Default Timezone not valid",
"workflowSettings.errorWorkflow": "Error Workflow",
@ -1278,6 +1283,8 @@
"workflowSettings.helpTexts.saveExecutionProgress": "Whether to save data after each node execution. This allows you to resume from where execution stopped if there is an error, but may increase latency.",
"workflowSettings.helpTexts.saveManualExecutions": "Whether to save data of executions that are started manually from the editor",
"workflowSettings.helpTexts.timezone": "The timezone in which the workflow should run. Used by 'cron' node, for example.",
"workflowSettings.helpTexts.workflowCallerIds": "Comma-delimited list of IDs of workflows that are allowed to call this workflow",
"workflowSettings.helpTexts.workflowCallerPolicy": "Workflows that are allowed to call this workflow using the Execute Workflow node",
"workflowSettings.hours": "hours",
"workflowSettings.minutes": "minutes",
"workflowSettings.noWorkflow": "- No Workflow -",