import { Service } from 'typedi'; import type { INode, Workflow } from 'n8n-workflow'; import { CredentialAccessError, NodeOperationError, WorkflowOperationError } from 'n8n-workflow'; import config from '@/config'; import { License } from '@/License'; import { OwnershipService } from '@/services/ownership.service'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { ProjectService } from '@/services/project.service'; import { Logger } from '@/Logger'; @Service() export class PermissionChecker { constructor( private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly ownershipService: OwnershipService, private readonly license: License, private readonly projectService: ProjectService, private readonly logger: Logger, ) {} /** * Check if a workflow has the ability to execute based on the projects it's apart of. */ async check(workflowId: string, nodes: INode[]) { const homeProject = await this.ownershipService.getWorkflowProjectCached(workflowId); const homeProjectOwner = await this.ownershipService.getProjectOwnerCached(homeProject.id); if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) { // Workflow belongs to a project by a user with privileges // so all credentials are usable. Skip credential checks. return; } const projectIds = await this.projectService.findProjectsWorkflowIsIn(workflowId); const credIdsToNodes = this.mapCredIdsToNodes(nodes); const workflowCredIds = Object.keys(credIdsToNodes); if (workflowCredIds.length === 0) return; const accessible = await this.sharedCredentialsRepository.getFilteredAccessibleCredentials( projectIds, workflowCredIds, ); for (const credentialsId of workflowCredIds) { if (!accessible.includes(credentialsId)) { const nodeToFlag = credIdsToNodes[credentialsId][0]; throw new CredentialAccessError(nodeToFlag, credentialsId, workflowId); } } } async checkSubworkflowExecutePolicy( subworkflow: Workflow, parentWorkflowId: string, node?: INode, ) { /** * 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'); const isSharingEnabled = this.license.isSharingEnabled(); if (!isSharingEnabled) { // Community version allows only same owner workflows policy = 'workflowsFromSameOwner'; } const parentWorkflowOwner = await this.ownershipService.getWorkflowProjectCached(parentWorkflowId); const subworkflowOwner = await this.ownershipService.getWorkflowProjectCached(subworkflow.id); const description = subworkflowOwner.id === parentWorkflowOwner.id ? 'Change the settings of the sub-workflow so it can be called by this one.' : `An admin for the ${subworkflowOwner.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`; const errorToThrow = new WorkflowOperationError( `Target workflow ID ${subworkflow.id} may not be called`, node, description, ); if (policy === 'none') { this.logger.warn('[PermissionChecker] Subworkflow execution denied', { callerWorkflowId: parentWorkflowId, subworkflowId: subworkflow.id, reason: 'Subworkflow may not be called', policy, isSharingEnabled, }); throw errorToThrow; } if (policy === 'workflowsFromAList') { if (parentWorkflowId === undefined) { this.logger.warn('[PermissionChecker] Subworkflow execution denied', { reason: 'Subworkflow may be called only by workflows from an allowlist', callerWorkflowId: parentWorkflowId, subworkflowId: subworkflow.id, policy, isSharingEnabled, }); throw errorToThrow; } const allowedCallerIds = subworkflow.settings.callerIds ?.split(',') .map((id) => id.trim()) .filter((id) => id !== ''); if (!allowedCallerIds?.includes(parentWorkflowId)) { this.logger.warn('[PermissionChecker] Subworkflow execution denied', { reason: 'Subworkflow may be called only by workflows from an allowlist', callerWorkflowId: parentWorkflowId, subworkflowId: subworkflow.id, allowlist: allowedCallerIds, policy, isSharingEnabled, }); throw errorToThrow; } } if (policy === 'workflowsFromSameOwner' && subworkflowOwner?.id !== parentWorkflowOwner.id) { this.logger.warn('[PermissionChecker] Subworkflow execution denied', { reason: 'Subworkflow may be called only by workflows owned by the same project', callerWorkflowId: parentWorkflowId, subworkflowId: subworkflow.id, callerProjectId: parentWorkflowOwner.id, subworkflowProjectId: subworkflowOwner.id, policy, isSharingEnabled, }); throw errorToThrow; } } private mapCredIdsToNodes(nodes: INode[]) { return 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.', level: 'warning', }); } map[cred.id] = map[cred.id] ? [...map[cred.id], node] : [node]; }); return map; }, {}); } }