diff --git a/packages/cli/commands/worker.ts b/packages/cli/commands/worker.ts index 7e0a8640cf..3c4d30d1fe 100644 --- a/packages/cli/commands/worker.ts +++ b/packages/cli/commands/worker.ts @@ -41,6 +41,7 @@ import { checkPermissionsForExecution, getWorkflowOwner, } from '../src/UserManagement/UserManagementHelper'; +import { generateFailedExecutionFromError } from '../src/WorkflowHelpers'; export class Worker extends Command { static description = '\nStarts a n8n worker'; @@ -183,8 +184,6 @@ export class Worker extends Command { settings: currentExecutionDb.workflowData.settings, }); - await checkPermissionsForExecution(workflow, workflowOwner.id); - const additionalData = await WorkflowExecuteAdditionalData.getBase( workflowOwner.id, undefined, @@ -197,6 +196,20 @@ export class Worker extends Command { { retryOf: currentExecutionDb.retryOf as string }, ); + try { + await checkPermissionsForExecution(workflow, workflowOwner.id); + } catch (error) { + const failedExecution = generateFailedExecutionFromError( + currentExecutionDb.mode, + error, + error.node, + ); + await additionalData.hooks.executeHookFunctions('workflowExecuteAfter', [failedExecution]); + return { + success: true, + }; + } + additionalData.hooks.hookFunctions.sendResponse = [ async (response: IExecuteResponsePromiseData): Promise => { const progress: Queue.WebhookResponse = { diff --git a/packages/cli/config/schema.ts b/packages/cli/config/schema.ts index 04c519c7d4..78b7f9d59d 100644 --- a/packages/cli/config/schema.ts +++ b/packages/cli/config/schema.ts @@ -875,6 +875,15 @@ export const schema = { }, }, + enterprise: { + features: { + sharing: { + format: Boolean, + default: false, + }, + }, + }, + hiringBanner: { enabled: { doc: 'Whether hiring banner in browser console is enabled.', diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 7fc14a8fd0..973fe767cc 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -805,7 +805,7 @@ export async function getCredentialWithoutUser( return credential; } -export function createCredentiasFromCredentialsEntity( +export function createCredentialsFromCredentialsEntity( credential: CredentialsEntity, encrypt = false, ): Credentials { diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 6f0be81979..0b92a8d1cb 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -28,16 +28,17 @@ import { Repository } from 'typeorm'; import { ChildProcess } from 'child_process'; import { Url } from 'url'; -import { Request } from 'express'; -import { WorkflowEntity } from './databases/entities/WorkflowEntity'; -import { TagEntity } from './databases/entities/TagEntity'; -import { Role } from './databases/entities/Role'; -import { User } from './databases/entities/User'; -import { SharedCredentials } from './databases/entities/SharedCredentials'; -import { SharedWorkflow } from './databases/entities/SharedWorkflow'; -import { Settings } from './databases/entities/Settings'; -import { InstalledPackages } from './databases/entities/InstalledPackages'; -import { InstalledNodes } from './databases/entities/InstalledNodes'; + +import type { Request } from 'express'; +import type { InstalledNodes } from './databases/entities/InstalledNodes'; +import type { InstalledPackages } from './databases/entities/InstalledPackages'; +import type { Role } from './databases/entities/Role'; +import type { Settings } from './databases/entities/Settings'; +import type { SharedCredentials } from './databases/entities/SharedCredentials'; +import type { SharedWorkflow } from './databases/entities/SharedWorkflow'; +import type { TagEntity } from './databases/entities/TagEntity'; +import type { User } from './databases/entities/User'; +import type { WorkflowEntity } from './databases/entities/WorkflowEntity'; export interface IActivationError { time: number; @@ -153,6 +154,7 @@ export interface ICredentialsBase { export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted { id: number | string; name: string; + shared?: SharedCredentials[]; } export interface ICredentialsResponse extends ICredentialsDb { @@ -506,6 +508,9 @@ export interface IN8nUISettings { type: string; }; isNpmAvailable: boolean; + enterprise: { + sharing: boolean; + }; } export interface IPersonalizationSurveyAnswers { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index fe98bc6428..037a6259f4 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,5 +1,5 @@ /* eslint-disable import/no-cycle */ -import { get as pslGet } from 'psl'; +import { snakeCase } from 'change-case'; import { BinaryDataManager } from 'n8n-core'; import { INodesGraphResult, @@ -8,7 +8,7 @@ import { ITelemetryTrackProperties, TelemetryHelpers, } from 'n8n-workflow'; -import { snakeCase } from 'change-case'; +import { get as pslGet } from 'psl'; import { IDiagnosticInfo, IInternalHooksClass, @@ -16,15 +16,20 @@ import { IWorkflowBase, IWorkflowDb, } from '.'; -import { Telemetry } from './telemetry'; import { IExecutionTrackProperties } from './Interfaces'; +import { Telemetry } from './telemetry'; export class InternalHooksClass implements IInternalHooksClass { private versionCli: string; private nodeTypes: INodeTypes; - constructor(private telemetry: Telemetry, versionCli: string, nodeTypes: INodeTypes) { + constructor( + private telemetry: Telemetry, + private instanceId: string, + versionCli: string, + nodeTypes: INodeTypes, + ) { this.versionCli = versionCli; this.nodeTypes = nodeTypes; } @@ -187,6 +192,7 @@ export class InternalHooksClass implements IInternalHooksClass { } const manualExecEventProperties: ITelemetryTrackProperties = { + user_id: userId, workflow_id: workflow.id.toString(), status: properties.success ? 'success' : 'failed', error_message: properties.error_message as string, @@ -398,6 +404,34 @@ export class InternalHooksClass implements IInternalHooksClass { ); } + /** + * Credentials + */ + + async onUserCreatedCredentials(userCreatedCredentialsData: { + credential_type: string; + credential_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('User created credentials', { + ...userCreatedCredentialsData, + instance_id: this.instanceId, + }); + } + + async onUserSharedCredentials(userSharedCredentialsData: { + credential_type: string; + credential_id: string; + user_id_sharer: string; + user_ids_sharees_added: string[]; + sharees_removed: number | null; + }): Promise { + return this.telemetry.track('User updated cred sharing', { + ...userSharedCredentialsData, + instance_id: this.instanceId, + }); + } + /** * Community nodes backend telemetry events */ diff --git a/packages/cli/src/InternalHooksManager.ts b/packages/cli/src/InternalHooksManager.ts index 617fe1b6a2..e56e3d25dd 100644 --- a/packages/cli/src/InternalHooksManager.ts +++ b/packages/cli/src/InternalHooksManager.ts @@ -18,6 +18,7 @@ export class InternalHooksManager { if (!this.internalHooksInstance) { this.internalHooksInstance = new InternalHooksClass( new Telemetry(instanceId, versionCli), + instanceId, versionCli, nodeTypes, ); diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 8bc83b4b35..7fca603dcd 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -148,9 +148,11 @@ const isUniqueConstraintError = (error: Error) => * @returns */ -export function send(processFunction: (req: Request, res: Response) => Promise, raw = false) { - // eslint-disable-next-line consistent-return - return async (req: Request, res: Response) => { +export function send( + processFunction: (req: R, res: S) => Promise, + raw = false, +) { + return async (req: R, res: S) => { try { const data = await processFunction(req, res); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b78ce48a65..071095f1c5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -29,20 +29,19 @@ /* eslint-disable import/no-dynamic-require */ /* eslint-disable no-await-in-loop */ -import express from 'express'; -import { readFileSync, promises } from 'fs'; import { exec as callbackExec } from 'child_process'; -import _ from 'lodash'; -import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path'; -import { FindManyOptions, getConnectionManager, In } from 'typeorm'; -import bodyParser from 'body-parser'; -import cookieParser from 'cookie-parser'; -import history from 'connect-history-api-fallback'; +import { promises, readFileSync } from 'fs'; import os from 'os'; -// eslint-disable-next-line import/no-extraneous-dependencies -import clientOAuth1, { RequestOptions } from 'oauth-1.0a'; -import axios, { AxiosRequestConfig } from 'axios'; +import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path'; import { createHmac } from 'crypto'; +import { promisify } from 'util'; +import cookieParser from 'cookie-parser'; +import express from 'express'; +import _ from 'lodash'; +import { FindManyOptions, getConnectionManager, In } from 'typeorm'; +// eslint-disable-next-line import/no-extraneous-dependencies +import axios, { AxiosRequestConfig } from 'axios'; +import clientOAuth1, { RequestOptions } from 'oauth-1.0a'; // IMPORTANT! Do not switch to anther bcrypt library unless really necessary and // tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ... import { compare } from 'bcryptjs'; @@ -70,8 +69,41 @@ import jwks from 'jwks-rsa'; import timezones from 'google-timezones-json'; import parseUrl from 'parseurl'; import promClient, { Registry } from 'prom-client'; -import { promisify } from 'util'; +import history from 'connect-history-api-fallback'; +import bodyParser from 'body-parser'; +import config from '../config'; import * as Queue from './Queue'; + +import { InternalHooksManager } from './InternalHooksManager'; +import { getCredentialTranslationPath } from './TranslationHelpers'; +import { WEBHOOK_METHODS } from './WebhookHelpers'; +import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers'; + +import { nodesController } from './api/nodes.api'; +import { workflowsController } from './api/workflows.api'; +import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants'; +import { credentialsController } from './credentials/credentials.controller'; +import { oauth2CredentialController } from './credentials/oauth2Credential.api'; +import type { + ExecutionRequest, + NodeParameterOptionsRequest, + OAuthRequest, + WorkflowRequest, +} from './requests'; +import { userManagementRouter } from './UserManagement'; +import { resolveJwt } from './UserManagement/auth/jwt'; + +import { executionsController } from './api/executions.api'; +import { nodeTypesController } from './api/nodeTypes.api'; +import { tagsController } from './api/tags.api'; +import { isCredentialsSharingEnabled } from './credentials/helpers'; +import { loadPublicApiVersions } from './PublicApi'; +import * as telemetryScripts from './telemetry/scripts'; +import { + getInstanceBaseUrl, + isEmailSetUp, + isUserManagementEnabled, +} from './UserManagement/UserManagementHelper'; import { ActiveExecutions, ActiveWorkflowRunner, @@ -82,6 +114,8 @@ import { Db, ExternalHooks, GenericHelpers, + getCredentialForUser, + getCredentialWithoutUser, ICredentialsDb, ICredentialsOverwrite, ICustomRequest, @@ -101,41 +135,8 @@ import { WebhookHelpers, WebhookServer, WorkflowExecuteAdditionalData, - getCredentialForUser, - getCredentialWithoutUser, } from '.'; -import config from '../config'; - -import { InternalHooksManager } from './InternalHooksManager'; -import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers'; -import { getCredentialTranslationPath, getNodeTranslationPath } from './TranslationHelpers'; -import { WEBHOOK_METHODS } from './WebhookHelpers'; - -import { userManagementRouter } from './UserManagement'; -import { resolveJwt } from './UserManagement/auth/jwt'; -import type { - ExecutionRequest, - NodeParameterOptionsRequest, - OAuthRequest, - WorkflowRequest, -} from './requests'; -import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants'; -import { credentialsController } from './api/credentials.api'; -import { executionsController } from './api/executions.api'; -import { workflowsController } from './api/workflows.api'; -import { nodesController } from './api/nodes.api'; -import { oauth2CredentialController } from './api/oauth2Credential.api'; -import { tagsController } from './api/tags.api'; -import { - getInstanceBaseUrl, - isEmailSetUp, - isUserManagementEnabled, -} from './UserManagement/UserManagementHelper'; -import { loadPublicApiVersions } from './PublicApi'; -import * as telemetryScripts from './telemetry/scripts'; -import { nodeTypesController } from './api/nodeTypes.api'; - require('body-parser-xml')(bodyParser); const exec = promisify(callbackExec); @@ -310,6 +311,9 @@ class App { type: config.getEnv('deployment.type'), }, isNpmAvailable: false, + enterprise: { + sharing: false, + }, }; } @@ -336,6 +340,11 @@ class App { config.getEnv('userManagement.skipInstanceOwnerSetup') === false, }); + // refresh enterprise status + Object.assign(this.frontendSettings.enterprise, { + sharing: isCredentialsSharingEnabled(), + }); + if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index d54bfa278f..f8099ff699 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable import/no-cycle */ -import { Workflow } from 'n8n-workflow'; +import { INode, NodeOperationError, Workflow } from 'n8n-workflow'; import { In } from 'typeorm'; import express from 'express'; import { compare, genSaltSync, hash } from 'bcryptjs'; @@ -147,6 +147,7 @@ export async function checkPermissionsForExecution( ): Promise { 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]; @@ -165,16 +166,21 @@ export async function checkPermissionsForExecution( // Migrations should handle the case where a credential does // not have an id. if (credentialDetail.id === null) { - throw new Error( + 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 Error( + 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); + } }); } }); @@ -197,7 +203,7 @@ export async function checkPermissionsForExecution( } // Check for the user's permission to all used credentials - const credentialCount = await Db.collections.SharedCredentials.count({ + const credentialsWithAccess = await Db.collections.SharedCredentials.find({ where: { user: { id: userId }, credentials: In(ids), @@ -207,8 +213,21 @@ export async function checkPermissionsForExecution( // 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 !== credentialCount) { - throw new Error('One or more of the used credentials are not accessible.'); + 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; } diff --git a/packages/cli/src/UserManagement/email/templates/passwordReset.html b/packages/cli/src/UserManagement/email/templates/passwordReset.html index 161407592e..701409c6d8 100644 --- a/packages/cli/src/UserManagement/email/templates/passwordReset.html +++ b/packages/cli/src/UserManagement/email/templates/passwordReset.html @@ -1,5 +1,5 @@

Hi {{firstName}},

Somebody asked to reset your password on n8n ({{ domain }}).

-

If it was not you, you can safely ignore this email.

+

Click the following link to choose a new password. The link is valid for 2 hours.

{{ passwordResetUrl }} diff --git a/packages/cli/src/UserManagement/middlewares/auth.ts b/packages/cli/src/UserManagement/middlewares/auth.ts new file mode 100644 index 0000000000..81bbf4a241 --- /dev/null +++ b/packages/cli/src/UserManagement/middlewares/auth.ts @@ -0,0 +1,56 @@ +/* eslint-disable import/no-cycle */ +import { Request, RequestHandler } from 'express'; +import jwt from 'jsonwebtoken'; +import passport from 'passport'; +import { Strategy } from 'passport-jwt'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import { JwtPayload } from '../Interfaces'; +import type { AuthenticatedRequest } from '../../requests'; +import * as config from '../../../config'; +import { AUTH_COOKIE_NAME } from '../../constants'; +import { issueCookie, resolveJwtContent } from '../auth/jwt'; + +const jwtFromRequest = (req: Request) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null; +}; + +export const jwtAuth = (): RequestHandler => { + const jwtStrategy = new Strategy( + { + jwtFromRequest, + secretOrKey: config.getEnv('userManagement.jwtSecret'), + }, + async (jwtPayload: JwtPayload, done) => { + try { + const user = await resolveJwtContent(jwtPayload); + return done(null, user); + } catch (error) { + Logger.debug('Failed to extract user from JWT payload', { jwtPayload }); + return done(null, false, { message: 'User not found' }); + } + }, + ); + + passport.use(jwtStrategy); + return passport.initialize(); +}; + +/** + * middleware to refresh cookie before it expires + */ +export const refreshExpiringCookie: RequestHandler = async ( + req: AuthenticatedRequest, + res, + next, +) => { + const cookieAuth = jwtFromRequest(req); + if (cookieAuth && req.user) { + const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number }; + if (cookieContents.exp * 1000 - Date.now() < 259200000) { + // if cookie expires in < 3 days, renew it. + await issueCookie(res, req.user); + } + } + next(); +}; diff --git a/packages/cli/src/UserManagement/middlewares/index.ts b/packages/cli/src/UserManagement/middlewares/index.ts new file mode 100644 index 0000000000..b73e939959 --- /dev/null +++ b/packages/cli/src/UserManagement/middlewares/index.ts @@ -0,0 +1,2 @@ +/* eslint-disable import/no-cycle */ +export * from './auth'; diff --git a/packages/cli/src/UserManagement/routes/auth.ts b/packages/cli/src/UserManagement/routes/auth.ts index b808db9102..f89fe4af2a 100644 --- a/packages/cli/src/UserManagement/routes/auth.ts +++ b/packages/cli/src/UserManagement/routes/auth.ts @@ -21,20 +21,19 @@ export function authenticationMethods(this: N8nApp): void { this.app.post( `/${this.restEndpoint}/login`, ResponseHelper.send(async (req: LoginRequest, res: Response): Promise => { - if (!req.body.email) { + const { email, password } = req.body; + if (!email) { throw new Error('Email is required to log in'); } - if (!req.body.password) { + if (!password) { throw new Error('Password is required to log in'); } - let user; + let user: User | undefined; try { user = await Db.collections.User.findOne( - { - email: req.body.email, - }, + { email }, { relations: ['globalRole'], }, diff --git a/packages/cli/src/UserManagement/routes/index.ts b/packages/cli/src/UserManagement/routes/index.ts index 0a8400e4ef..8c77ec652a 100644 --- a/packages/cli/src/UserManagement/routes/index.ts +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -4,21 +4,10 @@ /* eslint-disable import/no-cycle */ import cookieParser from 'cookie-parser'; import passport from 'passport'; -import { Strategy } from 'passport-jwt'; import { NextFunction, Request, Response } from 'express'; -import jwt from 'jsonwebtoken'; import { LoggerProxy as Logger } from 'n8n-workflow'; - -import { JwtPayload, N8nApp } from '../Interfaces'; -import { authenticationMethods } from './auth'; -import * as config from '../../../config'; -import { AUTH_COOKIE_NAME } from '../../constants'; -import { issueCookie, resolveJwtContent } from '../auth/jwt'; -import { meNamespace } from './me'; -import { usersNamespace } from './users'; -import { passwordResetNamespace } from './passwordReset'; +import { N8nApp } from '../Interfaces'; import { AuthenticatedRequest } from '../../requests'; -import { ownerNamespace } from './owner'; import { isAuthExcluded, isPostUsersId, @@ -26,32 +15,17 @@ import { isUserManagementDisabled, } from '../UserManagementHelper'; import { Db } from '../..'; +import { jwtAuth, refreshExpiringCookie } from '../middlewares'; +import { authenticationMethods } from './auth'; +import { meNamespace } from './me'; +import { usersNamespace } from './users'; +import { passwordResetNamespace } from './passwordReset'; +import { ownerNamespace } from './owner'; export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { // needed for testing; not adding overhead since it directly returns if req.cookies exists this.app.use(cookieParser()); - - const options = { - jwtFromRequest: (req: Request) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return (req.cookies?.[AUTH_COOKIE_NAME] as string | undefined) ?? null; - }, - secretOrKey: config.getEnv('userManagement.jwtSecret'), - }; - - passport.use( - new Strategy(options, async function validateCookieContents(jwtPayload: JwtPayload, done) { - try { - const user = await resolveJwtContent(jwtPayload); - return done(null, user); - } catch (error) { - Logger.debug('Failed to extract user from JWT payload', { jwtPayload }); - return done(null, false, { message: 'User not found' }); - } - }), - ); - - this.app.use(passport.initialize()); + this.app.use(jwtAuth()); this.app.use(async (req: Request, res: Response, next: NextFunction) => { if ( @@ -102,7 +76,7 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint } // Not owner and user exists. We now protect restricted urls. const postRestrictedUrls = [`/${this.restEndpoint}/users`, `/${this.restEndpoint}/owner`]; - const getRestrictedUrls = [`/${this.restEndpoint}/users`]; + const getRestrictedUrls: string[] = []; const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url; if ( (req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) || @@ -124,18 +98,7 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint next(); }); - // middleware to refresh cookie before it expires - this.app.use(async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - const cookieAuth = options.jwtFromRequest(req); - if (cookieAuth && req.user) { - const cookieContents = jwt.decode(cookieAuth) as JwtPayload & { exp: number }; - if (cookieContents.exp * 1000 - Date.now() < 259200000) { - // if cookie expires in < 3 days, renew it. - await issueCookie(res, req.user); - } - } - next(); - }); + this.app.use(refreshExpiringCookie); authenticationMethods.apply(this); ownerNamespace.apply(this); diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts index a527a55c44..3d4f49f55c 100644 --- a/packages/cli/src/UserManagement/routes/me.ts +++ b/packages/cli/src/UserManagement/routes/me.ts @@ -32,7 +32,8 @@ export function meNamespace(this: N8nApp): void { `/${this.restEndpoint}/me`, ResponseHelper.send( async (req: MeRequest.Settings, res: express.Response): Promise => { - if (!req.body.email) { + const { email } = req.body; + if (!email) { Logger.debug('Request to update user email failed because of missing email in payload', { userId: req.user.id, payload: req.body, @@ -40,10 +41,10 @@ export function meNamespace(this: N8nApp): void { throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); } - if (!validator.isEmail(req.body.email)) { + if (!validator.isEmail(email)) { Logger.debug('Request to update user email failed because of invalid email in payload', { userId: req.user.id, - invalidEmail: req.body.email, + invalidEmail: email, }); throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index ffe955ec56..e0db2dfba7 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -19,7 +19,10 @@ import { IRunExecutionData, ITaskData, LoggerProxy as Logger, + NodeApiError, + NodeOperationError, Workflow, + WorkflowExecuteMode, } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; // eslint-disable-next-line import/no-cycle @@ -684,3 +687,48 @@ export async function isBelowOnboardingThreshold(user: User): Promise { return belowThreshold; } + +export function generateFailedExecutionFromError( + mode: WorkflowExecuteMode, + error: NodeApiError | NodeOperationError, + node: INode, +): IRun { + return { + data: { + startData: { + destinationNode: node.name, + runNodeFilter: [node.name], + }, + resultData: { + error, + runData: { + [node.name]: [ + { + startTime: 0, + executionTime: 0, + error, + source: [], + }, + ], + }, + lastNodeExecuted: node.name, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [ + { + node, + data: {}, + source: null, + }, + ], + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }, + finished: false, + mode, + startedAt: new Date(), + stoppedAt: new Date(), + }; +} diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 107386a796..e733fa82aa 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -55,6 +55,7 @@ import { import * as Queue from './Queue'; import { InternalHooksManager } from './InternalHooksManager'; import { checkPermissionsForExecution } from './UserManagement/UserManagementHelper'; +import { generateFailedExecutionFromError } from './WorkflowHelpers'; export class WorkflowRunner { activeExecutions: ActiveExecutions.ActiveExecutions; @@ -267,14 +268,30 @@ export class WorkflowRunner { { executionId }, ); - await checkPermissionsForExecution(workflow, data.userId); - additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain( data, executionId, true, ); + try { + await checkPermissionsForExecution(workflow, data.userId); + } catch (error) { + // Create a failed execution with the data for the node + // save it and abort execution + const failedExecution = generateFailedExecutionFromError( + data.executionMode, + error, + error.node, + ); + additionalData.hooks + .executeHookFunctions('workflowExecuteAfter', [failedExecution]) + .then(() => { + this.activeExecutions.remove(executionId, failedExecution); + }); + return executionId; + } + additionalData.hooks.hookFunctions.sendResponse = [ async (response: IExecuteResponsePromiseData): Promise => { if (responsePromise) { diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 74ea198409..2b2a061b89 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -25,6 +25,7 @@ import { IWorkflowExecuteHooks, IWorkflowSettings, LoggerProxy, + NodeOperationError, Workflow, WorkflowExecuteMode, WorkflowHooks, @@ -50,6 +51,7 @@ import config from '../config'; import { InternalHooksManager } from './InternalHooksManager'; import { checkPermissionsForExecution } from './UserManagement/UserManagementHelper'; import { loadClassInIsolation } from './CommunityNodes/helpers'; +import { generateFailedExecutionFromError } from './WorkflowHelpers'; export class WorkflowRunnerProcess { data: IWorkflowExecutionDataProcessWithExecution | undefined; @@ -220,7 +222,22 @@ export class WorkflowRunnerProcess { settings: this.data.workflowData.settings, pinData: this.data.pinData, }); - await checkPermissionsForExecution(this.workflow, userId); + try { + await checkPermissionsForExecution(this.workflow, userId); + } catch (error) { + const caughtError = error as NodeOperationError; + const failedExecutionData = generateFailedExecutionFromError( + this.data.executionMode, + caughtError, + caughtError.node, + ); + + // Force the `workflowExecuteAfter` hook to run since + // it's the one responsible for saving the execution + await this.sendHookToParentProcess('workflowExecuteAfter', [failedExecutionData]); + // Interrupt the workflow execution since we don't have all necessary creds. + return failedExecutionData; + } const additionalData = await WorkflowExecuteAdditionalData.getBase( userId, undefined, diff --git a/packages/cli/src/api/credentials.api.ts b/packages/cli/src/api/credentials.api.ts deleted file mode 100644 index e374aee002..0000000000 --- a/packages/cli/src/api/credentials.api.ts +++ /dev/null @@ -1,419 +0,0 @@ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-restricted-syntax */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable import/no-cycle */ -import express from 'express'; -import { In } from 'typeorm'; -import { UserSettings, Credentials } from 'n8n-core'; -import { - INodeCredentialsDetails, - INodeCredentialTestResult, - LoggerProxy, - WorkflowExecuteMode, -} from 'n8n-workflow'; -import { getLogger } from '../Logger'; - -import { - CredentialsHelper, - Db, - GenericHelpers, - ICredentialsDb, - ICredentialsResponse, - whereClause, - ResponseHelper, - CredentialTypes, -} from '..'; - -import { RESPONSE_ERROR_MESSAGES } from '../constants'; -import { CredentialsEntity } from '../databases/entities/CredentialsEntity'; -import { SharedCredentials } from '../databases/entities/SharedCredentials'; -import { validateEntity } from '../GenericHelpers'; -import { createCredentiasFromCredentialsEntity } from '../CredentialsHelper'; -import type { CredentialRequest } from '../requests'; -import * as config from '../../config'; -import { externalHooks } from '../Server'; - -export const credentialsController = express.Router(); - -/** - * Initialize Logger if needed - */ -credentialsController.use((req, res, next) => { - try { - LoggerProxy.getInstance(); - } catch (error) { - LoggerProxy.init(getLogger()); - } - next(); -}); - -/** - * GET /credentials - */ -credentialsController.get( - '/', - ResponseHelper.send(async (req: CredentialRequest.GetAll): Promise => { - let credentials: ICredentialsDb[] = []; - - const filter = req.query.filter ? (JSON.parse(req.query.filter) as Record) : {}; - - try { - if (req.user.globalRole.name === 'owner') { - credentials = await Db.collections.Credentials.find({ - select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], - where: filter, - }); - } else { - const shared = await Db.collections.SharedCredentials.find({ - where: whereClause({ - user: req.user, - entityType: 'credentials', - }), - }); - - if (!shared.length) return []; - - credentials = await Db.collections.Credentials.find({ - select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], - where: { - id: In(shared.map(({ credentialId }) => credentialId)), - ...filter, - }, - }); - } - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - LoggerProxy.error('Request to list credentials failed', error); - throw error; - } - - return credentials.map((credential) => { - // eslint-disable-next-line no-param-reassign - credential.id = credential.id.toString(); - return credential as ICredentialsResponse; - }); - }), -); - -/** - * GET /credentials/new - * - * Generate a unique credential name. - */ -credentialsController.get( - '/new', - ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => { - const { name: newName } = req.query; - - return { - name: await GenericHelpers.generateUniqueName( - newName ?? config.getEnv('credentials.defaultName'), - 'credentials', - ), - }; - }), -); - -/** - * POST /credentials/test - * - * Test if a credential is valid. - */ -credentialsController.post( - '/test', - ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { - const { credentials, nodeToTestWith } = req.body; - - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.ResponseError( - RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, - undefined, - 500, - ); - } - - const helper = new CredentialsHelper(encryptionKey); - return helper.testCredentials(req.user, credentials.type, credentials, nodeToTestWith); - }), -); - -/** - * POST /credentials - */ -credentialsController.post( - '/', - ResponseHelper.send(async (req: CredentialRequest.Create) => { - delete req.body.id; // delete if sent - - const newCredential = new CredentialsEntity(); - - Object.assign(newCredential, req.body); - - await validateEntity(newCredential); - - // Add the added date for node access permissions - for (const nodeAccess of newCredential.nodesAccess) { - nodeAccess.date = new Date(); - } - - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.ResponseError( - RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, - undefined, - 500, - ); - } - - // Encrypt the data - const coreCredential = createCredentiasFromCredentialsEntity(newCredential, true); - - // @ts-ignore - coreCredential.setData(newCredential.data, encryptionKey); - - const encryptedData = coreCredential.getDataToSave() as ICredentialsDb; - - Object.assign(newCredential, encryptedData); - - await externalHooks.run('credentials.create', [encryptedData]); - - const role = await Db.collections.Role.findOneOrFail({ - name: 'owner', - scope: 'credential', - }); - - const { id, ...rest } = await Db.transaction(async (transactionManager) => { - const savedCredential = await transactionManager.save(newCredential); - - savedCredential.data = newCredential.data; - - const newSharedCredential = new SharedCredentials(); - - Object.assign(newSharedCredential, { - role, - user: req.user, - credentials: savedCredential, - }); - - await transactionManager.save(newSharedCredential); - - return savedCredential; - }); - LoggerProxy.verbose('New credential created', { - credentialId: newCredential.id, - ownerId: req.user.id, - }); - return { id: id.toString(), ...rest }; - }), -); - -/** - * DELETE /credentials/:id - */ -credentialsController.delete( - '/:id', - ResponseHelper.send(async (req: CredentialRequest.Delete) => { - const { id: credentialId } = req.params; - - const shared = await Db.collections.SharedCredentials.findOne({ - relations: ['credentials'], - where: whereClause({ - user: req.user, - entityType: 'credentials', - entityId: credentialId, - }), - }); - - if (!shared) { - LoggerProxy.info('Attempt to delete credential blocked due to lack of permissions', { - credentialId, - userId: req.user.id, - }); - throw new ResponseHelper.ResponseError( - `Credential with ID "${credentialId}" could not be found to be deleted.`, - undefined, - 404, - ); - } - - await externalHooks.run('credentials.delete', [credentialId]); - - await Db.collections.Credentials.remove(shared.credentials); - - return true; - }), -); - -/** - * PATCH /credentials/:id - */ -credentialsController.patch( - '/:id', - ResponseHelper.send(async (req: CredentialRequest.Update): Promise => { - const { id: credentialId } = req.params; - - const updateData = new CredentialsEntity(); - Object.assign(updateData, req.body); - - await validateEntity(updateData); - - const shared = await Db.collections.SharedCredentials.findOne({ - relations: ['credentials'], - where: whereClause({ - user: req.user, - entityType: 'credentials', - entityId: credentialId, - }), - }); - - if (!shared) { - LoggerProxy.info('Attempt to update credential blocked due to lack of permissions', { - credentialId, - userId: req.user.id, - }); - throw new ResponseHelper.ResponseError( - `Credential with ID "${credentialId}" could not be found to be updated.`, - undefined, - 404, - ); - } - - const { credentials: credential } = shared; - - // Add the date for newly added node access permissions - for (const nodeAccess of updateData.nodesAccess) { - if (!nodeAccess.date) { - nodeAccess.date = new Date(); - } - } - - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.ResponseError( - RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, - undefined, - 500, - ); - } - - const coreCredential = createCredentiasFromCredentialsEntity(credential); - - const decryptedData = coreCredential.getData(encryptionKey); - - // Do not overwrite the oauth data else data like the access or refresh token would get lost - // everytime anybody changes anything on the credentials even if it is just the name. - if (decryptedData.oauthTokenData) { - // @ts-ignore - updateData.data.oauthTokenData = decryptedData.oauthTokenData; - } - - // Encrypt the data - const credentials = new Credentials( - { id: credentialId, name: updateData.name }, - updateData.type, - updateData.nodesAccess, - ); - - // @ts-ignore - credentials.setData(updateData.data, encryptionKey); - - const newCredentialData = credentials.getDataToSave() as ICredentialsDb; - - // Add special database related data - newCredentialData.updatedAt = new Date(); - - await externalHooks.run('credentials.update', [newCredentialData]); - - // Update the credentials in DB - await Db.collections.Credentials.update(credentialId, newCredentialData); - - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the updated entry. - const responseData = await Db.collections.Credentials.findOne(credentialId); - - if (responseData === undefined) { - throw new ResponseHelper.ResponseError( - `Credential ID "${credentialId}" could not be found to be updated.`, - undefined, - 404, - ); - } - - // Remove the encrypted data as it is not needed in the frontend - const { id, data, ...rest } = responseData; - - LoggerProxy.verbose('Credential updated', { credentialId }); - - return { - id: id.toString(), - ...rest, - }; - }), -); - -/** - * GET /credentials/:id - */ -credentialsController.get( - '/:id', - ResponseHelper.send(async (req: CredentialRequest.Get) => { - const { id: credentialId } = req.params; - - const shared = await Db.collections.SharedCredentials.findOne({ - relations: ['credentials'], - where: whereClause({ - user: req.user, - entityType: 'credentials', - entityId: credentialId, - }), - }); - - if (!shared) { - throw new ResponseHelper.ResponseError( - `Credentials with ID "${credentialId}" could not be found.`, - undefined, - 404, - ); - } - - const { credentials: credential } = shared; - - if (req.query.includeData !== 'true') { - const { data, id, ...rest } = credential; - - return { - id: id.toString(), - ...rest, - }; - } - - const { data, id, ...rest } = credential; - - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.ResponseError( - RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, - undefined, - 500, - ); - } - - const coreCredential = createCredentiasFromCredentialsEntity(credential); - - return { - id: id.toString(), - data: coreCredential.getData(encryptionKey), - ...rest, - }; - }), -); diff --git a/packages/cli/src/credentials/credentials.controller.ee.ts b/packages/cli/src/credentials/credentials.controller.ee.ts new file mode 100644 index 0000000000..54e7d28491 --- /dev/null +++ b/packages/cli/src/credentials/credentials.controller.ee.ts @@ -0,0 +1,182 @@ +/* eslint-disable import/no-cycle */ +import express from 'express'; +import { INodeCredentialTestResult, LoggerProxy } from 'n8n-workflow'; +import { Db, InternalHooksManager, ResponseHelper } from '..'; +import type { CredentialsEntity } from '../databases/entities/CredentialsEntity'; + +import type { CredentialRequest } from '../requests'; +import { EECredentialsService as EECredentials } from './credentials.service.ee'; +import type { CredentialWithSharings } from './credentials.types'; +import { isCredentialsSharingEnabled, rightDiff } from './helpers'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const EECredentialsController = express.Router(); + +EECredentialsController.use((req, res, next) => { + if (!isCredentialsSharingEnabled()) { + // skip ee router and use free one + next('router'); + return; + } + // use ee router + next(); +}); + +/** + * GET /credentials + */ +EECredentialsController.get( + '/', + ResponseHelper.send(async (req: CredentialRequest.GetAll): Promise => { + try { + const allCredentials = await EECredentials.getAll(req.user, { + relations: ['shared', 'shared.role', 'shared.user'], + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + return allCredentials.map(EECredentials.addOwnerAndSharings); + } catch (error) { + LoggerProxy.error('Request to list credentials failed', error as Error); + throw error; + } + }), +); + +/** + * GET /credentials/:id + */ +EECredentialsController.get( + '/:id', + (req, res, next) => (req.params.id === 'new' ? next('router') : next()), // skip ee router and use free one for naming + ResponseHelper.send(async (req: CredentialRequest.Get) => { + const { id: credentialId } = req.params; + const includeDecryptedData = req.query.includeData === 'true'; + + if (Number.isNaN(Number(credentialId))) { + throw new ResponseHelper.ResponseError(`Credential ID must be a number.`, undefined, 400); + } + + let credential = (await EECredentials.get( + { id: credentialId }, + { relations: ['shared', 'shared.role', 'shared.user'] }, + )) as CredentialsEntity & CredentialWithSharings; + + if (!credential) { + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found.`, + undefined, + 404, + ); + } + + const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id); + + if (!userSharing && req.user.globalRole.name !== 'owner') { + throw new ResponseHelper.ResponseError(`Forbidden.`, undefined, 403); + } + + credential = EECredentials.addOwnerAndSharings(credential); + + // @ts-ignore @TODO_TECH_DEBT: Stringify `id` with entity field transformer + credential.id = credential.id.toString(); + + if (!includeDecryptedData || !userSharing || userSharing.role.name !== 'owner') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, data: _, ...rest } = credential; + + // @TODO_TECH_DEBT: Stringify `id` with entity field transformer + return { id: id.toString(), ...rest }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, data: _, ...rest } = credential; + + const key = await EECredentials.getEncryptionKey(); + const decryptedData = await EECredentials.decrypt(key, credential); + + // @TODO_TECH_DEBT: Stringify `id` with entity field transformer + return { id: id.toString(), data: decryptedData, ...rest }; + }), +); + +/** + * POST /credentials/test + * + * Test if a credential is valid. + */ +EECredentialsController.post( + '/test', + ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { + const { credentials, nodeToTestWith } = req.body; + + const encryptionKey = await EECredentials.getEncryptionKey(); + + const { ownsCredential } = await EECredentials.isOwned(req.user, credentials.id.toString()); + + if (!ownsCredential) { + const sharing = await EECredentials.getSharing(req.user, credentials.id); + if (!sharing) { + throw new ResponseHelper.ResponseError(`Forbidden`, undefined, 403); + } + + const decryptedData = await EECredentials.decrypt(encryptionKey, sharing.credentials); + Object.assign(credentials, { data: decryptedData }); + } + + return EECredentials.test(req.user, encryptionKey, credentials, nodeToTestWith); + }), +); + +/** + * (EE) PUT /credentials/:id/share + * + * Grant or remove users' access to a credential. + */ + +EECredentialsController.put('/:credentialId/share', async (req: CredentialRequest.Share, res) => { + const { credentialId } = req.params; + const { shareWithIds } = req.body; + + if (!Array.isArray(shareWithIds) || !shareWithIds.every((userId) => typeof userId === 'string')) { + return res.status(400).send('Bad Request'); + } + + const { ownsCredential, credential } = await EECredentials.isOwned(req.user, credentialId); + + if (!ownsCredential || !credential) { + return res.status(403).send(); + } + + let amountRemoved: number | null = null; + let newShareeIds: string[] = []; + await Db.transaction(async (trx) => { + // remove all sharings that are not supposed to exist anymore + const { affected } = await EECredentials.pruneSharings(trx, credentialId, [ + req.user.id, + ...shareWithIds, + ]); + if (affected) amountRemoved = affected; + + const sharings = await EECredentials.getSharings(trx, credentialId); + + // extract the new sharings that need to be added + newShareeIds = rightDiff( + [sharings, (sharing) => sharing.userId], + [shareWithIds, (shareeId) => shareeId], + ); + + if (newShareeIds.length) { + await EECredentials.share(trx, credential, newShareeIds); + } + }); + + void InternalHooksManager.getInstance().onUserSharedCredentials({ + credential_type: credential.type, + credential_id: credential.id.toString(), + user_id_sharer: req.user.id, + user_ids_sharees_added: newShareeIds, + sharees_removed: amountRemoved, + }); + + return res.status(200).send(); +}); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts new file mode 100644 index 0000000000..42d4214a36 --- /dev/null +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -0,0 +1,232 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable import/no-cycle */ +import express from 'express'; +import { INodeCredentialTestResult, LoggerProxy } from 'n8n-workflow'; + +import { GenericHelpers, InternalHooksManager, ResponseHelper } from '..'; +import config from '../../config'; +import { getLogger } from '../Logger'; +import { EECredentialsController } from './credentials.controller.ee'; +import { CredentialsService } from './credentials.service'; + +import type { ICredentialsResponse } from '..'; +import type { CredentialRequest } from '../requests'; + +export const credentialsController = express.Router(); + +/** + * Initialize Logger if needed + */ +credentialsController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +credentialsController.use('/', EECredentialsController); + +/** + * GET /credentials + */ +credentialsController.get( + '/', + ResponseHelper.send(async (req: CredentialRequest.GetAll): Promise => { + const credentials = await CredentialsService.getAll(req.user); + + return credentials.map((credential) => { + // eslint-disable-next-line no-param-reassign + credential.id = credential.id.toString(); + return credential as ICredentialsResponse; + }); + }), +); + +/** + * GET /credentials/new + * + * Generate a unique credential name. + */ +credentialsController.get( + '/new', + ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => { + const { name: newName } = req.query; + + return { + name: await GenericHelpers.generateUniqueName( + newName ?? config.getEnv('credentials.defaultName'), + 'credentials', + ), + }; + }), +); + +/** + * GET /credentials/:id + */ +credentialsController.get( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Get) => { + const { id: credentialId } = req.params; + const includeDecryptedData = req.query.includeData === 'true'; + + if (Number.isNaN(Number(credentialId))) { + throw new ResponseHelper.ResponseError(`Credential ID must be a number.`, undefined, 400); + } + + const sharing = await CredentialsService.getSharing(req.user, credentialId, ['credentials']); + + if (!sharing) { + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found.`, + undefined, + 404, + ); + } + + const { credentials: credential } = sharing; + + if (!includeDecryptedData) { + const { id, data: _, ...rest } = credential; + + // @TODO_TECH_DEBT: Stringify `id` with entity field transformer + return { id: id.toString(), ...rest }; + } + + const { id, data: _, ...rest } = credential; + + const key = await CredentialsService.getEncryptionKey(); + const decryptedData = await CredentialsService.decrypt(key, credential); + + // @TODO_TECH_DEBT: Stringify `id` with entity field transformer + return { id: id.toString(), data: decryptedData, ...rest }; + }), +); + +/** + * POST /credentials/test + * + * Test if a credential is valid. + */ +credentialsController.post( + '/test', + ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { + const { credentials, nodeToTestWith } = req.body; + + const encryptionKey = await CredentialsService.getEncryptionKey(); + return CredentialsService.test(req.user, encryptionKey, credentials, nodeToTestWith); + }), +); + +/** + * POST /credentials + */ +credentialsController.post( + '/', + ResponseHelper.send(async (req: CredentialRequest.Create) => { + const newCredential = await CredentialsService.prepareCreateData(req.body); + + const key = await CredentialsService.getEncryptionKey(); + const encryptedData = CredentialsService.createEncryptedData(key, null, newCredential); + const { id, ...rest } = await CredentialsService.save(newCredential, encryptedData, req.user); + + void InternalHooksManager.getInstance().onUserCreatedCredentials({ + credential_type: rest.type, + credential_id: id.toString(), + public_api: false, + }); + + return { id: id.toString(), ...rest }; + }), +); + +/** + * PATCH /credentials/:id + */ +credentialsController.patch( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Update): Promise => { + const { id: credentialId } = req.params; + + const sharing = await CredentialsService.getSharing(req.user, credentialId); + + if (!sharing) { + LoggerProxy.info('Attempt to update credential blocked due to lack of permissions', { + credentialId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + const { credentials: credential } = sharing; + + const key = await CredentialsService.getEncryptionKey(); + const decryptedData = await CredentialsService.decrypt(key, credential); + const preparedCredentialData = await CredentialsService.prepareUpdateData( + req.body, + decryptedData, + ); + const newCredentialData = CredentialsService.createEncryptedData( + key, + credentialId, + preparedCredentialData, + ); + + const responseData = await CredentialsService.update(credentialId, newCredentialData); + + if (responseData === undefined) { + throw new ResponseHelper.ResponseError( + `Credential ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + // Remove the encrypted data as it is not needed in the frontend + const { id, data: _, ...rest } = responseData; + + LoggerProxy.verbose('Credential updated', { credentialId }); + + return { + id: id.toString(), + ...rest, + }; + }), +); + +/** + * DELETE /credentials/:id + */ +credentialsController.delete( + '/:id', + ResponseHelper.send(async (req: CredentialRequest.Delete) => { + const { id: credentialId } = req.params; + + const sharing = await CredentialsService.getSharing(req.user, credentialId); + + if (!sharing) { + LoggerProxy.info('Attempt to delete credential blocked due to lack of permissions', { + credentialId, + userId: req.user.id, + }); + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be deleted.`, + undefined, + 404, + ); + } + + const { credentials: credential } = sharing; + + await CredentialsService.delete(credential); + + return true; + }), +); diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts new file mode 100644 index 0000000000..c636bb308b --- /dev/null +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -0,0 +1,96 @@ +/* eslint-disable import/no-cycle */ +/* eslint-disable no-param-reassign */ +import { DeleteResult, EntityManager, In, Not } from 'typeorm'; +import { Db } from '..'; +import { RoleService } from '../role/role.service'; +import { CredentialsService } from './credentials.service'; + +import { CredentialsEntity } from '../databases/entities/CredentialsEntity'; +import { SharedCredentials } from '../databases/entities/SharedCredentials'; +import { User } from '../databases/entities/User'; +import { UserService } from '../user/user.service'; +import type { CredentialWithSharings } from './credentials.types'; + +export class EECredentialsService extends CredentialsService { + static async isOwned( + user: User, + credentialId: string, + ): Promise<{ ownsCredential: boolean; credential?: CredentialsEntity }> { + const sharing = await this.getSharing(user, credentialId, ['credentials', 'role'], { + allowGlobalOwner: false, + }); + + if (!sharing || sharing.role.name !== 'owner') return { ownsCredential: false }; + + const { credentials: credential } = sharing; + + return { ownsCredential: true, credential }; + } + + static async getSharings( + transaction: EntityManager, + credentialId: string, + ): Promise { + const credential = await transaction.findOne(CredentialsEntity, credentialId, { + relations: ['shared'], + }); + return credential?.shared ?? []; + } + + static async pruneSharings( + transaction: EntityManager, + credentialId: string, + userIds: string[], + ): Promise { + return transaction.delete(SharedCredentials, { + credentials: { id: credentialId }, + user: { id: Not(In(userIds)) }, + }); + } + + static async share( + transaction: EntityManager, + credential: CredentialsEntity, + shareWithIds: string[], + ): Promise { + const [users, role] = await Promise.all([ + UserService.getByIds(transaction, shareWithIds), + RoleService.trxGet(transaction, { scope: 'credential', name: 'user' }), + ]); + + const newSharedCredentials = users + .filter((user) => !user.isPending) + .map((user) => + Db.collections.SharedCredentials.create({ + credentials: credential, + user, + role, + }), + ); + + return transaction.save(newSharedCredentials); + } + + static addOwnerAndSharings( + credential: CredentialsEntity & CredentialWithSharings, + ): CredentialsEntity & CredentialWithSharings { + credential.ownedBy = null; + credential.sharedWith = []; + + credential.shared?.forEach(({ user, role }) => { + const { id, email, firstName, lastName } = user; + + if (role.name === 'owner') { + credential.ownedBy = { id, email, firstName, lastName }; + return; + } + + credential.sharedWith?.push({ id, email, firstName, lastName }); + }); + + // @ts-ignore + delete credential.shared; + + return credential; + } +} diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts new file mode 100644 index 0000000000..5db9f98485 --- /dev/null +++ b/packages/cli/src/credentials/credentials.service.ts @@ -0,0 +1,276 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable import/no-cycle */ +import { Credentials, UserSettings } from 'n8n-core'; +import { + ICredentialDataDecryptedObject, + ICredentialsDecrypted, + INodeCredentialTestResult, + LoggerProxy, +} from 'n8n-workflow'; +import { FindOneOptions, In } from 'typeorm'; + +import { + createCredentialsFromCredentialsEntity, + CredentialsHelper, + Db, + ICredentialsDb, + ResponseHelper, +} from '..'; +import { RESPONSE_ERROR_MESSAGES } from '../constants'; +import { CredentialsEntity } from '../databases/entities/CredentialsEntity'; +import { SharedCredentials } from '../databases/entities/SharedCredentials'; +import { validateEntity } from '../GenericHelpers'; +import { externalHooks } from '../Server'; + +import type { User } from '../databases/entities/User'; +import type { CredentialRequest } from '../requests'; + +export class CredentialsService { + static async get( + credential: Partial, + options?: { relations: string[] }, + ): Promise { + return Db.collections.Credentials.findOne(credential, { + relations: options?.relations, + }); + } + + static async getAll(user: User, options?: { relations: string[] }): Promise { + const SELECT_FIELDS: Array = [ + 'id', + 'name', + 'type', + 'nodesAccess', + 'createdAt', + 'updatedAt', + ]; + + // if instance owner, return all credentials + + if (user.globalRole.name === 'owner') { + return Db.collections.Credentials.find({ + select: SELECT_FIELDS, + relations: options?.relations, + }); + } + + // if member, return credentials owned by or shared with member + + const userSharings = await Db.collections.SharedCredentials.find({ + where: { + user, + }, + }); + + return Db.collections.Credentials.find({ + select: SELECT_FIELDS, + relations: options?.relations, + where: { + id: In(userSharings.map((x) => x.credentialId)), + }, + }); + } + + /** + * Retrieve the sharing that matches a user and a credential. + */ + static async getSharing( + user: User, + credentialId: number | string, + relations: string[] | undefined = ['credentials'], + { allowGlobalOwner } = { allowGlobalOwner: true }, + ): Promise { + const options: FindOneOptions = { + where: { + credentials: { id: credentialId }, + }, + }; + + // Omit user from where if the requesting user is the global + // owner. This allows the global owner to view and delete + // credentials they don't own. + if (!allowGlobalOwner || user.globalRole.name !== 'owner') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + options.where.user = { id: user.id }; + } + + if (relations?.length) { + options.relations = relations; + } + + return Db.collections.SharedCredentials.findOne(options); + } + + static createCredentialsFromCredentialsEntity( + credential: CredentialsEntity, + encrypt = false, + ): Credentials { + const { id, name, type, nodesAccess, data } = credential; + if (encrypt) { + return new Credentials({ id: null, name }, type, nodesAccess); + } + return new Credentials({ id: id.toString(), name }, type, nodesAccess, data); + } + + static async prepareCreateData( + data: CredentialRequest.CredentialProperties, + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...rest } = data; + + // This saves us a merge but requires some type casting. These + // types are compatiable for this case. + const newCredentials = Db.collections.Credentials.create( + rest as ICredentialsDb, + ) as CredentialsEntity; + + await validateEntity(newCredentials); + + // Add the date for newly added node access permissions + for (const nodeAccess of newCredentials.nodesAccess) { + nodeAccess.date = new Date(); + } + + return newCredentials; + } + + static async prepareUpdateData( + data: CredentialRequest.CredentialProperties, + decryptedData: ICredentialDataDecryptedObject, + ): Promise { + // This saves us a merge but requires some type casting. These + // types are compatiable for this case. + const updateData = Db.collections.Credentials.create( + data as ICredentialsDb, + ) as CredentialsEntity; + + await validateEntity(updateData); + + // Add the date for newly added node access permissions + for (const nodeAccess of updateData.nodesAccess) { + if (!nodeAccess.date) { + nodeAccess.date = new Date(); + } + } + + // Do not overwrite the oauth data else data like the access or refresh token would get lost + // everytime anybody changes anything on the credentials even if it is just the name. + if (decryptedData.oauthTokenData) { + // @ts-ignore + updateData.data.oauthTokenData = decryptedData.oauthTokenData; + } + return updateData; + } + + static createEncryptedData( + encryptionKey: string, + credentialsId: string | null, + data: CredentialsEntity, + ): ICredentialsDb { + const credentials = new Credentials( + { id: credentialsId, name: data.name }, + data.type, + data.nodesAccess, + ); + + credentials.setData(data.data as unknown as ICredentialDataDecryptedObject, encryptionKey); + + const newCredentialData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialData.updatedAt = new Date(); + + return newCredentialData; + } + + static async getEncryptionKey(): Promise { + try { + return await UserSettings.getEncryptionKey(); + } catch (error) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + } + + static async decrypt( + encryptionKey: string, + credential: CredentialsEntity, + ): Promise { + const coreCredential = createCredentialsFromCredentialsEntity(credential); + return coreCredential.getData(encryptionKey); + } + + static async update( + credentialId: string, + newCredentialData: ICredentialsDb, + ): Promise { + await externalHooks.run('credentials.update', [newCredentialData]); + + // Update the credentials in DB + await Db.collections.Credentials.update(credentialId, newCredentialData); + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the updated entry. + return Db.collections.Credentials.findOne(credentialId); + } + + static async save( + credential: CredentialsEntity, + encryptedData: ICredentialsDb, + user: User, + ): Promise { + // To avoid side effects + const newCredential = new CredentialsEntity(); + Object.assign(newCredential, credential, encryptedData); + + await externalHooks.run('credentials.create', [encryptedData]); + + const role = await Db.collections.Role.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); + + const result = await Db.transaction(async (transactionManager) => { + const savedCredential = await transactionManager.save(newCredential); + + savedCredential.data = newCredential.data; + + const newSharedCredential = new SharedCredentials(); + + Object.assign(newSharedCredential, { + role, + user, + credentials: savedCredential, + }); + + await transactionManager.save(newSharedCredential); + + return savedCredential; + }); + LoggerProxy.verbose('New credential created', { + credentialId: newCredential.id, + ownerId: user.id, + }); + return result; + } + + static async delete(credentials: CredentialsEntity): Promise { + await externalHooks.run('credentials.delete', [credentials.id]); + + await Db.collections.Credentials.remove(credentials); + } + + static async test( + user: User, + encryptionKey: string, + credentials: ICredentialsDecrypted, + nodeToTestWith: string | undefined, + ): Promise { + const helper = new CredentialsHelper(encryptionKey); + + return helper.testCredentials(user, credentials.type, credentials, nodeToTestWith); + } +} diff --git a/packages/cli/src/credentials/credentials.types.ts b/packages/cli/src/credentials/credentials.types.ts new file mode 100644 index 0000000000..9d1cefab30 --- /dev/null +++ b/packages/cli/src/credentials/credentials.types.ts @@ -0,0 +1,7 @@ +import type { IUser } from 'n8n-workflow'; +import type { ICredentialsDb } from '../Interfaces'; + +export interface CredentialWithSharings extends ICredentialsDb { + ownedBy?: IUser | null; + sharedWith?: IUser[]; +} diff --git a/packages/cli/src/credentials/helpers.ee.ts b/packages/cli/src/credentials/helpers.ee.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli/src/credentials/helpers.ts b/packages/cli/src/credentials/helpers.ts new file mode 100644 index 0000000000..ec04d53d13 --- /dev/null +++ b/packages/cli/src/credentials/helpers.ts @@ -0,0 +1,28 @@ +/* eslint-disable import/no-cycle */ +import config from '../../config'; +import { isUserManagementEnabled } from '../UserManagement/UserManagementHelper'; + +export function isCredentialsSharingEnabled(): boolean { + return isUserManagementEnabled() && config.getEnv('enterprise.features.sharing'); +} + +// return the difference between two arrays +export function rightDiff( + [arr1, keyExtractor1]: [T1[], (item: T1) => string], + [arr2, keyExtractor2]: [T2[], (item: T2) => string], +): T2[] { + // create map { itemKey => true } for fast lookup for diff + const keyMap = arr1.reduce<{ [key: string]: true }>((map, item) => { + // eslint-disable-next-line no-param-reassign + map[keyExtractor1(item)] = true; + return map; + }, {}); + + // diff against map + return arr2.reduce((acc, item) => { + if (!keyMap[keyExtractor2(item)]) { + acc.push(item); + } + return acc; + }, []); +} diff --git a/packages/cli/src/api/oauth2Credential.api.ts b/packages/cli/src/credentials/oauth2Credential.api.ts similarity index 100% rename from packages/cli/src/api/oauth2Credential.api.ts rename to packages/cli/src/credentials/oauth2Credential.api.ts diff --git a/packages/cli/src/databases/entities/Role.ts b/packages/cli/src/databases/entities/Role.ts index 331e274754..f47d65090b 100644 --- a/packages/cli/src/databases/entities/Role.ts +++ b/packages/cli/src/databases/entities/Role.ts @@ -17,6 +17,7 @@ import { User } from './User'; import { SharedWorkflow } from './SharedWorkflow'; import { SharedCredentials } from './SharedCredentials'; +type RoleNames = 'owner' | 'member' | 'user'; type RoleScopes = 'global' | 'workflow' | 'credential'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -42,7 +43,7 @@ export class Role { @Column({ length: 32 }) @IsString({ message: 'Role name must be of type string.' }) @Length(1, 32, { message: 'Role name must be 1 to 32 characters long.' }) - name: string; + name: RoleNames; @Column() scope: RoleScopes; diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index e01e5b2d33..628111b16a 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -15,6 +15,7 @@ import { BeforeInsert, } from 'typeorm'; import { IsEmail, IsString, Length } from 'class-validator'; +import type { IUser } from 'n8n-workflow'; import * as config from '../../../config'; import { DatabaseType, IPersonalizationSurveyAnswers, IUserSettings } from '../..'; import { Role } from './Role'; @@ -59,7 +60,7 @@ function getTimestampSyntax() { } @Entity() -export class User { +export class User implements IUser { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/packages/cli/src/databases/migrations/mysqldb/1660062385367-CreateCredentialsUserRole.ts b/packages/cli/src/databases/migrations/mysqldb/1660062385367-CreateCredentialsUserRole.ts new file mode 100644 index 0000000000..0d19e0c248 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1660062385367-CreateCredentialsUserRole.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config from '../../../../config'; + +export class CreateCredentialsUserRole1660062385367 implements MigrationInterface { + name = 'CreateCredentialsUserRole1660062385367'; + + async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(` + INSERT INTO ${tablePrefix}role (name, scope) + VALUES ("user", "credential") + ON CONFLICT DO NOTHING; + `); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(` + DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='credential'; + `); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 38b05e8600..691009b2e1 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -19,6 +19,7 @@ import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; import { IntroducePinData1654090101303 } from './1654090101303-IntroducePinData'; import { AddNodeIds1658932910559 } from './1658932910559-AddNodeIds'; import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinData'; +import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -42,4 +43,5 @@ export const mysqlMigrations = [ IntroducePinData1654090101303, AddNodeIds1658932910559, AddJsonKeyPinData1659895550980, + CreateCredentialsUserRole1660062385367, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1660062385367-CreateCredentialsUserRole.ts b/packages/cli/src/databases/migrations/postgresdb/1660062385367-CreateCredentialsUserRole.ts new file mode 100644 index 0000000000..57c8e01d6e --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1660062385367-CreateCredentialsUserRole.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config from '../../../../config'; + +export class CreateCredentialsUserRole1660062385367 implements MigrationInterface { + name = 'CreateCredentialsUserRole1660062385367'; + + async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(` + INSERT INTO ${tablePrefix}role (name, scope) + VALUES ('user', 'credential') + ON CONFLICT DO NOTHING; + `); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(` + DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='credential'; + `); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 0665b1829c..491393ea9c 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -17,6 +17,7 @@ import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; import { IntroducePinData1654090467022 } from './1654090467022-IntroducePinData'; import { AddNodeIds1658932090381 } from './1658932090381-AddNodeIds'; import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinData'; +import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -36,6 +37,7 @@ export const postgresMigrations = [ CommunityNodes1652254514002, AddAPIKeyColumn1652905585850, IntroducePinData1654090467022, + CreateCredentialsUserRole1660062385367, AddNodeIds1658932090381, AddJsonKeyPinData1659902242948, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1648740597343-LowerCaseUserEmail.ts b/packages/cli/src/databases/migrations/sqlite/1648740597343-LowerCaseUserEmail.ts index 2eb7898750..91c7427477 100644 --- a/packages/cli/src/databases/migrations/sqlite/1648740597343-LowerCaseUserEmail.ts +++ b/packages/cli/src/databases/migrations/sqlite/1648740597343-LowerCaseUserEmail.ts @@ -1,5 +1,5 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -import config = require('../../../../config'); +import * as config from '../../../../config'; import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; export class LowerCaseUserEmail1648740597343 implements MigrationInterface { @@ -8,7 +8,7 @@ export class LowerCaseUserEmail1648740597343 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { logMigrationStart(this.name); - const tablePrefix = config.get('database.tablePrefix'); + const tablePrefix = config.getEnv('database.tablePrefix'); await queryRunner.query(` UPDATE "${tablePrefix}user" diff --git a/packages/cli/src/databases/migrations/sqlite/1660062385367-CreateCredentialsUserRole.ts b/packages/cli/src/databases/migrations/sqlite/1660062385367-CreateCredentialsUserRole.ts new file mode 100644 index 0000000000..2e87d54680 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1660062385367-CreateCredentialsUserRole.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; + +export class CreateCredentialsUserRole1660062385367 implements MigrationInterface { + name = 'CreateCredentialsUserRole1660062385367'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}role" (name, scope) + VALUES ("user", "credential") + ON CONFLICT DO NOTHING; + `); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query(` + DELETE FROM "${tablePrefix}role" WHERE name='user' AND scope='credential'; + `); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index f51da758ff..4e14548d19 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -16,6 +16,7 @@ import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; import { IntroducePinData1654089251344 } from './1654089251344-IntroducePinData'; import { AddNodeIds1658930531669 } from './1658930531669-AddNodeIds'; import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData'; +import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -36,6 +37,7 @@ const sqliteMigrations = [ IntroducePinData1654089251344, AddNodeIds1658930531669, AddJsonKeyPinData1659888469333, + CreateCredentialsUserRole1660062385367, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index e6e615d27e..05f5f682a0 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -105,6 +105,8 @@ export declare namespace CredentialRequest { type NewName = WorkflowRequest.NewName; type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; + + type Share = AuthenticatedRequest<{ credentialId: string }, {}, { shareWithIds: string[] }>; } // ---------------------------------- diff --git a/packages/cli/src/role/role.service.ts b/packages/cli/src/role/role.service.ts new file mode 100644 index 0000000000..66e3ff8b74 --- /dev/null +++ b/packages/cli/src/role/role.service.ts @@ -0,0 +1,14 @@ +/* eslint-disable import/no-cycle */ +import { EntityManager } from 'typeorm'; +import { Db } from '..'; +import { Role } from '../databases/entities/Role'; + +export class RoleService { + static async get(role: Partial): Promise { + return Db.collections.Role.findOne(role); + } + + static async trxGet(transaction: EntityManager, role: Partial) { + return transaction.findOne(Role, role); + } +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index f5e750a4bd..6ce3e3d54a 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -142,16 +142,6 @@ export class Telemetry { [key: string]: string | number | boolean | object | undefined | null; }): Promise { return new Promise((resolve) => { - if (this.postHog) { - this.postHog.identify({ - distinctId: this.instanceId, - properties: { - ...traits, - instanceId: this.instanceId, - }, - }); - } - if (this.rudderStack) { this.rudderStack.identify( { @@ -192,13 +182,19 @@ export class Telemetry { }; if (withPostHog && this.postHog) { - this.postHog.capture({ ...payload, distinctId: payload.userId }); + return Promise.all([ + this.postHog.capture({ + distinctId: payload.userId, + ...payload, + }), + this.rudderStack.track(payload), + ]).then(() => resolve()); } - this.rudderStack.track(payload, resolve); - } else { - resolve(); + return this.rudderStack.track(payload, resolve); } + + return resolve(); }); } @@ -208,7 +204,7 @@ export class Telemetry { ): Promise { if (!this.postHog) return Promise.resolve(false); - const fullId = [this.instanceId, userId].join('_'); // PostHog disallows # in ID + const fullId = [this.instanceId, userId].join('#'); return this.postHog.isFeatureEnabled(featureFlagName, fullId); } diff --git a/packages/cli/src/user/user.service.ts b/packages/cli/src/user/user.service.ts new file mode 100644 index 0000000000..dd9008ae6b --- /dev/null +++ b/packages/cli/src/user/user.service.ts @@ -0,0 +1,14 @@ +/* eslint-disable import/no-cycle */ +import { EntityManager, In } from 'typeorm'; +import { Db } from '..'; +import { User } from '../databases/entities/User'; + +export class UserService { + static async get(user: Partial): Promise { + return Db.collections.User.findOne(user); + } + + static async getByIds(transaction: EntityManager, ids: string[]) { + return transaction.find(User, { id: In(ids) }); + } +} diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 9ae08f60c0..fc266b06d3 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -1,14 +1,14 @@ import express = require('express'); import validator from 'validator'; - -import config = require('../../config'); -import * as utils from './shared/utils'; -import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; +import config from '../../config'; import { Db } from '../../src'; +import { AUTH_COOKIE_NAME } from '../../src/constants'; import type { Role } from '../../src/databases/entities/Role'; +import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; import { randomValidPassword } from './shared/random'; import * as testDb from './shared/testDb'; -import { AUTH_COOKIE_NAME } from '../../src/constants'; +import type { AuthAgent } from './shared/types'; +import * as utils from './shared/utils'; jest.mock('../../src/telemetry'); @@ -16,6 +16,7 @@ let app: express.Application; let testDbName = ''; let globalOwnerRole: Role; let globalMemberRole: Role; +let authAgent: AuthAgent; beforeAll(async () => { app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true }); @@ -24,6 +25,9 @@ beforeAll(async () => { globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); + + authAgent = utils.createAuthAgent(app); + utils.initTestLogger(); utils.initTestTelemetry(); }); @@ -109,9 +113,7 @@ test('GET /login should return cookie if UM is disabled', async () => { { value: JSON.stringify(false) }, ); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - - const response = await authOwnerShellAgent.get('/login'); + const response = await authAgent(ownerShell).get('/login'); expect(response.statusCode).toBe(200); @@ -133,9 +135,8 @@ test('GET /login should return 401 Unauthorized if invalid cookie', async () => test('GET /login should return logged-in owner shell', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authMemberAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authMemberAgent.get('/login'); + const response = await authAgent(ownerShell).get('/login'); expect(response.statusCode).toBe(200); @@ -170,9 +171,8 @@ test('GET /login should return logged-in owner shell', async () => { test('GET /login should return logged-in member shell', async () => { const memberShell = await testDb.createUserShell(globalMemberRole); - const authMemberAgent = utils.createAgent(app, { auth: true, user: memberShell }); - const response = await authMemberAgent.get('/login'); + const response = await authAgent(memberShell).get('/login'); expect(response.statusCode).toBe(200); @@ -207,9 +207,8 @@ test('GET /login should return logged-in member shell', async () => { test('GET /login should return logged-in owner', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); - const response = await authOwnerAgent.get('/login'); + const response = await authAgent(owner).get('/login'); expect(response.statusCode).toBe(200); @@ -244,9 +243,8 @@ test('GET /login should return logged-in owner', async () => { test('GET /login should return logged-in member', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); - const response = await authMemberAgent.get('/login'); + const response = await authAgent(member).get('/login'); expect(response.statusCode).toBe(200); @@ -281,9 +279,8 @@ test('GET /login should return logged-in member', async () => { test('POST /logout should log user out', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); - const response = await authOwnerAgent.post('/logout'); + const response = await authAgent(owner).post('/logout'); expect(response.statusCode).toBe(200); expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index 6652c23b26..5dd130f1b6 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -1,20 +1,22 @@ import express from 'express'; import request from 'supertest'; +import type { Role } from '../../src/databases/entities/Role'; import { REST_PATH_SEGMENT, - ROUTES_REQUIRING_AUTHORIZATION, ROUTES_REQUIRING_AUTHENTICATION, + ROUTES_REQUIRING_AUTHORIZATION, } from './shared/constants'; -import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; -import type { Role } from '../../src/databases/entities/Role'; +import type { AuthAgent } from './shared/types'; +import * as utils from './shared/utils'; jest.mock('../../src/telemetry'); let app: express.Application; let testDbName = ''; let globalMemberRole: Role; +let authAgent: AuthAgent; beforeAll(async () => { app = await utils.initTestServer({ @@ -26,6 +28,8 @@ beforeAll(async () => { globalMemberRole = await testDb.getGlobalMemberRole(); + authAgent = utils.createAuthAgent(app); + utils.initTestLogger(); utils.initTestTelemetry(); }); @@ -49,8 +53,7 @@ ROUTES_REQUIRING_AUTHORIZATION.forEach(async (route) => { test(`${route} should return 403 Forbidden for member`, async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); - const response = await authMemberAgent[method](endpoint); + const response = await authAgent(member)[method](endpoint); expect(response.statusCode).toBe(403); }); diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts new file mode 100644 index 0000000000..44e8567014 --- /dev/null +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -0,0 +1,551 @@ +import express from 'express'; +import { UserSettings } from 'n8n-core'; +import { In } from 'typeorm'; + +import { Db, IUser } from '../../src'; +import { RESPONSE_ERROR_MESSAGES } from '../../src/constants'; +import type { CredentialWithSharings } from '../../src/credentials/credentials.types'; +import * as CredentialHelpers from '../../src/credentials/helpers'; +import type { Role } from '../../src/databases/entities/Role'; +import { randomCredentialPayload } from './shared/random'; +import * as testDb from './shared/testDb'; +import type { AuthAgent, SaveCredentialFunction } from './shared/types'; +import * as utils from './shared/utils'; + +jest.mock('../../src/telemetry'); + +// mock whether credentialsSharing is enabled or not +const mockIsCredentialsSharingEnabled = jest.spyOn( + CredentialHelpers, + 'isCredentialsSharingEnabled', +); +mockIsCredentialsSharingEnabled.mockReturnValue(true); + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let credentialOwnerRole: Role; +let saveCredential: SaveCredentialFunction; +let authAgent: AuthAgent; + +beforeAll(async () => { + app = await utils.initTestServer({ + endpointGroups: ['credentials'], + applyAuth: true, + }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initConfigFile(); + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + globalMemberRole = await testDb.getGlobalMemberRole(); + credentialOwnerRole = await testDb.getCredentialOwnerRole(); + + saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); + authAgent = utils.createAuthAgent(app); + + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.truncate(['User', 'SharedCredentials', 'Credentials'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +// ---------------------------------------- +// dynamic router switching +// ---------------------------------------- + +test('router should switch based on flag', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + // free router + mockIsCredentialsSharingEnabled.mockReturnValueOnce(false); + + const freeShareResponse = authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [member.id] }); + + const freeGetResponse = authAgent(owner).get(`/credentials/${savedCredential.id}`).send(); + + const [{ statusCode: freeShareStatus }, { statusCode: freeGetStatus }] = await Promise.all([ + freeShareResponse, + freeGetResponse, + ]); + + expect(freeShareStatus).toBe(404); + expect(freeGetStatus).toBe(200); + + // EE router + mockIsCredentialsSharingEnabled.mockReturnValueOnce(true); + + const eeShareResponse = authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [member.id] }); + + const eeGetResponse = authAgent(owner).get(`/credentials/${savedCredential.id}`).send(); + + const [{ statusCode: eeShareStatus }, { statusCode: eeGetStatus }] = await Promise.all([ + eeShareResponse, + eeGetResponse, + ]); + + expect(eeShareStatus).toBe(200); + expect(eeGetStatus).toBe(200); +}); + +// ---------------------------------------- +// GET /credentials - fetch all credentials +// ---------------------------------------- + +test('GET /credentials should return all creds for owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const [member1, member2, member3] = await testDb.createManyUsers(3, { + globalRole: globalMemberRole, + }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + await saveCredential(randomCredentialPayload(), { user: member1 }); + + const sharedWith = [member1, member2, member3]; + await testDb.shareCredentialWithUsers(savedCredential, sharedWith); + + const response = await authAgent(owner).get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); // owner retrieved owner cred and member cred + + const [ownerCredential, memberCredential] = response.body.data; + + validateMainCredentialData(ownerCredential); + expect(ownerCredential.data).toBeUndefined(); + + validateMainCredentialData(memberCredential); + expect(memberCredential.data).toBeUndefined(); + + expect(ownerCredential.ownedBy).toMatchObject({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, + }); + + expect(Array.isArray(ownerCredential.sharedWith)).toBe(true); + expect(ownerCredential.sharedWith.length).toBe(3); + + ownerCredential.sharedWith.forEach((sharee: IUser, idx: number) => { + expect(sharee).toMatchObject({ + id: sharedWith[idx].id, + email: sharedWith[idx].email, + firstName: sharedWith[idx].firstName, + lastName: sharedWith[idx].lastName, + }); + }); + + expect(memberCredential.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); + + expect(Array.isArray(memberCredential.sharedWith)).toBe(true); + expect(memberCredential.sharedWith.length).toBe(0); +}); + +test('GET /credentials should return only relevant creds for member', async () => { + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); + + await saveCredential(randomCredentialPayload(), { user: member2 }); + const savedMemberCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + + await testDb.shareCredentialWithUsers(savedMemberCredential, [member2]); + + const response = await authAgent(member1).get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); // member retrieved only member cred + + const [member1Credential] = response.body.data; + + validateMainCredentialData(member1Credential); + expect(member1Credential.data).toBeUndefined(); + + expect(member1Credential.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); + + expect(Array.isArray(member1Credential.sharedWith)).toBe(true); + expect(member1Credential.sharedWith.length).toBe(1); + + const [sharee] = member1Credential.sharedWith; + + expect(sharee).toMatchObject({ + id: member2.id, + email: member2.email, + firstName: member2.firstName, + lastName: member2.lastName, + }); +}); + +// ---------------------------------------- +// GET /credentials/:id - fetch a certain credential +// ---------------------------------------- + +test('GET /credentials/:id should retrieve owned cred for owner', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerAgent = authAgent(ownerShell); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); + + const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + const { data: firstCredential } = firstResponse.body; + validateMainCredentialData(firstCredential); + expect(firstCredential.data).toBeUndefined(); + expect(firstCredential.ownedBy).toMatchObject({ + id: ownerShell.id, + email: ownerShell.email, + firstName: ownerShell.firstName, + lastName: ownerShell.lastName, + }); + expect(firstCredential.sharedWith.length).toBe(0); + + const secondResponse = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + + const { data: secondCredential } = secondResponse.body; + validateMainCredentialData(secondCredential); + expect(secondCredential.data).toBeDefined(); +}); + +test('GET /credentials/:id should retrieve non-owned cred for owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = authAgent(owner); + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + await testDb.shareCredentialWithUsers(savedCredential, [member2]); + + const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + + expect(response1.statusCode).toBe(200); + + validateMainCredentialData(response1.body.data); + expect(response1.body.data.data).toBeUndefined(); + expect(response1.body.data.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); + expect(response1.body.data.sharedWith.length).toBe(1); + expect(response1.body.data.sharedWith[0]).toMatchObject({ + id: member2.id, + email: member2.email, + firstName: member2.firstName, + lastName: member2.lastName, + }); + + const response2 = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response2.statusCode).toBe(200); + + validateMainCredentialData(response2.body.data); + expect(response2.body.data.data).toBeUndefined(); + expect(response2.body.data.sharedWith.length).toBe(1); +}); + +test('GET /credentials/:id should retrieve owned cred for member', async () => { + const [member1, member2, member3] = await testDb.createManyUsers(3, { + globalRole: globalMemberRole, + }); + const authMemberAgent = authAgent(member1); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + await testDb.shareCredentialWithUsers(savedCredential, [member2, member3]); + + const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + const { data: firstCredential } = firstResponse.body; + validateMainCredentialData(firstCredential); + expect(firstCredential.data).toBeUndefined(); + expect(firstCredential.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); + expect(firstCredential.sharedWith.length).toBe(2); + firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => { + expect([member2.id, member3.id]).toContain(sharee.id); + }); + + const secondResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + + const { data: secondCredential } = secondResponse.body; + validateMainCredentialData(secondCredential); + expect(secondCredential.data).toBeDefined(); + expect(firstCredential.sharedWith.length).toBe(2); +}); + +test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); + + const response = await authAgent(member).get(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(403); + expect(response.body.data).toBeUndefined(); // owner's cred not returned +}); + +test('GET /credentials/:id should fail with missing encryption key', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); + + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); + + const response = await authAgent(ownerShell) + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('GET /credentials/:id should return 404 if cred not found', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + + const response = await authAgent(ownerShell).get('/credentials/789'); + expect(response.statusCode).toBe(404); + + const responseAbc = await authAgent(ownerShell).get('/credentials/abc'); + expect(responseAbc.statusCode).toBe(400); + + // because EE router has precedence, check if forwards this route + const responseNew = await authAgent(ownerShell).get('/credentials/new'); + expect(responseNew.statusCode).toBe(200); +}); + +// ---------------------------------------- +// indempotent share/unshare +// ---------------------------------------- + +test('PUT /credentials/:id/share should share the credential with the provided userIds and unshare it for missing ones', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const [member1, member2, member3, member4, member5] = await testDb.createManyUsers(5, { + globalRole: globalMemberRole, + }); + const shareWithIds = [member1.id, member2.id, member3.id]; + + await testDb.shareCredentialWithUsers(savedCredential, [member4, member5]); + + const response = await authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const sharedCredentials = await Db.collections.SharedCredentials.find({ + relations: ['role'], + where: { credentials: savedCredential }, + }); + + // check that sharings have been removed/added correctly + expect(sharedCredentials.length).toBe(shareWithIds.length + 1); // +1 for the owner + + sharedCredentials.forEach((sharedCredential) => { + if (sharedCredential.userId === owner.id) { + expect(sharedCredential.role.name).toBe('owner'); + expect(sharedCredential.role.scope).toBe('credential'); + return; + } + expect(shareWithIds).toContain(sharedCredential.userId); + expect(sharedCredential.role.name).toBe('user'); + expect(sharedCredential.role.scope).toBe('credential'); + }); +}); + +// ---------------------------------------- +// share +// ---------------------------------------- + +test('PUT /credentials/:id/share should share the credential with the provided userIds', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const [member1, member2, member3] = await testDb.createManyUsers(3, { + globalRole: globalMemberRole, + }); + const memberIds = [member1.id, member2.id, member3.id]; + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const response = await authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: memberIds }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + // check that sharings got correctly set in DB + const sharedCredentials = await Db.collections.SharedCredentials.find({ + relations: ['role'], + where: { credentials: savedCredential, user: { id: In([...memberIds]) } }, + }); + + expect(sharedCredentials.length).toBe(memberIds.length); + + sharedCredentials.forEach((sharedCredential) => { + expect(sharedCredential.role.name).toBe('user'); + expect(sharedCredential.role.scope).toBe('credential'); + }); + + // check that owner still exists + const ownerSharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['role'], + where: { credentials: savedCredential, user: owner }, + }); + + expect(ownerSharedCredential.role.name).toBe('owner'); + expect(ownerSharedCredential.role.scope).toBe('credential'); +}); + +test('PUT /credentials/:id/share should respond 403 for non-existing credentials', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const response = await authAgent(owner) + .put(`/credentials/1234567/share`) + .send({ shareWithIds: [member.id] }); + + expect(response.statusCode).toBe(403); +}); + +test('PUT /credentials/:id/share should respond 403 for non-owned credentials', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const response = await authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [member.id] }); + + expect(response.statusCode).toBe(403); +}); + +test('PUT /credentials/:id/share should ignore pending sharee', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const memberShell = await testDb.createUserShell(globalMemberRole); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const response = await authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [memberShell.id] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Db.collections.SharedCredentials.find({ + where: { credentials: savedCredential }, + }); + + expect(sharedCredentials.length).toBe(1); + expect(sharedCredentials[0].userId).toBe(owner.id); +}); + +test('PUT /credentials/:id/share should ignore non-existing sharee', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const response = await authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: ['bce38a11-5e45-4d1c-a9ee-36e4a20ab0fc'] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Db.collections.SharedCredentials.find({ + where: { credentials: savedCredential }, + }); + + expect(sharedCredentials.length).toBe(1); + expect(sharedCredentials[0].userId).toBe(owner.id); +}); + +test('PUT /credentials/:id/share should respond 400 if invalid payload is provided', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const responses = await Promise.all([ + authAgent(owner).put(`/credentials/${savedCredential.id}/share`).send(), + authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [1] }), + ]); + + responses.forEach((response) => expect(response.statusCode).toBe(400)); +}); + +// ---------------------------------------- +// unshare +// ---------------------------------------- + +test('PUT /credentials/:id/share should unshare the credential', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); + + await testDb.shareCredentialWithUsers(savedCredential, [member1, member2]); + + const response = await authAgent(owner) + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Db.collections.SharedCredentials.find({ + where: { credentials: savedCredential }, + }); + + expect(sharedCredentials.length).toBe(1); + expect(sharedCredentials[0].userId).toBe(owner.id); +}); + +function validateMainCredentialData(credential: CredentialWithSharings) { + expect(typeof credential.name).toBe('string'); + expect(typeof credential.type).toBe('string'); + expect(typeof credential.nodesAccess[0].nodeType).toBe('string'); + expect(credential.ownedBy).toBeDefined(); + expect(Array.isArray(credential.sharedWith)).toBe(true); +} diff --git a/packages/cli/test/integration/credentials.api.test.ts b/packages/cli/test/integration/credentials.test.ts similarity index 67% rename from packages/cli/test/integration/credentials.api.test.ts rename to packages/cli/test/integration/credentials.test.ts index 5e2297e670..aa1db81da2 100644 --- a/packages/cli/test/integration/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -1,22 +1,34 @@ import express from 'express'; import { UserSettings } from 'n8n-core'; + import { Db } from '../../src'; -import { randomCredentialPayload, randomName, randomString } from './shared/random'; -import * as utils from './shared/utils'; -import type { CredentialPayload, SaveCredentialFunction } from './shared/types'; -import type { Role } from '../../src/databases/entities/Role'; -import type { User } from '../../src/databases/entities/User'; -import * as testDb from './shared/testDb'; import { RESPONSE_ERROR_MESSAGES } from '../../src/constants'; -import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; +import * as CredentialHelpers from '../../src/credentials/helpers'; +import type { Role } from '../../src/databases/entities/Role'; +import { randomCredentialPayload, randomName, randomString } from './shared/random'; +import * as testDb from './shared/testDb'; +import type { SaveCredentialFunction } from './shared/types'; +import * as utils from './shared/utils'; + +import config from '../../config'; +import type { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; +import type { AuthAgent } from './shared/types'; jest.mock('../../src/telemetry'); +// mock that credentialsSharing is not enabled +const mockIsCredentialsSharingEnabled = jest.spyOn( + CredentialHelpers, + 'isCredentialsSharingEnabled', +); +mockIsCredentialsSharingEnabled.mockReturnValue(false); + let app: express.Application; let testDbName = ''; let globalOwnerRole: Role; let globalMemberRole: Role; let saveCredential: SaveCredentialFunction; +let authAgent: AuthAgent; beforeAll(async () => { app = await utils.initTestServer({ @@ -31,7 +43,10 @@ beforeAll(async () => { globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); const credentialOwnerRole = await testDb.getCredentialOwnerRole(); - saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + + saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); + + authAgent = utils.createAuthAgent(app); utils.initTestLogger(); utils.initTestTelemetry(); @@ -45,13 +60,62 @@ afterAll(async () => { await testDb.terminate(testDbName); }); +// ---------------------------------------- +// GET /credentials - fetch all credentials +// ---------------------------------------- + +test('GET /credentials should return all creds for owner', async () => { + const [owner, member] = await Promise.all([ + testDb.createUser({ globalRole: globalOwnerRole }), + testDb.createUser({ globalRole: globalMemberRole }), + ]); + + const [{ id: savedOwnerCredentialId }, { id: savedMemberCredentialId }] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: owner }), + saveCredential(randomCredentialPayload(), { user: member }), + ]); + + const response = await authAgent(owner).get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); // owner retrieved owner cred and member cred + + const savedCredentialsIds = [savedOwnerCredentialId, savedMemberCredentialId]; + response.body.data.forEach((credential: CredentialsEntity) => { + validateMainCredentialData(credential); + expect(credential.data).toBeUndefined(); + expect(savedCredentialsIds.includes(Number(credential.id))).toBe(true); + }); +}); + +test('GET /credentials should return only own creds for member', async () => { + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); + + const [savedCredential1] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: member1 }), + saveCredential(randomCredentialPayload(), { user: member2 }), + ]); + + const response = await authAgent(member1).get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); // member retrieved only own cred + + const [member1Credential] = response.body.data; + + validateMainCredentialData(member1Credential); + expect(member1Credential.data).toBeUndefined(); + expect(member1Credential.id).toBe(savedCredential1.id.toString()); +}); + test('POST /credentials should create cred', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const payload = randomCredentialPayload(); - const response = await authOwnerAgent.post('/credentials').send(payload); + const response = await authAgent(ownerShell).post('/credentials').send(payload); expect(response.statusCode).toBe(200); @@ -59,6 +123,9 @@ test('POST /credentials should create cred', async () => { expect(name).toBe(payload.name); expect(type).toBe(payload.type); + if (!payload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } expect(nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); expect(encryptedData).not.toBe(payload.data); @@ -66,7 +133,7 @@ test('POST /credentials should create cred', async () => { expect(credential.name).toBe(payload.name); expect(credential.type).toBe(payload.type); - expect(credential.nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(credential.nodesAccess[0].nodeType).toBe(payload.nodesAccess![0].nodeType); expect(credential.data).not.toBe(payload.data); const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ @@ -80,7 +147,7 @@ test('POST /credentials should create cred', async () => { test('POST /credentials should fail with invalid inputs', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerAgent = authAgent(ownerShell); await Promise.all( INVALID_PAYLOADS.map(async (invalidPayload) => { @@ -95,9 +162,8 @@ test('POST /credentials should fail with missing encryption key', async () => { mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); + const response = await authAgent(ownerShell).post('/credentials').send(randomCredentialPayload()); expect(response.statusCode).toBe(500); @@ -106,7 +172,7 @@ test('POST /credentials should fail with missing encryption key', async () => { test('POST /credentials should ignore ID in payload', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerAgent = authAgent(ownerShell); const firstResponse = await authOwnerAgent .post('/credentials') @@ -123,10 +189,9 @@ test('POST /credentials should ignore ID in payload', async () => { test('DELETE /credentials/:id should delete owned cred for owner', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + const response = await authAgent(ownerShell).delete(`/credentials/${savedCredential.id}`); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ data: true }); @@ -142,11 +207,10 @@ test('DELETE /credentials/:id should delete owned cred for owner', async () => { test('DELETE /credentials/:id should delete non-owned cred for owner', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const member = await testDb.createUser({ globalRole: globalMemberRole }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + const response = await authAgent(ownerShell).delete(`/credentials/${savedCredential.id}`); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ data: true }); @@ -162,10 +226,9 @@ test('DELETE /credentials/:id should delete non-owned cred for owner', async () test('DELETE /credentials/:id should delete owned cred for member', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + const response = await authAgent(member).delete(`/credentials/${savedCredential.id}`); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ data: true }); @@ -182,10 +245,9 @@ test('DELETE /credentials/:id should delete owned cred for member', async () => test('DELETE /credentials/:id should not delete non-owned cred for member', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + const response = await authAgent(member).delete(`/credentials/${savedCredential.id}`); expect(response.statusCode).toBe(404); @@ -200,20 +262,18 @@ test('DELETE /credentials/:id should not delete non-owned cred for member', asyn test('DELETE /credentials/:id should fail if cred not found', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerAgent.delete('/credentials/123'); + const response = await authAgent(ownerShell).delete('/credentials/123'); expect(response.statusCode).toBe(404); }); test('PATCH /credentials/:id should update owned cred for owner', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); const patchPayload = randomCredentialPayload(); - const response = await authOwnerAgent + const response = await authAgent(ownerShell) .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); @@ -223,14 +283,18 @@ test('PATCH /credentials/:id should update owned cred for owner', async () => { expect(name).toBe(patchPayload.name); expect(type).toBe(patchPayload.type); + if (!patchPayload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); const credential = await Db.collections.Credentials.findOneOrFail(id); expect(credential.name).toBe(patchPayload.name); expect(credential.type).toBe(patchPayload.type); - expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); expect(credential.data).not.toBe(patchPayload.data); const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ @@ -243,12 +307,11 @@ test('PATCH /credentials/:id should update owned cred for owner', async () => { test('PATCH /credentials/:id should update non-owned cred for owner', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const member = await testDb.createUser({ globalRole: globalMemberRole }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const patchPayload = randomCredentialPayload(); - const response = await authOwnerAgent + const response = await authAgent(ownerShell) .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); @@ -258,14 +321,19 @@ test('PATCH /credentials/:id should update non-owned cred for owner', async () = expect(name).toBe(patchPayload.name); expect(type).toBe(patchPayload.type); + + if (!patchPayload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); const credential = await Db.collections.Credentials.findOneOrFail(id); expect(credential.name).toBe(patchPayload.name); expect(credential.type).toBe(patchPayload.type); - expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); expect(credential.data).not.toBe(patchPayload.data); const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ @@ -278,11 +346,10 @@ test('PATCH /credentials/:id should update non-owned cred for owner', async () = test('PATCH /credentials/:id should update owned cred for member', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const patchPayload = randomCredentialPayload(); - const response = await authMemberAgent + const response = await authAgent(member) .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); @@ -292,14 +359,19 @@ test('PATCH /credentials/:id should update owned cred for member', async () => { expect(name).toBe(patchPayload.name); expect(type).toBe(patchPayload.type); + + if (!patchPayload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); const credential = await Db.collections.Credentials.findOneOrFail(id); expect(credential.name).toBe(patchPayload.name); expect(credential.type).toBe(patchPayload.type); - expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); expect(credential.data).not.toBe(patchPayload.data); const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ @@ -313,11 +385,10 @@ test('PATCH /credentials/:id should update owned cred for member', async () => { test('PATCH /credentials/:id should not update non-owned cred for member', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); const patchPayload = randomCredentialPayload(); - const response = await authMemberAgent + const response = await authAgent(member) .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); @@ -330,7 +401,7 @@ test('PATCH /credentials/:id should not update non-owned cred for member', async test('PATCH /credentials/:id should fail with invalid inputs', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerAgent = authAgent(ownerShell); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); await Promise.all( @@ -339,6 +410,9 @@ test('PATCH /credentials/:id should fail with invalid inputs', async () => { .patch(`/credentials/${savedCredential.id}`) .send(invalidPayload); + if (response.statusCode === 500) { + console.log(response.statusCode, response.body); + } expect(response.statusCode).toBe(400); }), ); @@ -346,9 +420,10 @@ test('PATCH /credentials/:id should fail with invalid inputs', async () => { test('PATCH /credentials/:id should fail if cred not found', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerAgent.patch('/credentials/123').send(randomCredentialPayload()); + const response = await authAgent(ownerShell) + .patch('/credentials/123') + .send(randomCredentialPayload()); expect(response.statusCode).toBe(404); }); @@ -358,121 +433,84 @@ test('PATCH /credentials/:id should fail with missing encryption key', async () mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); + const response = await authAgent(ownerShell).post('/credentials').send(randomCredentialPayload()); expect(response.statusCode).toBe(500); mock.mockRestore(); }); -test('GET /credentials should retrieve all creds for owner', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); +test('GET /credentials/new should return default name for new credential or its increment', async () => { + const ownerShell = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = authAgent(ownerShell); + const name = config.getEnv('credentials.defaultName'); + let tempName = name; - for (let i = 0; i < 3; i++) { - await saveCredential(randomCredentialPayload(), { user: ownerShell }); + for (let i = 0; i < 4; i++) { + const response = await authOwnerAgent.get(`/credentials/new?name=${name}`); + + expect(response.statusCode).toBe(200); + if (i === 0) { + expect(response.body.data.name).toBe(name); + } else { + tempName = name + ' ' + (i + 1); + expect(response.body.data.name).toBe(tempName); + } + await saveCredential({ ...randomCredentialPayload(), name: tempName }, { user: ownerShell }); } - - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - await saveCredential(randomCredentialPayload(), { user: member }); - - const response = await authOwnerAgent.get('/credentials'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(4); // 3 owner + 1 member - - await Promise.all( - response.body.data.map(async (credential: CredentialsEntity) => { - const { name, type, nodesAccess, data: encryptedData } = credential; - - expect(typeof name).toBe('string'); - expect(typeof type).toBe('string'); - expect(typeof nodesAccess[0].nodeType).toBe('string'); - expect(encryptedData).toBeUndefined(); - }), - ); }); -test('GET /credentials should retrieve owned creds for member', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); +test('GET /credentials/new should return name from query for new credential or its increment', async () => { + const ownerShell = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = authAgent(ownerShell); + const name = 'special credential name'; + let tempName = name; - for (let i = 0; i < 3; i++) { - await saveCredential(randomCredentialPayload(), { user: member }); + for (let i = 0; i < 4; i++) { + const response = await authOwnerAgent.get(`/credentials/new?name=${name}`); + + expect(response.statusCode).toBe(200); + if (i === 0) { + expect(response.body.data.name).toBe(name); + } else { + tempName = name + ' ' + (i + 1); + expect(response.body.data.name).toBe(tempName); + } + await saveCredential({ ...randomCredentialPayload(), name: tempName }, { user: ownerShell }); } - - const response = await authMemberAgent.get('/credentials'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(3); - - await Promise.all( - response.body.data.map(async (credential: CredentialsEntity) => { - const { name, type, nodesAccess, data: encryptedData } = credential; - - expect(typeof name).toBe('string'); - expect(typeof type).toBe('string'); - expect(typeof nodesAccess[0].nodeType).toBe('string'); - expect(encryptedData).toBeUndefined(); - }), - ); -}); - -test('GET /credentials should not retrieve non-owned creds for member', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); - - for (let i = 0; i < 3; i++) { - await saveCredential(randomCredentialPayload(), { user: ownerShell }); - } - - const response = await authMemberAgent.get('/credentials'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(0); // owner's creds not returned }); test('GET /credentials/:id should retrieve owned cred for owner', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerAgent = authAgent(ownerShell); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); expect(firstResponse.statusCode).toBe(200); - expect(typeof firstResponse.body.data.name).toBe('string'); - expect(typeof firstResponse.body.data.type).toBe('string'); - expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + validateMainCredentialData(firstResponse.body.data); expect(firstResponse.body.data.data).toBeUndefined(); const secondResponse = await authOwnerAgent .get(`/credentials/${savedCredential.id}`) .query({ includeData: true }); - expect(secondResponse.statusCode).toBe(200); - expect(typeof secondResponse.body.data.name).toBe('string'); - expect(typeof secondResponse.body.data.type).toBe('string'); - expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + validateMainCredentialData(secondResponse.body.data); expect(secondResponse.body.data.data).toBeDefined(); }); test('GET /credentials/:id should retrieve owned cred for member', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const authMemberAgent = authAgent(member); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); expect(firstResponse.statusCode).toBe(200); - expect(typeof firstResponse.body.data.name).toBe('string'); - expect(typeof firstResponse.body.data.type).toBe('string'); - expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + validateMainCredentialData(firstResponse.body.data); expect(firstResponse.body.data.data).toBeUndefined(); const secondResponse = await authMemberAgent @@ -481,19 +519,40 @@ test('GET /credentials/:id should retrieve owned cred for member', async () => { expect(secondResponse.statusCode).toBe(200); - expect(typeof secondResponse.body.data.name).toBe('string'); - expect(typeof secondResponse.body.data.type).toBe('string'); - expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + validateMainCredentialData(secondResponse.body.data); expect(secondResponse.body.data.data).toBeDefined(); }); +test('GET /credentials/:id should retrieve non-owned cred for owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + const authOwnerAgent = authAgent(owner); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + + expect(response1.statusCode).toBe(200); + + validateMainCredentialData(response1.body.data); + expect(response1.body.data.data).toBeUndefined(); + + const response2 = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response2.statusCode).toBe(200); + + validateMainCredentialData(response2.body.data); + expect(response2.body.data.data).toBeDefined(); +}); + test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + const response = await authAgent(member).get(`/credentials/${savedCredential.id}`); expect(response.statusCode).toBe(404); expect(response.body.data).toBeUndefined(); // owner's cred not returned @@ -501,13 +560,12 @@ test('GET /credentials/:id should not retrieve non-owned cred for member', async test('GET /credentials/:id should fail with missing encryption key', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - const response = await authOwnerAgent + const response = await authAgent(ownerShell) .get(`/credentials/${savedCredential.id}`) .query({ includeData: true }); @@ -518,13 +576,28 @@ test('GET /credentials/:id should fail with missing encryption key', async () => test('GET /credentials/:id should return 404 if cred not found', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authMemberAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - - const response = await authMemberAgent.get('/credentials/789'); + const response = await authAgent(ownerShell).get('/credentials/789'); expect(response.statusCode).toBe(404); }); +test('GET /credentials/:id should return 400 if id is not a number', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + + const responseAbc = await authAgent(ownerShell).get('/credentials/abc'); + expect(responseAbc.statusCode).toBe(400); +}); + +function validateMainCredentialData(credential: CredentialsEntity) { + expect(typeof credential.name).toBe('string'); + expect(typeof credential.type).toBe('string'); + expect(typeof credential.nodesAccess[0].nodeType).toBe('string'); + // @ts-ignore + expect(credential.ownedBy).toBeUndefined(); + // @ts-ignore + expect(credential.sharedWith).toBeUndefined(); +} + const INVALID_PAYLOADS = [ { type: randomName(), @@ -547,11 +620,5 @@ const INVALID_PAYLOADS = [ nodesAccess: [{ nodeType: randomName() }], }, {}, - [], undefined, ]; - -function affixRoleToSaveCredential(role: Role) { - return (credentialPayload: CredentialPayload, { user }: { user: User }) => - testDb.saveCredential(credentialPayload, { user, role }); -} diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 3737195372..8e2ad67c96 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -1,12 +1,11 @@ import express from 'express'; -import validator from 'validator'; import { IsNull } from 'typeorm'; +import validator from 'validator'; import config from '../../config'; -import * as utils from './shared/utils'; -import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { Db } from '../../src'; import type { Role } from '../../src/databases/entities/Role'; +import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { randomApiKey, randomEmail, @@ -15,6 +14,8 @@ import { randomValidPassword, } from './shared/random'; import * as testDb from './shared/testDb'; +import type { AuthAgent } from './shared/types'; +import * as utils from './shared/utils'; jest.mock('../../src/telemetry'); @@ -22,6 +23,7 @@ let app: express.Application; let testDbName = ''; let globalOwnerRole: Role; let globalMemberRole: Role; +let authAgent: AuthAgent; beforeAll(async () => { app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true }); @@ -30,6 +32,9 @@ beforeAll(async () => { globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); + + authAgent = utils.createAuthAgent(app); + utils.initTestLogger(); utils.initTestTelemetry(); }); @@ -45,9 +50,8 @@ describe('Owner shell', () => { test('GET /me should return sanitized owner shell', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerShellAgent.get('/me'); + const response = await authAgent(ownerShell).get('/me'); expect(response.statusCode).toBe(200); @@ -79,7 +83,7 @@ describe('Owner shell', () => { test('PATCH /me should succeed with valid inputs', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerShellAgent = authAgent(ownerShell); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { const response = await authOwnerShellAgent.patch('/me').send(validPayload); @@ -121,7 +125,7 @@ describe('Owner shell', () => { test('PATCH /me should fail with invalid inputs', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerShellAgent = authAgent(ownerShell); for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { const response = await authOwnerShellAgent.patch('/me').send(invalidPayload); @@ -136,7 +140,7 @@ describe('Owner shell', () => { test('PATCH /me/password should fail for shell', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerShellAgent = authAgent(ownerShell); const validPasswordPayload = { currentPassword: randomValidPassword(), @@ -168,7 +172,7 @@ describe('Owner shell', () => { test('POST /me/survey should succeed with valid inputs', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerShellAgent = authAgent(ownerShell); const validPayloads = [SURVEY, {}]; @@ -188,9 +192,8 @@ describe('Owner shell', () => { test('POST /me/api-key should create an api key', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerShellAgent.post('/me/api-key'); + const response = await authAgent(ownerShell).post('/me/api-key'); expect(response.statusCode).toBe(200); expect(response.body.data.apiKey).toBeDefined(); @@ -206,9 +209,8 @@ describe('Owner shell', () => { test('GET /me/api-key should fetch the api key', async () => { let ownerShell = await testDb.createUserShell(globalOwnerRole); ownerShell = await testDb.addApiKey(ownerShell); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerShellAgent.get('/me/api-key'); + const response = await authAgent(ownerShell).get('/me/api-key'); expect(response.statusCode).toBe(200); expect(response.body.data.apiKey).toEqual(ownerShell.apiKey); @@ -217,9 +219,8 @@ describe('Owner shell', () => { test('DELETE /me/api-key should fetch the api key', async () => { let ownerShell = await testDb.createUserShell(globalOwnerRole); ownerShell = await testDb.addApiKey(ownerShell); - const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerShellAgent.delete('/me/api-key'); + const response = await authAgent(ownerShell).delete('/me/api-key'); expect(response.statusCode).toBe(200); @@ -247,9 +248,8 @@ describe('Member', () => { test('GET /me should return sanitized member', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); - const response = await authMemberAgent.get('/me'); + const response = await authAgent(member).get('/me'); expect(response.statusCode).toBe(200); @@ -281,7 +281,7 @@ describe('Member', () => { test('PATCH /me should succeed with valid inputs', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const authMemberAgent = authAgent(member); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { const response = await authMemberAgent.patch('/me').send(validPayload); @@ -323,7 +323,7 @@ describe('Member', () => { test('PATCH /me should fail with invalid inputs', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const authMemberAgent = authAgent(member); for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { const response = await authMemberAgent.patch('/me').send(invalidPayload); @@ -342,14 +342,13 @@ describe('Member', () => { password: memberPassword, globalRole: globalMemberRole, }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); const validPayload = { currentPassword: memberPassword, newPassword: randomValidPassword(), }; - const response = await authMemberAgent.patch('/me/password').send(validPayload); + const response = await authAgent(member).patch('/me/password').send(validPayload); expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); @@ -360,7 +359,7 @@ describe('Member', () => { test('PATCH /me/password should fail with invalid inputs', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const authMemberAgent = authAgent(member); for (const payload of INVALID_PASSWORD_PAYLOADS) { const response = await authMemberAgent.patch('/me/password').send(payload); @@ -379,7 +378,7 @@ describe('Member', () => { test('POST /me/survey should succeed with valid inputs', async () => { const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + const authMemberAgent = authAgent(member); const validPayloads = [SURVEY, {}]; @@ -399,9 +398,8 @@ describe('Member', () => { globalRole: globalMemberRole, apiKey: randomApiKey(), }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); - const response = await authMemberAgent.post('/me/api-key'); + const response = await authAgent(member).post('/me/api-key'); expect(response.statusCode).toBe(200); expect(response.body.data.apiKey).toBeDefined(); @@ -417,9 +415,8 @@ describe('Member', () => { globalRole: globalMemberRole, apiKey: randomApiKey(), }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); - const response = await authMemberAgent.get('/me/api-key'); + const response = await authAgent(member).get('/me/api-key'); expect(response.statusCode).toBe(200); expect(response.body.data.apiKey).toEqual(member.apiKey); @@ -430,9 +427,8 @@ describe('Member', () => { globalRole: globalMemberRole, apiKey: randomApiKey(), }); - const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); - const response = await authMemberAgent.delete('/me/api-key'); + const response = await authAgent(member).delete('/me/api-key'); expect(response.statusCode).toBe(200); @@ -453,9 +449,8 @@ describe('Owner', () => { test('GET /me should return sanitized owner', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); - const response = await authOwnerAgent.get('/me'); + const response = await authAgent(owner).get('/me'); expect(response.statusCode).toBe(200); @@ -487,7 +482,7 @@ describe('Owner', () => { test('PATCH /me should succeed with valid inputs', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const authOwnerAgent = authAgent(owner); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { const response = await authOwnerAgent.patch('/me').send(validPayload); diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index c5899fce33..ae7e2f1e53 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -1,23 +1,25 @@ import express from 'express'; import validator from 'validator'; -import * as utils from './shared/utils'; -import * as testDb from './shared/testDb'; -import { Db } from '../../src'; import config from '../../config'; +import { Db } from '../../src'; +import type { Role } from '../../src/databases/entities/Role'; import { randomEmail, + randomInvalidPassword, randomName, randomValidPassword, - randomInvalidPassword, } from './shared/random'; -import type { Role } from '../../src/databases/entities/Role'; +import * as testDb from './shared/testDb'; +import type { AuthAgent } from './shared/types'; +import * as utils from './shared/utils'; jest.mock('../../src/telemetry'); let app: express.Application; let testDbName = ''; let globalOwnerRole: Role; +let authAgent: AuthAgent; beforeAll(async () => { app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); @@ -25,6 +27,9 @@ beforeAll(async () => { testDbName = initResult.testDbName; globalOwnerRole = await testDb.getGlobalOwnerRole(); + + authAgent = utils.createAuthAgent(app); + utils.initTestLogger(); utils.initTestTelemetry(); }); @@ -43,7 +48,6 @@ afterAll(async () => { test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const newOwnerData = { email: randomEmail(), @@ -52,7 +56,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () password: randomValidPassword(), }; - const response = await authOwnerAgent.post('/owner').send(newOwnerData); + const response = await authAgent(ownerShell).post('/owner').send(newOwnerData); expect(response.statusCode).toBe(200); @@ -96,7 +100,6 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () test('POST /owner should create owner with lowercased email', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); const newOwnerData = { email: randomEmail().toUpperCase(), @@ -105,7 +108,7 @@ test('POST /owner should create owner with lowercased email', async () => { password: randomValidPassword(), }; - const response = await authOwnerAgent.post('/owner').send(newOwnerData); + const response = await authAgent(ownerShell).post('/owner').send(newOwnerData); expect(response.statusCode).toBe(200); @@ -120,7 +123,7 @@ test('POST /owner should create owner with lowercased email', async () => { test('POST /owner should fail with invalid inputs', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const authOwnerAgent = authAgent(ownerShell); await Promise.all( INVALID_POST_OWNER_PAYLOADS.map(async (invalidPayload) => { @@ -132,9 +135,8 @@ test('POST /owner should fail with invalid inputs', async () => { test('POST /owner/skip-setup should persist skipping setup to the DB', async () => { const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - const response = await authOwnerAgent.post('/owner/skip-setup').send(); + const response = await authAgent(ownerShell).post('/owner/skip-setup').send(); expect(response.statusCode).toBe(200); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index e37b82aeff..3d878082d4 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -28,18 +28,14 @@ beforeAll(async () => { utils.initConfigFile(); - const [ - fetchedGlobalOwnerRole, - fetchedGlobalMemberRole, - _, - fetchedCredentialOwnerRole, - ] = await testDb.getAllRoles(); + const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, _, fetchedCredentialOwnerRole] = + await testDb.getAllRoles(); globalOwnerRole = fetchedGlobalOwnerRole; globalMemberRole = fetchedGlobalMemberRole; credentialOwnerRole = fetchedCredentialOwnerRole; - saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); utils.initTestLogger(); utils.initTestTelemetry(); @@ -405,8 +401,3 @@ const INVALID_PAYLOADS = [ [], undefined, ]; - -function affixRoleToSaveCredential(role: Role) { - return (credentialPayload: CredentialPayload, { user }: { user: User }) => - testDb.saveCredential(credentialPayload, { user, role }); -} diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index cf64136027..30ad7aefb0 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -41,7 +41,6 @@ export const ROUTES_REQUIRING_AUTHENTICATION: Readonly = [ */ export const ROUTES_REQUIRING_AUTHORIZATION: Readonly = [ 'POST /users', - 'GET /users', 'DELETE /users/123', 'POST /users/123/reinvite', 'POST /owner', diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index c86aa48cdb..cdafe0f8d3 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'crypto'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '../../../src/databases/entities/User'; +import type { CredentialPayload } from './types'; /** * Create a random alphanumeric string of random length between two limits, both inclusive. @@ -46,11 +47,9 @@ const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS); export const randomName = () => randomString(4, 8); -export function randomCredentialPayload() { - return { - name: randomName(), - type: randomName(), - nodesAccess: [{ nodeType: randomName() }], - data: { accessToken: randomString(6, 16) }, - }; -} +export const randomCredentialPayload = (): CredentialPayload => ({ + name: randomName(), + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(6, 16) }, +}); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 9ffe7e93d0..7f902da19c 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -1,10 +1,18 @@ import { exec as callbackExec } from 'child_process'; import { promisify } from 'util'; -import { createConnection, getConnection, ConnectionOptions, Connection } from 'typeorm'; import { UserSettings } from 'n8n-core'; +import { Connection, ConnectionOptions, createConnection, getConnection } from 'typeorm'; import config from '../../../config'; +import { DatabaseType, Db, ICredentialsDb } from '../../../src'; +import { createCredentialsFromCredentialsEntity } from '../../../src/CredentialsHelper'; +import { entities } from '../../../src/databases/entities'; +import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; +import { mysqlMigrations } from '../../../src/databases/migrations/mysqldb'; +import { postgresMigrations } from '../../../src/databases/migrations/postgresdb'; +import { sqliteMigrations } from '../../../src/databases/migrations/sqlite'; +import { hashPassword } from '../../../src/UserManagement/UserManagementHelper'; import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME, @@ -12,7 +20,6 @@ import { MAPPING_TABLES, MAPPING_TABLES_TO_CLEAR, } from './constants'; -import { DatabaseType, Db, ICredentialsDb } from '../../../src'; import { randomApiKey, randomCredentialPayload, @@ -21,16 +28,15 @@ import { randomString, randomValidPassword, } from './random'; -import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; -import { hashPassword } from '../../../src/UserManagement/UserManagementHelper'; -import { entities } from '../../../src/databases/entities'; -import { mysqlMigrations } from '../../../src/databases/migrations/mysqldb'; -import { postgresMigrations } from '../../../src/databases/migrations/postgresdb'; -import { sqliteMigrations } from '../../../src/databases/migrations/sqlite'; import { categorize, getPostgresSchemaSection } from './utils'; -import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper'; +import { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity'; +import { InstalledNodes } from '../../../src/databases/entities/InstalledNodes'; +import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages'; import type { Role } from '../../../src/databases/entities/Role'; +import { TagEntity } from '../../../src/databases/entities/TagEntity'; +import { User } from '../../../src/databases/entities/User'; +import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity'; import type { CollectionName, CredentialPayload, @@ -38,12 +44,6 @@ import type { InstalledPackagePayload, MappingName, } from './types'; -import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages'; -import { InstalledNodes } from '../../../src/databases/entities/InstalledNodes'; -import { User } from '../../../src/databases/entities/User'; -import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity'; -import { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity'; -import { TagEntity } from '../../../src/databases/entities/TagEntity'; const exec = promisify(callbackExec); @@ -318,6 +318,23 @@ export async function saveCredential( return savedCredential; } +export async function shareCredentialWithUsers(credential: CredentialsEntity, users: User[]) { + const role = await Db.collections.Role.findOne({ scope: 'credential', name: 'user' }); + const newSharedCredentials = users.map((user) => + Db.collections.SharedCredentials.create({ + user, + credentials: credential, + role, + }), + ); + return Db.collections.SharedCredentials.save(newSharedCredentials); +} + +export function affixRoleToSaveCredential(role: Role) { + return (credentialPayload: CredentialPayload, { user }: { user: User }) => + saveCredential(credentialPayload, { user, role }); +} + // ---------------------------------- // user creation // ---------------------------------- @@ -353,6 +370,34 @@ export function createUserShell(globalRole: Role): Promise { return Db.collections.User.save(shell); } +/** + * Create many users in the DB, defaulting to a `member`. + */ +export async function createManyUsers( + amount: number, + attributes: Partial = {}, +): Promise { + let { email, password, firstName, lastName, globalRole, ...rest } = attributes; + if (!globalRole) { + globalRole = await getGlobalMemberRole(); + } + + const users = await Promise.all( + [...Array(amount)].map(async () => + Db.collections.User.create({ + email: email ?? randomEmail(), + password: await hashPassword(password ?? randomValidPassword()), + firstName: firstName ?? randomName(), + lastName: lastName ?? randomName(), + globalRole, + ...rest, + }), + ), + ); + + return Db.collections.User.save(users); +} + // -------------------------------------- // Installed nodes and packages creation // -------------------------------------- @@ -725,7 +770,7 @@ export const getMySqlOptions = ({ name }: { name: string }): ConnectionOptions = async function encryptCredentialData(credential: CredentialsEntity) { const encryptionKey = await UserSettings.getEncryptionKey(); - const coreCredential = createCredentiasFromCredentialsEntity(credential, true); + const coreCredential = createCredentialsFromCredentialsEntity(credential, true); // @ts-ignore coreCredential.setData(credential.data, encryptionKey); diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 5fa3890fe2..2cbfd948dd 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -1,13 +1,10 @@ import { randomBytes } from 'crypto'; import { existsSync } from 'fs'; -import express from 'express'; -import superagent from 'superagent'; -import request from 'supertest'; -import { URL } from 'url'; import bodyParser from 'body-parser'; -import { set } from 'lodash'; import { CronJob } from 'cron'; +import express from 'express'; +import { set } from 'lodash'; import { BinaryDataManager, UserSettings } from 'n8n-core'; import { ICredentialType, @@ -21,20 +18,15 @@ import { ITriggerResponse, LoggerProxy, NodeHelpers, - TriggerTime, toCronExpression, + TriggerTime, } from 'n8n-workflow'; +import type { N8nApp } from '../../../src/UserManagement/Interfaces'; +import superagent from 'superagent'; +import request from 'supertest'; +import { URL } from 'url'; import config from '../../../config'; -import { - AUTHLESS_ENDPOINTS, - COMMUNITY_NODE_VERSION, - COMMUNITY_PACKAGE_VERSION, - PUBLIC_API_REST_PATH_SEGMENT, - REST_PATH_SEGMENT, -} from './constants'; -import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '../../../src/constants'; -import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; import { ActiveWorkflowRunner, CredentialTypes, @@ -48,11 +40,24 @@ import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/ro import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth'; import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner'; import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset'; -import { issueJWT } from '../../../src/UserManagement/auth/jwt'; -import { getLogger } from '../../../src/Logger'; -import { credentialsController } from '../../../src/api/credentials.api'; -import { loadPublicApiVersions } from '../../../src/PublicApi/'; +import { nodesController } from '../../../src/api/nodes.api'; +import { workflowsController } from '../../../src/api/workflows.api'; +import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '../../../src/constants'; +import { credentialsController } from '../../../src/credentials/credentials.controller'; +import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages'; import type { User } from '../../../src/databases/entities/User'; +import { getLogger } from '../../../src/Logger'; +import { loadPublicApiVersions } from '../../../src/PublicApi/'; +import { issueJWT } from '../../../src/UserManagement/auth/jwt'; +import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; +import { + AUTHLESS_ENDPOINTS, + COMMUNITY_NODE_VERSION, + COMMUNITY_PACKAGE_VERSION, + PUBLIC_API_REST_PATH_SEGMENT, + REST_PATH_SEGMENT, +} from './constants'; +import { randomName } from './random'; import type { ApiPath, EndpointGroup, @@ -60,11 +65,6 @@ import type { InstalledPackagePayload, PostgresSchemaSection, } from './types'; -import type { N8nApp } from '../../../src/UserManagement/Interfaces'; -import { workflowsController } from '../../../src/api/workflows.api'; -import { nodesController } from '../../../src/api/nodes.api'; -import { randomName } from './random'; -import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages'; /** * Initialize a test server. @@ -153,9 +153,6 @@ export function initTestTelemetry() { void InternalHooksManager.init('test-instance-id', 'test-version', mockNodeTypes); } -export const createAuthAgent = (app: express.Application) => (user: User) => - createAgent(app, { auth: true, user }); - /** * Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`), * and `functionEndpoints` (legacy, namespaced inside a function). @@ -309,7 +306,9 @@ export async function initNodeTypes() { const timezone = this.getTimezone(); // Start the cron-jobs - const cronJobs = cronTimes.map(cronTime => new CronJob(cronTime, executeTrigger, undefined, true, timezone)); + const cronJobs = cronTimes.map( + (cronTime) => new CronJob(cronTime, executeTrigger, undefined, true, timezone), + ); // Stop the cron-jobs async function closeFunction() { @@ -587,6 +586,10 @@ export function createAgent( return agent; } +export function createAuthAgent(app: express.Application) { + return (user: User) => createAgent(app, { auth: true, user }); +} + /** * Plugin to prefix a path segment into a request URL pathname. * diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 2d1143a7b8..64704c60db 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1,24 +1,23 @@ import express from 'express'; import validator from 'validator'; -import { v4 as uuid } from 'uuid'; -import { Db } from '../../src'; import config from '../../config'; +import { Db } from '../../src'; +import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; +import type { Role } from '../../src/databases/entities/Role'; +import type { User } from '../../src/databases/entities/User'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import { compareHash } from '../../src/UserManagement/UserManagementHelper'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { randomEmail, - randomValidPassword, - randomName, randomInvalidPassword, - randomCredentialPayload, + randomName, + randomValidPassword, } from './shared/random'; -import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; -import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; -import type { Role } from '../../src/databases/entities/Role'; -import type { User } from '../../src/databases/entities/User'; -import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; -import { compareHash } from '../../src/UserManagement/UserManagementHelper'; +import type { AuthAgent } from './shared/types'; +import * as utils from './shared/utils'; import * as UserManagementMailer from '../../src/UserManagement/email/UserManagementMailer'; import { NodeMailer } from '../../src/UserManagement/email/NodeMailer'; @@ -32,6 +31,7 @@ let globalMemberRole: Role; let globalOwnerRole: Role; let workflowOwnerRole: Role; let credentialOwnerRole: Role; +let authAgent: AuthAgent; beforeAll(async () => { app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); @@ -50,6 +50,8 @@ beforeAll(async () => { workflowOwnerRole = fetchedWorkflowOwnerRole; credentialOwnerRole = fetchedCredentialOwnerRole; + authAgent = utils.createAuthAgent(app); + utils.initTestTelemetry(); utils.initTestLogger(); }); @@ -73,11 +75,10 @@ afterAll(async () => { test('GET /users should return all users', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); await testDb.createUser({ globalRole: globalMemberRole }); - const response = await authOwnerAgent.get('/users'); + const response = await authAgent(owner).get('/users'); expect(response.statusCode).toBe(200); expect(response.body.data.length).toBe(2); @@ -113,7 +114,6 @@ test('GET /users should return all users', async () => { test('DELETE /users/:id should delete the user', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); @@ -151,7 +151,7 @@ test('DELETE /users/:id should delete the user', async () => { credentials: savedCredential, }); - const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`); + const response = await authAgent(owner).delete(`/users/${userToDelete.id}`); expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); @@ -182,9 +182,8 @@ test('DELETE /users/:id should delete the user', async () => { test('DELETE /users/:id should fail to delete self', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); - const response = await authOwnerAgent.delete(`/users/${owner.id}`); + const response = await authAgent(owner).delete(`/users/${owner.id}`); expect(response.statusCode).toBe(400); @@ -194,11 +193,10 @@ test('DELETE /users/:id should fail to delete self', async () => { test('DELETE /users/:id should fail if user to delete is transferee', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); const { id: idToDelete } = await testDb.createUser({ globalRole: globalMemberRole }); - const response = await authOwnerAgent.delete(`/users/${idToDelete}`).query({ + const response = await authAgent(owner).delete(`/users/${idToDelete}`).query({ transferId: idToDelete, }); @@ -210,7 +208,6 @@ test('DELETE /users/:id should fail if user to delete is transferee', async () = test('DELETE /users/:id with transferId should perform transfer', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); @@ -221,42 +218,39 @@ test('DELETE /users/:id with transferId should perform transfer', async () => { role: credentialOwnerRole, }); - const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`).query({ + const response = await authAgent(owner).delete(`/users/${userToDelete.id}`).query({ transferId: owner.id, }); expect(response.statusCode).toBe(200); const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({ - relations: ['workflow', 'user'], + relations: ['workflow'], where: { user: owner }, }); - const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['credentials', 'user'], - where: { user: owner }, - }); - - const deletedUser = await Db.collections.User.findOne(userToDelete); - - expect(sharedWorkflow.user.id).toBe(owner.id); expect(sharedWorkflow.workflow).toBeDefined(); expect(sharedWorkflow.workflow.id).toBe(savedWorkflow.id); - expect(sharedCredential.user.id).toBe(owner.id); + const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['credentials'], + where: { user: owner }, + }); + expect(sharedCredential.credentials).toBeDefined(); expect(sharedCredential.credentials.id).toBe(savedCredential.id); + const deletedUser = await Db.collections.User.findOne(userToDelete); + expect(deletedUser).toBeUndefined(); }); test('GET /resolve-signup-token should validate invite token', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); const memberShell = await testDb.createUserShell(globalMemberRole); - const response = await authOwnerAgent + const response = await authAgent(owner) .get('/resolve-signup-token') .query({ inviterId: owner.id }) .query({ inviteeId: memberShell.id }); @@ -274,7 +268,7 @@ test('GET /resolve-signup-token should validate invite token', async () => { test('GET /resolve-signup-token should fail with invalid inputs', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const authOwnerAgent = authAgent(owner); const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole }); @@ -441,20 +435,22 @@ test('POST /users/:id should fail with already accepted invite', async () => { test('POST /users should fail if emailing is not set up', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); - const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + const response = await authAgent(owner) + .post('/users') + .send([{ email: randomEmail() }]); expect(response.statusCode).toBe(500); }); test('POST /users should fail if user management is disabled', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); config.set('userManagement.disabled', true); - const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + const response = await authAgent(owner) + .post('/users') + .send([{ email: randomEmail() }]); expect(response.statusCode).toBe(500); }); @@ -463,7 +459,6 @@ test('POST /users should email invites and create user shells but ignore existin const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const member = await testDb.createUser({ globalRole: globalMemberRole }); const memberShell = await testDb.createUserShell(globalMemberRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); config.set('userManagement.emails.mode', 'smtp'); @@ -471,7 +466,7 @@ test('POST /users should email invites and create user shells but ignore existin const payload = testEmails.map((e) => ({ email: e })); - const response = await authOwnerAgent.post('/users').send(payload); + const response = await authAgent(owner).post('/users').send(payload); expect(response.statusCode).toBe(200); @@ -504,7 +499,7 @@ test('POST /users should email invites and create user shells but ignore existin test('POST /users should fail with invalid inputs', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); + const authOwnerAgent = authAgent(owner); config.set('userManagement.emails.mode', 'smtp'); @@ -529,11 +524,10 @@ test('POST /users should fail with invalid inputs', async () => { test('POST /users should ignore an empty payload', async () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); config.set('userManagement.emails.mode', 'smtp'); - const response = await authOwnerAgent.post('/users').send([]); + const response = await authAgent(owner).post('/users').send([]); const { data } = response.body; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index b8b1809fd9..9ef61f1a97 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -14,6 +14,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/prefer-optional-chain */ import { GenericValue, IAdditionalCredentialOptions, diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.ts similarity index 86% rename from packages/design-system/src/components/N8nActionBox/ActionBox.stories.js rename to packages/design-system/src/components/N8nActionBox/ActionBox.stories.ts index bbdcd733d3..57fb3726c4 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.ts @@ -1,5 +1,8 @@ +/* tslint:disable:variable-name */ + import N8nActionBox from './ActionBox.vue'; import { action } from '@storybook/addon-actions'; +import {StoryFn} from "@storybook/vue"; export default { title: 'Atoms/ActionBox', @@ -21,7 +24,7 @@ const methods = { onClick: action('click'), }; -const Template = (args, { argTypes }) => ({ +const Template: StoryFn = (args, { argTypes }) => ({ props: Object.keys(argTypes), components: { N8nActionBox, diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index ed2bfee6d4..f2853deb89 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -1,14 +1,25 @@