diff --git a/packages/cli/package.json b/packages/cli/package.json index 804fe74a23..4e14e17bbe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,7 +30,7 @@ "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", "test": "npm run test:sqlite", - "test:sqlite": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=sqlite; jest", + "test:sqlite": "export N8N_LOG_LEVEL='debug'; export DB_TYPE=sqlite; jest --detectOpenHandles", "test:postgres": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=postgresdb && jest", "test:mysql": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=mysqldb && jest", "watch": "tsc --watch", @@ -92,7 +92,6 @@ "typescript": "~4.6.0" }, "dependencies": { - "@apidevtools/swagger-parser": "^10.0.3", "@oclif/command": "^1.5.18", "@oclif/errors": "^1.2.2", "@rudderstack/rudder-sdk-node": "1.0.6", @@ -138,7 +137,6 @@ "p-cancelable": "^2.0.0", "passport": "^0.5.0", "passport-cookie": "^1.0.9", - "passport-http-header-strategy": "^1.1.0", "passport-jwt": "^4.0.0", "pg": "^8.3.0", "prom-client": "^13.1.0", diff --git a/packages/cli/src/PublicApi/helpers.ts b/packages/cli/src/PublicApi/helpers.ts index bb1616e65e..fe7512500f 100644 --- a/packages/cli/src/PublicApi/helpers.ts +++ b/packages/cli/src/PublicApi/helpers.ts @@ -5,8 +5,6 @@ import * as querystring from 'querystring'; // eslint-disable-next-line import/no-extraneous-dependencies import { pick } from 'lodash'; -import express = require('express'); -import SwaggerParser from '@apidevtools/swagger-parser'; import { In } from 'typeorm'; import { validate as uuidValidate } from 'uuid'; import { User } from '../databases/entities/User'; @@ -22,13 +20,6 @@ interface IPaginationOffsetDecoded { offset: number; limit: number; } -export interface IMiddlewares { - [key: string]: [IMiddleware]; -} -interface IMiddleware { - (req: express.Request, res: express.Response, next: express.NextFunction): void; -} - export type OperationID = 'getUsers' | 'getUser'; export const decodeCursor = (cursor: string): IPaginationOffsetDecoded => { @@ -40,7 +31,7 @@ export const decodeCursor = (cursor: string): IPaginationOffsetDecoded => { }; }; -export const getNextCursor = ( +export const encodeNextCursor = ( offset: number, limit: number, numberOfRecords: number, @@ -49,12 +40,10 @@ export const getNextCursor = ( if (retrieveRecordsLength < numberOfRecords) { return Buffer.from( - JSON.stringify( - querystring.encode({ - limit, - offset: offset + limit, - }), - ), + JSON.stringify({ + limit, + offset: offset + limit, + }), ).toString('base64'); } @@ -68,59 +57,6 @@ export const getSelectableProperties = (table: 'user' | 'role'): string[] => { }[table]; }; -export const connectionName = (): string => { - return 'default'; -}; - -const middlewareDefined = (operationId: OperationID, middlewares: IMiddlewares) => - operationId && middlewares[operationId]; - -export const addMiddlewares = ( - router: express.Router, - method: string, - routePath: string, - operationId: OperationID, - middlewares: IMiddlewares, -): void => { - if (middlewareDefined(operationId, middlewares)) { - const expressPath = routePath.replace(/\{([^}]+)}/g, ':$1'); - switch (method) { - case 'get': - router.get(expressPath, ...middlewares[operationId]); - break; - case 'post': - router.post(expressPath, ...middlewares[operationId]); - break; - case 'put': - router.put(expressPath, ...middlewares[operationId]); - break; - case 'delete': - router.delete(expressPath, ...middlewares[operationId]); - break; - default: - break; - } - } -}; - -export const addCustomMiddlewares = async ( - apiController: express.Router, - openApiSpec: string, - middlewares: IMiddlewares, -): Promise => { - const { paths = {} } = await SwaggerParser.parse(openApiSpec); - Object.entries(paths).forEach(([routePath, methods]) => { - Object.entries(methods).forEach(([method, data]) => { - const operationId: OperationID = ( - data as { - 'x-eov-operation-id': OperationID; - } - )['x-eov-operation-id']; - addMiddlewares(apiController, method, routePath, operationId, middlewares); - }); - }); -}; - export async function getGlobalMemberRole(): Promise { return Db.collections.Role?.findOneOrFail({ name: 'member', @@ -227,7 +163,7 @@ export async function inviteUsers( export async function getUser(data: { withIdentifier: string; - includeRole: boolean; + includeRole?: boolean; }): Promise { return Db.collections.User?.findOne({ where: { diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts new file mode 100644 index 0000000000..5beb377f4b --- /dev/null +++ b/packages/cli/src/PublicApi/index.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/no-cycle +import { publicApiController as publicApiControllerV1 } from './v1'; + +export const publicApi = [publicApiControllerV1()]; diff --git a/packages/cli/src/PublicApi/middlewares.ts b/packages/cli/src/PublicApi/middlewares.ts index ba2e6c0d1a..d07e3f98c6 100644 --- a/packages/cli/src/PublicApi/middlewares.ts +++ b/packages/cli/src/PublicApi/middlewares.ts @@ -1,9 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable consistent-return */ /* eslint-disable @typescript-eslint/no-explicit-any */ import express = require('express'); -import validator from 'validator'; import config = require('../../config'); import type { UserRequest } from '../requests'; import { decodeCursor } from './helpers'; @@ -15,10 +17,10 @@ const instanceOwnerSetup = ( res: express.Response, next: express.NextFunction, ): any => { - if (config.getEnv('userManagement.isInstanceOwnerSetUp')) { - return next(); + if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) { + return res.status(404).json({ message: 'asasas' }); } - return res.status(400).json({ message: 'asasas' }); + next(); }; const emailSetup = ( @@ -26,10 +28,10 @@ const emailSetup = ( res: express.Response, next: express.NextFunction, ): any => { - if (config.getEnv('userManagement.emails.mode')) { - return next(); + if (!config.getEnv('userManagement.emails.mode')) { + return res.status(500).json({ message: 'asasas' }); } - return res.status(400).json({ message: 'asasas' }); + next(); }; const authorize = @@ -41,26 +43,27 @@ const authorize = if (role.includes(userRole)) { return next(); } - return res.status(400).json({ + return res.status(403).json({ message: 'asasas', }); }; -const validEmail = ( - req: UserRequest.Invite, - res: express.Response, - next: express.NextFunction, -): any => { - // eslint-disable-next-line no-restricted-syntax - for (const { email } of req.body) { - if (!validator.isEmail(email)) { - return res.status(400).json({ - message: `Request to send email invite(s) to user(s) failed because of an invalid email address: ${email}`, - }); - } - } - next(); -}; +// move this to open api validator +// const validEmail = ( +// req: UserRequest.Invite, +// res: express.Response, +// next: express.NextFunction, +// ): any => { +// // eslint-disable-next-line no-restricted-syntax +// for (const { email } of req.body) { +// if (!validator.isEmail(email)) { +// return res.status(400).json({ +// message: `Request to send email invite(s) to user(s) failed because of an invalid email address: ${email}`, +// }); +// } +// } +// next(); +// }; const deletingOwnUser = ( req: UserRequest.Delete, @@ -95,9 +98,6 @@ const validCursor = ( ): any => { let offset = 0; let limit = 10; - if (req.query?.limit) { - limit = parseInt(req.query?.limit, 10) || 10; - } if (req.query.cursor) { const { cursor } = req.query; try { @@ -108,31 +108,21 @@ const validCursor = ( }); } } - req.limit = limit; - req.offset = offset; - next(); -}; - -const parseIncludeRole = ( - req: UserRequest.Get, - res: express.Response, - next: express.NextFunction, -): any => { - req.includeRole = false; - if (req.query?.includeRole) { - req.includeRole = req.query.includeRole === 'true'; - } + // @ts-ignore + req.query.offset = offset; + // @ts-ignore + req.query.limit = limit; next(); }; export const middlewares = { - createUsers: [instanceOwnerSetup, emailSetup, validEmail, authorize(['owner'])], + createUsers: [instanceOwnerSetup, emailSetup, authorize(['owner'])], deleteUsers: [ instanceOwnerSetup, deletingOwnUser, transferingToDeletedUser, authorize(['owner']), ], - getUsers: [instanceOwnerSetup, parseIncludeRole, validCursor, authorize(['owner'])], - getUser: [instanceOwnerSetup, parseIncludeRole, authorize(['owner'])], + getUsers: [instanceOwnerSetup, validCursor, authorize(['owner'])], + getUser: [instanceOwnerSetup, authorize(['owner'])], }; diff --git a/packages/cli/src/PublicApi/v1/index.ts b/packages/cli/src/PublicApi/v1/index.ts index 7d267a3221..3978ed149f 100644 --- a/packages/cli/src/PublicApi/v1/index.ts +++ b/packages/cli/src/PublicApi/v1/index.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable global-require */ +/* eslint-disable import/no-dynamic-require */ /* eslint-disable import/no-cycle */ import * as OpenApiValidator from 'express-openapi-validator'; @@ -5,55 +8,48 @@ import path = require('path'); import express = require('express'); -import { HttpError } from 'express-openapi-validator/dist/framework/types'; -import passport = require('passport'); -import { Strategy } from 'passport-http-header-strategy'; -import { VerifiedCallback } from 'passport-jwt'; -import { Db } from '../..'; -import { middlewares } from '../middlewares'; -import { addCustomMiddlewares, IMiddlewares } from '../helpers'; +import { HttpError, OpenAPIV3 } from 'express-openapi-validator/dist/framework/types'; -export const publicApiController = (async (): Promise => { +import { Db } from '../..'; + +export const publicApiController = (): express.Router => { const openApiSpec = path.join(__dirname, 'openapi.yml'); const apiController = express.Router(); - apiController.use('/spec', express.static(openApiSpec)); + apiController.use('/v1/spec', express.static(openApiSpec)); - apiController.use(express.json()); - - passport.use( - new Strategy( - { header: 'X-N8N-API-KEY', passReqToCallback: false }, - async (token: string, done: VerifiedCallback) => { - const user = await Db.collections.User?.findOne({ - where: { - apiKey: token, - }, - relations: ['globalRole'], - }); - - if (!user) { - return done(null, false); - } - - return done(null, user); - }, - ), - ); - - // add authentication middlewlares - apiController.use('/', passport.authenticate('header', { session: false })); - - await addCustomMiddlewares(apiController, openApiSpec, middlewares as unknown as IMiddlewares); + apiController.use('/v1', express.json()); apiController.use( + '/v1', OpenApiValidator.middleware({ apiSpec: openApiSpec, operationHandlers: path.join(__dirname), validateRequests: true, validateApiSpec: true, - validateSecurity: false, + validateSecurity: { + handlers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + ApiKeyAuth: async (req, scopes, schema: OpenAPIV3.ApiKeySecurityScheme) => { + const apiKey = req.headers[schema.name.toLowerCase()]; + const user = await Db.collections.User?.findOne({ + where: { + apiKey, + }, + relations: ['globalRole'], + }); + + if (!user) { + return false; + } + + req.user = user; + + return true; + }, + }, + }, }), ); @@ -62,12 +58,12 @@ export const publicApiController = (async (): Promise => { apiController.use( // eslint-disable-next-line @typescript-eslint/no-unused-vars (error: HttpError, req: express.Request, res: express.Response, next: express.NextFunction) => { - return res.status(error.status || 500).json({ + return res.status(error.status || 400).json({ message: error.message, - errors: error.errors, + // errors: error.errors, }); }, ); return apiController; -})(); +}; diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index 82a8679e1c..3771e17c3e 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -16,7 +16,7 @@ externalDocs: servers: - url: /api/v1 tags: -- name: user +- name: users description: Operations about user externalDocs: description: Find out more about our store @@ -31,15 +31,6 @@ paths: summary: Retrieve all users description: Retrieve all users from your instance. Only available for the instance owner. parameters: - - name: select - in: query - required: false - style: form - explode: true - schema: - type: string - description: Comma-separted list of the properties to return. Use a to return all properties. Dot notation be use for nested properties - example: email,firstName - name: limit in: query description: The maximum number of items to return @@ -65,7 +56,7 @@ paths: style: form explode: true schema: - type: string + type: boolean example: true responses: "200": @@ -84,7 +75,7 @@ paths: x-eov-operation-id: createUsers x-eov-operation-handler: routes/Users tags: - - user + - users summary: Invite a user description: Invites a user to your instance. Only available for the instance owner. operationId: createUser @@ -138,7 +129,7 @@ paths: style: form explode: true schema: - type: string + type: boolean example: true responses: "200": @@ -257,7 +248,7 @@ components: description: User's last name readOnly: true example: doe - finishedSetup: + pending: type: boolean description: Whether the user finished setting up the invitation or not readOnly: true @@ -315,4 +306,4 @@ components: name: X-N8N-API-KEY security: - - ApiKeyAuth: [] \ No newline at end of file + - ApiKeyAuth: [] diff --git a/packages/cli/src/PublicApi/v1/routes/Users/index.ts b/packages/cli/src/PublicApi/v1/routes/Users/index.ts index 68aaea4a0f..d85039d948 100644 --- a/packages/cli/src/PublicApi/v1/routes/Users/index.ts +++ b/packages/cli/src/PublicApi/v1/routes/Users/index.ts @@ -1,8 +1,10 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unused-vars */ import express = require('express'); -import { getConnection, In } from 'typeorm'; -import { validate as uuidValidate } from 'uuid'; import { UserRequest } from '../../../../requests'; import { User } from '../../../../databases/entities/User'; @@ -10,11 +12,10 @@ import { Role } from '../../../../databases/entities/Role'; import { clean, - decodeCursor, deleteDataAndSendTelemetry, getAllUsersAndCount, getGlobalMemberRole, - getNextCursor, + encodeNextCursor, getUser, getUsers, getUsersToSaveAndInvite, @@ -25,122 +26,137 @@ import { import * as UserManagementMailer from '../../../../UserManagement/email/UserManagementMailer'; -import { Db, ResponseHelper } from '../../../..'; +import { ResponseHelper } from '../../../..'; + +import { middlewares } from '../../../middlewares'; export = { - createUsers: ResponseHelper.send(async (req: UserRequest.Invite, res: express.Response) => { - const tokenOwnerId = req.user.id; - const emailsInBody = req.body.map((data) => data.email); + createUsers: [ + ...middlewares.createUsers, + ResponseHelper.send(async (req: UserRequest.Invite, res: express.Response) => { + const tokenOwnerId = req.user.id; + const emailsInBody = req.body.map((data) => data.email); - let mailer: UserManagementMailer.UserManagementMailer | undefined; - try { - mailer = await UserManagementMailer.getInstance(); - } catch (error) { - if (error instanceof Error) { + let mailer: UserManagementMailer.UserManagementMailer | undefined; + try { + mailer = await UserManagementMailer.getInstance(); + } catch (error) { + if (error instanceof Error) { + throw new ResponseHelper.ResponseError( + 'Email sending must be set up in order to request a password reset email', + undefined, + 500, + ); + } + } + + let role: Role | undefined; + + try { + role = await getGlobalMemberRole(); + } catch (error) { throw new ResponseHelper.ResponseError( - 'Email sending must be set up in order to request a password reset email', + 'Members role not found in database - inconsistent state', undefined, 500, ); } - } - let role: Role | undefined; + const { usersToSave, pendingUsers } = await getUsersToSaveAndInvite(emailsInBody); - try { - role = await getGlobalMemberRole(); - } catch (error) { - throw new ResponseHelper.ResponseError( - 'Members role not found in database - inconsistent state', - undefined, - 500, - ); - } + let savedUsers; - const { usersToSave, pendingUsers } = await getUsersToSaveAndInvite(emailsInBody); + try { + savedUsers = await saveUsersWithRole(usersToSave, role!, tokenOwnerId); + } catch (error) { + throw new ResponseHelper.ResponseError('An error occurred during user creation'); + } - let savedUsers; + const userstoInvite = [...savedUsers, ...pendingUsers]; - try { - savedUsers = await saveUsersWithRole(usersToSave, role!, tokenOwnerId); - } catch (error) { - throw new ResponseHelper.ResponseError('An error occurred during user creation'); - } + await inviteUsers(userstoInvite, mailer, tokenOwnerId); - const userstoInvite = [...savedUsers, ...pendingUsers]; + return clean(userstoInvite); + }), + ], + deleteUser: [ + ...middlewares.deleteUsers, + async (req: UserRequest.Delete, res: express.Response): Promise => { + const { identifier: idToDelete } = req.params; + const { transferId, includeRole } = req.query; + const apiKeyUserOwner = req.user; - await inviteUsers(userstoInvite, mailer, tokenOwnerId); + const users = await getUsers({ + withIdentifiers: [idToDelete, transferId ?? ''], + includeRole, + }); - return clean(userstoInvite); - }), - // eslint-disable-next-line consistent-return - deleteUser: async (req: UserRequest.Delete, res: express.Response): Promise => { - const { identifier: idToDelete } = req.params; - const { transferId } = req.query; - const apiKeyUserOwner = req.user; - const includeRole = req.query?.includeRole?.toLowerCase() === 'true' || false; + if (!users?.length || (transferId && users.length !== 2)) { + throw new ResponseHelper.ResponseError( + 'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB', + undefined, + 400, + ); + } - const users = await getUsers({ withIdentifiers: [idToDelete, transferId ?? ''], includeRole }); + const userToDelete = users?.find((user) => user.id === req.params.identifier) as User; - if (!users?.length || (transferId && users.length !== 2)) { - throw new ResponseHelper.ResponseError( - 'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB', - undefined, - 400, - ); - } + if (transferId) { + const transferee = users?.find((user) => user.id === transferId) as User; - const userToDelete = users?.find((user) => user.id === req.params.identifier) as User; + await transferWorkflowsAndCredentials({ + fromUser: userToDelete, + toUser: transferee, + }); - if (transferId) { - const transferee = users?.find((user) => user.id === transferId) as User; + return clean(userToDelete); + } - await transferWorkflowsAndCredentials({ + await deleteDataAndSendTelemetry({ fromUser: userToDelete, - toUser: transferee, + apiKeyOwnerUser: apiKeyUserOwner, + transferId, }); return clean(userToDelete); - } + }, + ], + getUser: [ + ...middlewares.getUser, + // @ts-ignore + ResponseHelper.send(async (req: UserRequest.Get, res: express.Response) => { + const { includeRole } = req.query; + const { identifier } = req.params; - await deleteDataAndSendTelemetry({ - fromUser: userToDelete, - apiKeyOwnerUser: apiKeyUserOwner, - transferId, - }); + const user = await getUser({ withIdentifier: identifier, includeRole }); - return clean(userToDelete); - }, - // eslint-disable-next-line consistent-return - getUser: ResponseHelper.send(async (req: UserRequest.Get, res: express.Response) => { - const { includeRole } = req; - const { identifier } = req.params; + if (!user) { + throw new ResponseHelper.ResponseError( + `Could not find user with identifier: ${identifier as string}`, + undefined, + 404, + ); + } - const user = await getUser({ withIdentifier: identifier, includeRole }); + return clean(user, { includeRole }); + }, true), + ], + getUsers: [ + ...middlewares.getUsers, + // @ts-ignore + ResponseHelper.send(async (req: UserRequest.Get, res: express.Response) => { + const { offset, limit, includeRole = false } = req.query; - if (!user) { - throw new ResponseHelper.ResponseError( - `Could not find user with identifier: ${identifier}`, - undefined, - 404, - ); - } + const [users, count] = await getAllUsersAndCount({ + includeRole, + limit, + offset, + }); - return clean(user, { includeRole }); - }, true), - // eslint-disable-next-line consistent-return - getUsers: ResponseHelper.send(async (req: UserRequest.Get, res: express.Response) => { - const { offset, limit, includeRole } = req; - - const [users, count] = await getAllUsersAndCount({ - includeRole, - limit, - offset, - }); - - return { - users: clean(users, { includeRole }), - nextCursor: getNextCursor(offset, limit, count), - }; - }, true), + return { + users: clean(users, { includeRole }), + nextCursor: encodeNextCursor(offset, limit, count), + }; + }, true), + ], }; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 18b559c826..5c8f64c018 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -169,7 +169,7 @@ import { SharedWorkflow } from './databases/entities/SharedWorkflow'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants'; import { credentialsController } from './api/credentials.api'; import { getInstanceBaseUrl, isEmailSetUp } from './UserManagement/UserManagementHelper'; -import { publicApiController as publicApiControllerV1 } from './PublicApi/v1'; +import { publicApi } from './PublicApi'; require('body-parser-xml')(bodyParser); @@ -580,7 +580,7 @@ class App { return ResponseHelper.sendSuccessResponse(res, {}, true, 204); }); - this.app.use(`/${this.publicApiEndpoint}/v1`, await publicApiControllerV1); + this.app.use(`/${this.publicApiEndpoint}`, ...publicApi); // Parse cookies for easier access this.app.use(cookieParser()); diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 022569749d..a11c94fc58 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -28,9 +28,6 @@ export type AuthenticatedRequest< RequestQuery = {}, > = express.Request & { user: User; - limit: number; - offset: number; - includeRole: boolean; }; // ---------------------------------- @@ -205,14 +202,14 @@ export declare namespace UserRequest { { id: string; email: string; identifier: string }, {}, {}, - { transferId?: string; includeRole: string } + { transferId?: string; includeRole: boolean } >; export type Get = AuthenticatedRequest< { id: string; email: string; identifier: string }, {}, {}, - { limit?: string; offset: string; cursor?: string; includeRole?: string } + { limit: number; offset: number; cursor: string; includeRole: boolean } >; export type Reinvite = AuthenticatedRequest<{ id: string }>;