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:
75
packages/cli/src/UserManagement/PermissionChecker.ts
Normal file
75
packages/cli/src/UserManagement/PermissionChecker.ts
Normal 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;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user