diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index 267cc93266..0e0213d31d 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -4,7 +4,7 @@ module.exports = { '^.+\\.ts?$': 'ts-jest', }, testURL: 'http://localhost/', - testRegex: '(/__tests__/.*|(\\.|/)(test))\\.ts$', + testRegex: '(/__tests__/.*|(\\.|/)(test-api))\\.ts$', testPathIgnorePatterns: ['/dist/', '/node_modules/'], moduleFileExtensions: ['ts', 'js', 'json'], globals: { diff --git a/packages/cli/src/PublicApi/helpers.ts b/packages/cli/src/PublicApi/helpers.ts new file mode 100644 index 0000000000..e4b07cd3bc --- /dev/null +++ b/packages/cli/src/PublicApi/helpers.ts @@ -0,0 +1,47 @@ +import * as querystring from 'querystring'; + +interface IPaginationOffsetDecoded { + offset: number; + limit: number; +} + +export const decodeCursor = (cursor: string): IPaginationOffsetDecoded => { + const data = JSON.parse(Buffer.from(cursor, 'base64').toString()) as string; + const unserializedData = querystring.decode(data) as { offset: string; limit: string }; + return { + offset: parseInt(unserializedData.offset, 10), + limit: parseInt(unserializedData.limit, 10), + }; +}; + +export const getNextCursor = ( + offset: number, + limit: number, + numberOfRecords: number, +): string | null => { + const retrieveRecordsLength = offset + limit; + + if (retrieveRecordsLength < numberOfRecords) { + return Buffer.from( + JSON.stringify( + querystring.encode({ + limit, + offset: offset + limit, + }), + ), + ).toString('base64'); + } + + return null; +}; + +export const getSelectableProperties = (table: 'user' | 'role'): string[] => { + return { + user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt'], + role: ['id', 'name', 'scope', 'createdAt', 'updatedAt'], + }[table]; +}; + +export const connectionName = (): string => { + return 'default'; +}; diff --git a/packages/cli/src/PublicApi/v1/index.ts b/packages/cli/src/PublicApi/v1/index.ts index 97eca7b8d5..d7d338ff61 100644 --- a/packages/cli/src/PublicApi/v1/index.ts +++ b/packages/cli/src/PublicApi/v1/index.ts @@ -1,7 +1,4 @@ - -import { - Application, -} from 'express'; +import { Application, Response } from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; @@ -9,57 +6,70 @@ import path = require('path'); import express = require('express'); -import { OpenAPIV3 } from 'express-openapi-validator/dist/framework/types'; +import { HttpError, OpenAPIV3 } from 'express-openapi-validator/dist/framework/types'; +// eslint-disable-next-line import/no-cycle import { Db } from '../..'; +import config = require('../../../config'); export interface N8nApp { app: Application; } -const publicApiController = express.Router(); +export const publicApiController = express.Router(); -export const getRoutes = (): express.Router => { +publicApiController.use( + `/v1`, + OpenApiValidator.middleware({ + apiSpec: path.join(__dirname, 'openapi.yml'), + operationHandlers: path.join(__dirname), + validateRequests: true, + validateApiSpec: true, + validateSecurity: { + handlers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + ApiKeyAuth: async (req, scopes, schema: OpenAPIV3.ApiKeySecurityScheme) => { + const apiKey = req.headers[schema.name.toLowerCase()]; - publicApiController.use(`/v1`, - OpenApiValidator.middleware({ - apiSpec: path.join(__dirname, 'openapi.yml'), - operationHandlers: path.join(__dirname), - validateRequests: true, // (default) - validateApiSpec: true, - validateSecurity: { - handlers: { - ApiKeyAuth: async (req, scopes, schema: OpenAPIV3.ApiKeySecurityScheme) => { + const user = await Db.collections.User?.find({ + where: { + apiKey, + }, + relations: ['globalRole'], + }); - const apiKey = req.headers[schema.name.toLowerCase()]; + if (!user?.length) { + return false; + } - const user = await Db.collections.User!.find({ - where: { - apiKey, - }, - relations: ['globalRole'], - }); + if (!config.get('userManagement.isInstanceOwnerSetUp')) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw { + message: 'asasasas', + status: 400, + }; + } - if (!user.length) { - return false; - } + if (user[0].globalRole.name !== 'owner') { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw { + status: 403, + }; + } - req.user = user[0]; + [req.user] = user; - return true; - }, + return true; }, - }, - })); - - //add error handler - //@ts-ignore - publicApiController.use((err, req, res, next) => { - res.status(err.status || 500).json({ - message: err.message, - errors: err.errors, - }); + }, + }, + }), +); +// add error handler +// @ts-ignore +publicApiController.use((error: HttpError, req, res: Response) => { + res.status(error.status || 500).json({ + message: error.message, + errors: error.errors, }); - - return publicApiController; -}; \ No newline at end of file +}); diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index 7221a17e75..8ae6b21df5 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -59,6 +59,14 @@ paths: schema: type: string example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA + - name: includeRole + in: query + required: false + style: form + explode: true + schema: + type: string + example: true responses: "200": description: Successful operation diff --git a/packages/cli/src/PublicApi/v1/routes/Users/index.ts b/packages/cli/src/PublicApi/v1/routes/Users/index.ts index 4ff4d7da66..3359eb5e8f 100644 --- a/packages/cli/src/PublicApi/v1/routes/Users/index.ts +++ b/packages/cli/src/PublicApi/v1/routes/Users/index.ts @@ -1,18 +1,53 @@ import express = require('express'); +import { getConnection } from 'typeorm'; import { UserRequest } from '../../../../requests'; +import { User } from '../../../../databases/entities/User'; +import { + connectionName, + decodeCursor, + getNextCursor, + getSelectableProperties, +} from '../../../helpers'; + export = { - createUsers: async (req: UserRequest.Invite, res: express.Response) => { - res.json({ success: true}); - }, - deleteUser: async (req: UserRequest.Delete, res: express.Response) => { + createUsers: async (req: UserRequest.Invite, res: express.Response): Promise => { res.json({ success: true }); }, - getUser: async (req: UserRequest.Get, res: express.Response) => { + deleteUser: async (req: UserRequest.Delete, res: express.Response): Promise => { res.json({ success: true }); }, - getUsers: async (req: UserRequest.Get, res: express.Response) => { + getUser: async (req: UserRequest.Get, res: express.Response): Promise => { res.json({ success: true }); }, -}; + getUsers: async (req: UserRequest.Get, res: express.Response): Promise => { + let offset = 0; + let limit = parseInt(req.query.limit, 10) || 10; + const includeRole = req.query?.includeRole?.toLowerCase() === 'true' || false; + + if (req.query.cursor) { + const { cursor } = req.query; + ({ offset, limit } = decodeCursor(cursor)); + } + + const query = getConnection(connectionName()) + .getRepository(User) + .createQueryBuilder() + .leftJoinAndSelect('User.globalRole', 'Role') + .select(getSelectableProperties('user')?.map((property) => `User.${property}`)) + .limit(limit) + .offset(offset); + + if (includeRole) { + query.addSelect(getSelectableProperties('role')?.map((property) => `Role.${property}`)); + } + + const [users, count] = await query.getManyAndCount(); + + res.json({ + users, + nextCursor: getNextCursor(offset, limit, count), + }); + }, +}; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 42a1adf9e2..802be9ed78 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -172,7 +172,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 * as publicApiv1Routes from './PublicApi/v1'; +import { publicApiController } from './PublicApi/v1'; require('body-parser-xml')(bodyParser); @@ -564,25 +564,23 @@ class App { // Public API // ---------------------------------------- - - //test routes to create/regenerate/delete token - //NOTE: Only works with admin role - //This should be within the user's management user scope + // test routes to create/regenerate/delete token + // NOTE: Only works with admin role + // This should be within the user's management user scope this.app.post('/token', async (req: express.Request, res: express.Response) => { const ramdonToken = randomBytes(20).toString('hex'); - //@ts-ignore + // @ts-ignore await Db.collections.User!.update({ globalRole: 1 }, { apiKey: ramdonToken }); return ResponseHelper.sendSuccessResponse(res, { token: ramdonToken }, true, 200); }); this.app.delete('/token', async (req: express.Request, res: express.Response) => { - //@ts-ignore + // @ts-ignore await Db.collections.User!.update({ globalRole: 1 }, { apiKey: null }); return ResponseHelper.sendSuccessResponse(res, {}, true, 204); }); - - this.app.use(`/${this.publicApiEndpoint}`, publicApiv1Routes.getRoutes()); + this.app.use(`/${this.publicApiEndpoint}/`, publicApiController); // Parse cookies for easier access this.app.use(cookieParser()); @@ -3111,7 +3109,7 @@ async function getExecutionsCount( try { // Get an estimate of rows count. const estimateRowsNumberSql = - 'SELECT n_live_tup FROM pg_stat_all_tables WHERE relname = \'execution_entity\';'; + "SELECT n_live_tup FROM pg_stat_all_tables WHERE relname = 'execution_entity';"; const rows: Array<{ n_live_tup: string }> = await Db.collections.Execution!.query( estimateRowsNumberSql, ); diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index b344d02a14..9b997f3eb7 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -196,9 +196,19 @@ export declare namespace UserRequest { { inviterId?: string; inviteeId?: string } >; - export type Delete = AuthenticatedRequest<{ id: string, email: string }, {}, {}, { transferId?: string }>; + export type Delete = AuthenticatedRequest< + { id: string; email: string }, + {}, + {}, + { transferId?: string } + >; - export type Get = AuthenticatedRequest<{ id: string, email: string }, {}, {}, { limit: string, cursor: string }>; + export type Get = AuthenticatedRequest< + { id: string; email: string }, + {}, + {}, + { limit: string; cursor: string; includeRole: string } + >; export type Reinvite = AuthenticatedRequest<{ id: string }>; diff --git a/packages/cli/test/integration/publicApi/users.endpoints.test-api.ts b/packages/cli/test/integration/publicApi/users.endpoints.test-api.ts new file mode 100644 index 0000000000..5247c655d8 --- /dev/null +++ b/packages/cli/test/integration/publicApi/users.endpoints.test-api.ts @@ -0,0 +1,183 @@ +import express = require('express'); +import validator from 'validator'; +import { v4 as uuid } from 'uuid'; +import { compare } from 'bcryptjs'; + +import { Db } from '../../../src'; +import config = require('../../../config'); +import { SUCCESS_RESPONSE_BODY } from './../shared/constants'; +import { Role } from '../../../src/databases/entities/Role'; +import { + randomApiKey, + randomEmail, + randomInvalidPassword, + randomName, + randomValidPassword, +} from './../shared/random'; + +import * as utils from './../shared/utils'; +import * as testDb from './../shared/testDb'; + +// import * from './../../../src/PublicApi/helpers' + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let workflowOwnerRole: Role; +let credentialOwnerRole: Role; + +beforeAll(async () => { + app = utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + const [ + fetchedGlobalOwnerRole, + fetchedGlobalMemberRole, + fetchedWorkflowOwnerRole, + fetchedCredentialOwnerRole, + ] = await testDb.getAllRoles(); + + globalOwnerRole = fetchedGlobalOwnerRole; + globalMemberRole = fetchedGlobalMemberRole; + workflowOwnerRole = fetchedWorkflowOwnerRole; + credentialOwnerRole = fetchedCredentialOwnerRole; + + utils.initTestTelemetry(); + utils.initTestLogger(); +}); + +beforeEach(async () => { + // do not combine calls - shared tables must be cleared first and separately + await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName); + await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName); + + jest.isolateModules(() => { + jest.mock('../../../config'); + jest.mock('./../../../src/PublicApi/helpers', () => ({ + ...jest.requireActual('./../../../src/PublicApi/helpers'), + connectionName: jest.fn(() => testDbName), + })); + }); + + await testDb.createUser({ + id: INITIAL_TEST_USER.id, + email: INITIAL_TEST_USER.email, + password: INITIAL_TEST_USER.password, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + globalRole: globalOwnerRole, + apiKey: INITIAL_TEST_USER.apiKey, + }); + + config.set('userManagement.disabled', false); + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', ''); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('GET /users should fail due to missing API Key', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + + const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: false, user: owner }); + + await testDb.createUser(); + + const response = await authOwnerAgent.get('/v1/users'); + + expect(response.statusCode).toBe(401); + +}); + +test('GET /users should fail due to invalid API Key', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + + owner.apiKey = null; + + const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: false, user: owner }); + + const response = await authOwnerAgent.get('/v1/users'); + + expect(response.statusCode).toBe(401); +}); + +test('GET /users should fail due to member trying to access owner only endpoint', async () => { + const member = await testDb.createUser(); + + const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: member }); + + const response = await authOwnerAgent.get('/v1/users'); + + expect(response.statusCode).toBe(403); +}); + +test('GET /users should fail due no instance owner not setup', async () => { + + config.set('userManagement.isInstanceOwnerSetUp', false); + + const owner = await Db.collections.User!.findOneOrFail(); + + const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner }); + + const response = await authOwnerAgent.get('/v1/users'); + + expect(response.statusCode).toBe(400); + +}); + +test('GET /users should return all users', async () => { + + const owner = await Db.collections.User!.findOneOrFail(); + + const authOwnerAgent = utils.createAgent(app, { apiPath: 'public', auth: true, user: owner }); + + await testDb.createUser(); + + const response = await authOwnerAgent.get('/v1/users'); + + expect(response.statusCode).toBe(200); + expect(response.body.users.length).toBe(2); + expect(response.body.nextCursor).toBeNull(); + + for (const user of response.body.users) { + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + createdAt, + updatedAt, + } = user; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBeDefined(); + expect(lastName).toBeDefined(); + expect(personalizationAnswers).toBeUndefined(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + //virtual method not working + //expect(isPending).toBe(false); + expect(globalRole).toBeUndefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } +}); + +const INITIAL_TEST_USER = { + id: uuid(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + apiKey: randomApiKey(), +}; \ No newline at end of file diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index 99c624efb4..d3add721b0 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -2,6 +2,8 @@ import config = require('../../../config'); export const REST_PATH_SEGMENT = config.get('endpoints.rest') as Readonly; +export const PUBLIC_API_REST_PATH_SEGMENT = config.get('publicApiEndpoints.path') as Readonly; + export const AUTHLESS_ENDPOINTS: Readonly = [ 'healthz', 'metrics', diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index 4a531aba3b..c291b48e3a 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -10,6 +10,10 @@ export function randomString(min: number, max: number) { return randomBytes(randomInteger / 2).toString('hex'); } +export function randomApiKey() { + return randomBytes(20).toString('hex'); +} + const chooseRandomly = (array: T[]) => array[Math.floor(Math.random() * array.length)]; const randomDigit = () => Math.floor(Math.random() * 10); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 7c64dcfd90..3710375802 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -4,7 +4,7 @@ import { Credentials, UserSettings } from 'n8n-core'; import config = require('../../../config'); import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants'; import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src'; -import { randomEmail, randomName, randomString, randomValidPassword } from './random'; +import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random'; import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants'; @@ -185,13 +185,14 @@ export async function saveCredential( * Store a user in the DB, defaulting to a `member`. */ export async function createUser(attributes: Partial = {}): Promise { - const { email, password, firstName, lastName, globalRole, ...rest } = attributes; + const { email, password, firstName, lastName, globalRole, apiKey, ...rest } = attributes; const user = { email: email ?? randomEmail(), password: password ?? randomValidPassword(), firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), globalRole: globalRole ?? (await getGlobalMemberRole()), + apiKey: apiKey?? randomApiKey(), ...rest, }; diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index b160faea01..526f0887a7 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -13,7 +13,9 @@ export type SmtpTestAccount = { }; }; -type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials'; +export type ApiPath = 'internal' | 'public'; + +type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials' | 'publicApi'; export type CredentialPayload = { name: string; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 45f97cfabf..e616073a6a 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -11,7 +11,7 @@ import { INodeTypes, LoggerProxy } from 'n8n-workflow'; import { UserSettings } from 'n8n-core'; import config = require('../../../config'); -import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants'; +import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants'; import { AUTH_COOKIE_NAME } from '../../../src/constants'; import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; import { Db, ExternalHooks, InternalHooksManager } from '../../../src'; @@ -23,10 +23,10 @@ import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/U import { issueJWT } from '../../../src/UserManagement/auth/jwt'; import { getLogger } from '../../../src/Logger'; import { credentialsController } from '../../../src/api/credentials.api'; - +import { publicApiController } from '../../../src/PublicApi/v1/'; import type { User } from '../../../src/databases/entities/User'; import { Telemetry } from '../../../src/telemetry'; -import type { EndpointGroup, SmtpTestAccount } from './types'; +import type { ApiPath, EndpointGroup, SmtpTestAccount } from './types'; import type { N8nApp } from '../../../src/UserManagement/Interfaces'; /** @@ -45,6 +45,7 @@ export function initTestServer({ const testServer = { app: express(), restEndpoint: REST_PATH_SEGMENT, + publicApiEndpoint: PUBLIC_API_REST_PATH_SEGMENT, ...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}), }; @@ -65,10 +66,15 @@ export function initTestServer({ if (routerEndpoints.length) { const map: Record = { credentials: credentialsController, + publicApi: publicApiController, }; for (const group of routerEndpoints) { - testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]); + if (group === 'publicApi') { + testServer.app.use(`/${testServer.publicApiEndpoint}`, map[group]); + } else { + testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]); + } } } @@ -106,7 +112,7 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const functionEndpoints: string[] = []; endpointGroups.forEach((group) => - (group === 'credentials' ? routerEndpoints : functionEndpoints).push(group), + (group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push(group), ); return [routerEndpoints, functionEndpoints]; @@ -143,13 +149,24 @@ export function initConfigFile() { /** * Create a request agent, optionally with an auth cookie. */ -export function createAgent(app: express.Application, options?: { auth: true; user: User }) { +export function createAgent(app: express.Application, options?: { apiPath?: ApiPath, auth: boolean; user: User }) { const agent = request.agent(app); - agent.use(prefix(REST_PATH_SEGMENT)); - if (options?.auth && options?.user) { - const { token } = issueJWT(options.user); - agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + if (options?.apiPath === undefined || options?.apiPath === 'internal') { + agent.use(prefix(REST_PATH_SEGMENT)); + + if (options?.auth && options?.user) { + const { token } = issueJWT(options.user); + agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + } + } + + if (options?.apiPath === 'public') { + agent.use(prefix(PUBLIC_API_REST_PATH_SEGMENT)); + + if (options?.auth && options?.user.apiKey) { + agent.set({ 'X-N8N-API-KEY': options.user.apiKey }); + } } return agent; @@ -171,7 +188,6 @@ export function prefix(pathSegment: string) { url.pathname = pathSegment + url.pathname; request.url = url.toString(); - return request; }; }