feat(core): Add credential runtime checks and prevent tampering in manual run (#4481)

*  Create `PermissionChecker`

*  Adjust helper

* 🔥 Remove superseded helpers

*  Use `PermissionChecker`

* 🧪 Add test for dynamic router switching

*  Simplify checks

*  Export utils

*  Add missing `init` method

* 🧪 Write tests for `PermissionChecker`

* 📘 Update types

* 🧪 Fix tests

*  Set up `runManually()`

*  Refactor to reuse methods

* 🧪 Clear shared tables first

* 🔀 Adjust merge

*  Adjust imports
This commit is contained in:
Iván Ovejero
2022-11-11 11:14:45 +01:00
committed by GitHub
parent 50f7538779
commit d35d63a855
16 changed files with 497 additions and 233 deletions

View File

@@ -0,0 +1,75 @@
import { INode, NodeOperationError, Workflow } from 'n8n-workflow';
import { In } from 'typeorm';
import * as Db from '@/Db';
export class PermissionChecker {
/**
* Check if a user is permitted to execute a workflow.
*/
static async check(workflow: Workflow, userId: string) {
// allow if no nodes in this workflow use creds
const credIdsToNodes = PermissionChecker.mapCredIdsToNodes(workflow);
const workflowCredIds = Object.keys(credIdsToNodes);
if (workflowCredIds.length === 0) return;
// allow if requesting user is instance owner
const user = await Db.collections.User.findOneOrFail(userId, {
relations: ['globalRole'],
});
if (user.globalRole.name === 'owner') return;
// allow if all creds used in this workflow are a subset of
// all creds accessible to users who have access to this workflow
const workflowSharings = await Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: { workflow: { id: Number(workflow.id) } },
});
const workflowUserIds = workflowSharings.map((s) => s.userId);
const credentialSharings = await Db.collections.SharedCredentials.find({
where: { user: In(workflowUserIds) },
});
const accessibleCredIds = credentialSharings.map((s) => s.credentialId.toString());
const inaccessibleCredIds = workflowCredIds.filter((id) => !accessibleCredIds.includes(id));
if (inaccessibleCredIds.length === 0) return;
// if disallowed, flag only first node using first inaccessible cred
const nodeToFlag = credIdsToNodes[inaccessibleCredIds[0]][0];
throw new NodeOperationError(nodeToFlag, 'Node has no access to credential', {
description: 'Please recreate the credential or ask its owner to share it with you.',
});
}
private static mapCredIdsToNodes(workflow: Workflow) {
return Object.values(workflow.nodes).reduce<{ [credentialId: string]: INode[] }>(
(map, node) => {
if (node.disabled || !node.credentials) return map;
Object.values(node.credentials).forEach((cred) => {
if (!cred.id) {
throw new NodeOperationError(node, 'Node uses invalid credential', {
description: 'Please recreate the credential.',
});
}
map[cred.id] = map[cred.id] ? [...map[cred.id], node] : [node];
});
return map;
},
{},
);
}
}

View File

@@ -145,97 +145,6 @@ export async function getUserById(userId: string): Promise<User> {
return user;
}
export async function checkPermissionsForExecution(
workflow: Workflow,
userId: string,
): Promise<boolean> {
const credentialIds = new Set();
const nodeNames = Object.keys(workflow.nodes);
const credentialUsedBy = new Map();
// Iterate over all nodes
nodeNames.forEach((nodeName) => {
const node = workflow.nodes[nodeName];
if (node.disabled === true) {
// If a node is disabled there is no need to check its credentials
return;
}
// And check if any of the nodes uses credentials.
if (node.credentials) {
const credentialNames = Object.keys(node.credentials);
// For every credential this node uses
credentialNames.forEach((credentialName) => {
const credentialDetail = node.credentials![credentialName];
// If it does not contain an id, it means it is a very old
// workflow. Nowadays it should not happen anymore.
// Migrations should handle the case where a credential does
// not have an id.
if (credentialDetail.id === null) {
throw new NodeOperationError(
node,
`The credential on node '${node.name}' is not valid. Please open the workflow and set it to a valid value.`,
);
}
if (!credentialDetail.id) {
throw new NodeOperationError(
node,
`Error initializing workflow: credential ID not present. Please open the workflow and save it to fix this error. [Node: '${node.name}']`,
);
}
credentialIds.add(credentialDetail.id.toString());
if (!credentialUsedBy.has(credentialDetail.id)) {
credentialUsedBy.set(credentialDetail.id, node);
}
});
}
});
// Now that we obtained all credential IDs used by this workflow, we can
// now check if the owner of this workflow has access to all of them.
const ids = Array.from(credentialIds);
if (ids.length === 0) {
// If the workflow does not use any credentials, then we're fine
return true;
}
// If this check happens on top, we may get
// uninitialized db errors.
// Db is certainly initialized if workflow uses credentials.
const user = await getUserById(userId);
if (user.globalRole.name === 'owner') {
return true;
}
// Check for the user's permission to all used credentials
const credentialsWithAccess = await Db.collections.SharedCredentials.find({
where: {
user: { id: userId },
credentials: In(ids),
},
});
// Considering the user needs to have access to all credentials
// then both arrays (allowed credentials vs used credentials)
// must be the same length
if (ids.length !== credentialsWithAccess.length) {
credentialsWithAccess.forEach((credential) => {
credentialUsedBy.delete(credential.credentialId.toString());
});
// Find the first missing node from the Set - this is arbitrarily fetched
const firstMissingCredentialNode = credentialUsedBy.values().next().value as INode;
throw new NodeOperationError(
firstMissingCredentialNode,
'This node does not have access to the required credential',
{
description:
'Maybe the credential was removed or you have lost access to it. Try contacting the owner if this credential does not belong to you',
},
);
}
return true;
}
/**
* Check if a URL contains an auth-excluded endpoint.
*/