diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 11d0ea6cd5528..ce46bc994b6a0 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -35,7 +35,7 @@ import type PCancelable from 'p-cancelable'; import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { TagEntity } from '@db/entities/TagEntity'; -import type { GlobalRole, User } from '@db/entities/User'; +import type { AssignableRole, GlobalRole, User } from '@db/entities/User'; import type { CredentialsRepository } from '@db/repositories/credentials.repository'; import type { SettingsRepository } from '@db/repositories/settings.repository'; import type { UserRepository } from '@db/repositories/user.repository'; @@ -634,6 +634,11 @@ export interface PublicUser { featureFlags?: FeatureFlags; } +export interface Invitation { + email: string; + role: AssignableRole; +} + export interface N8nApp { app: Application; restEndpoint: string; diff --git a/packages/cli/src/databases/entities/AuthUser.ts b/packages/cli/src/databases/entities/AuthUser.ts index f9f8d89f8e9e3..dbfb8f83fd297 100644 --- a/packages/cli/src/databases/entities/AuthUser.ts +++ b/packages/cli/src/databases/entities/AuthUser.ts @@ -1,16 +1,8 @@ -import { Column, Entity, PrimaryColumn } from '@n8n/typeorm'; +import { Column, Entity } from '@n8n/typeorm'; +import { User } from './User'; @Entity({ name: 'user' }) -export class AuthUser { - @PrimaryColumn({ type: 'uuid', update: false }) - id: string; - - @Column({ type: String, update: false }) - email: string; - - @Column({ type: Boolean, default: false }) - mfaEnabled: boolean; - +export class AuthUser extends User { @Column({ type: String, nullable: true }) mfaSecret?: string | null; diff --git a/packages/cli/src/services/hooks.service.ts b/packages/cli/src/services/hooks.service.ts new file mode 100644 index 0000000000000..72d4b7f2612e5 --- /dev/null +++ b/packages/cli/src/services/hooks.service.ts @@ -0,0 +1,120 @@ +import { Service } from 'typedi'; +import type { NextFunction, Response } from 'express'; +import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; +import type { FindManyOptions, FindOneOptions, FindOptionsWhere } from '@n8n/typeorm'; + +import { AuthService } from '@/auth/auth.service'; +import type { AuthUser } from '@db/entities/AuthUser'; +import type { User } from '@db/entities/User'; +import { UserRepository } from '@db/repositories/user.repository'; +import { SettingsRepository } from '@db/repositories/settings.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; +import { AuthUserRepository } from '@db/repositories/authUser.repository'; +import type { Settings } from '@db/entities/Settings'; +import { UserService } from '@/services/user.service'; +import type { AuthenticatedRequest } from '@/requests'; +import type { Invitation } from '@/Interfaces'; + +/** + * Exposes functionality to be used by the cloud BE hooks. + * DO NOT DELETE or RENAME any of the methods without making sure this is not used in cloud BE hooks. + */ +@Service() +export class HooksService { + constructor( + private readonly userService: UserService, + private readonly authService: AuthService, + private readonly userRepository: UserRepository, + private readonly settingsRepository: SettingsRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly credentialsRepository: CredentialsRepository, + private readonly authUserRepository: AuthUserRepository, + ) {} + + /** + * Invite users to instance during signup + */ + async inviteUsers(owner: User, attributes: Invitation[]) { + return await this.userService.inviteUsers(owner, attributes); + } + + /** + * Set the n8n-auth cookie in the response to auto-login + * the user after instance is provisioned + */ + issueCookie(res: Response, user: AuthUser) { + return this.authService.issueCookie(res, user); + } + + /** + * Find user in the instance + * 1. To know whether the instance owner is already setup + * 2. To know when to update the user's profile also in cloud + */ + async findOneUser(filter: FindOneOptions) { + return await this.authUserRepository.findOne(filter); + } + + /** + * Save instance owner with the cloud signup data + */ + async saveUser(user: User) { + return await this.userRepository.save(user); + } + + /** + * Update instance's settings + * 1. To keep the state when users are invited to the instance + */ + async updateSettings(filter: FindOptionsWhere, set: QueryDeepPartialEntity) { + return await this.settingsRepository.update(filter, set); + } + + /** + * Count the number of workflows + * 1. To enforce the active workflow limits in cloud + */ + async workflowsCount(filter: FindManyOptions) { + return await this.workflowRepository.count(filter); + } + + /** + * Count the number of credentials + * 1. To enforce the max credential limits in cloud + */ + async credentialsCount(filter: FindManyOptions) { + return await this.credentialsRepository.count(filter); + } + + /** + * Count the number of occurrences of a specific key + * 1. To know when to stop attempting to invite users + */ + async settingsCount(filter: FindManyOptions) { + return await this.settingsRepository.count(filter); + } + + /** + * Add auth middleware to routes injected via the hooks + * 1. To authenticate the /proxy routes in the hooks + */ + async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) { + return await this.authService.authMiddleware(req, res, next); + } + + /** + * Return repositories to be used in the hooks + * 1. Some self-hosted users rely in the repositories to interact with the DB directly + */ + dbCollections() { + return { + User: this.userRepository, + Settings: this.settingsRepository, + Credentials: this.credentialsRepository, + Workflow: this.workflowRepository, + }; + } +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 24343c8957a2a..e9681d68da818 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -4,7 +4,7 @@ import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workf import type { User, AssignableRole } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; -import type { PublicUser } from '@/Interfaces'; +import type { Invitation, PublicUser } from '@/Interfaces'; import type { PostHogClient } from '@/posthog'; import { Logger } from '@/Logger'; import { UserManagementMailer } from '@/UserManagement/email'; @@ -178,14 +178,14 @@ export class UserService { ); } - async inviteUsers(owner: User, attributes: Array<{ email: string; role: AssignableRole }>) { - const emails = attributes.map(({ email }) => email); + async inviteUsers(owner: User, invitations: Invitation[]) { + const emails = invitations.map(({ email }) => email); const existingUsers = await this.userRepository.findManyByEmail(emails); const existUsersEmails = existingUsers.map((user) => user.email); - const toCreateUsers = attributes.filter(({ email }) => !existUsersEmails.includes(email)); + const toCreateUsers = invitations.filter(({ email }) => !existUsersEmails.includes(email)); const pendingUsersToInvite = existingUsers.filter((email) => email.isPending); @@ -222,7 +222,7 @@ export class UserService { const usersInvited = await this.sendEmails( owner, Object.fromEntries(createdUsers), - attributes[0].role, // same role for all invited users + invitations[0].role, // same role for all invited users ); return { usersInvited, usersCreated: toCreateUsers.map(({ email }) => email) }; diff --git a/packages/cli/test/unit/services/hooks.service.test.ts b/packages/cli/test/unit/services/hooks.service.test.ts new file mode 100644 index 0000000000000..406ede0374d4d --- /dev/null +++ b/packages/cli/test/unit/services/hooks.service.test.ts @@ -0,0 +1,151 @@ +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; + +import type { AuthUser } from '@db/entities/AuthUser'; +import type { CredentialsRepository } from '@db/repositories/credentials.repository'; +import type { SettingsRepository } from '@db/repositories/settings.repository'; +import type { UserRepository } from '@db/repositories/user.repository'; +import type { WorkflowRepository } from '@db/repositories/workflow.repository'; +import type { AuthService } from '@/auth/auth.service'; +import type { UserService } from '@/services/user.service'; +import { HooksService } from '@/services/hooks.service'; +import type { Invitation } from '@/Interfaces'; +import type { AuthenticatedRequest } from '@/requests'; +import type { AuthUserRepository } from '@/databases/repositories/authUser.repository'; + +describe('HooksService', () => { + const mockedUser = mock(); + const userService = mock(); + const authService = mock(); + const userRepository = mock(); + const settingsRepository = mock(); + const workflowRepository = mock(); + const credentialsRepository = mock(); + const authUserRepository = mock(); + const hooksService = new HooksService( + userService, + authService, + userRepository, + settingsRepository, + workflowRepository, + credentialsRepository, + authUserRepository, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('hooksService.inviteUsers should call userService.inviteUsers', async () => { + // ARRANGE + const usersToInvite: Invitation[] = [{ email: 'test@n8n.io', role: 'global:member' }]; + + // ACT + await hooksService.inviteUsers(mockedUser, usersToInvite); + + // ASSERT + expect(userService.inviteUsers).toHaveBeenCalledWith(mockedUser, usersToInvite); + }); + + it('hooksService.issueCookie should call authService.issueCookie', async () => { + // ARRANGE + const res = mock(); + + // ACT + hooksService.issueCookie(res, mockedUser); + + // ASSERT + expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser); + }); + + it('hooksService.findOneUser should call authUserRepository.findOne', async () => { + // ARRANGE + const filter = { where: { id: '1' } }; + + // ACT + await hooksService.findOneUser(filter); + + // ASSERT + expect(authUserRepository.findOne).toHaveBeenCalledWith(filter); + }); + + it('hooksService.saveUser should call userRepository.save', async () => { + // ACT + await hooksService.saveUser(mockedUser); + + // ASSERT + + expect(userRepository.save).toHaveBeenCalledWith(mockedUser); + }); + + it('hooksService.updateSettings should call settingRepository.update', async () => { + // ARRANGE + const filter = { key: 'test' }; + const set = { value: 'true' }; + + // ACT + await hooksService.updateSettings(filter, set); + + // ASSERT + expect(settingsRepository.update).toHaveBeenCalledWith(filter, set); + }); + + it('hooksService.workflowsCount should call workflowRepository.count', async () => { + // ARRANGE + const filter = { where: { active: true } }; + + // ACT + await hooksService.workflowsCount(filter); + + // ASSERT + expect(workflowRepository.count).toHaveBeenCalledWith(filter); + }); + + it('hooksService.credentialsCount should call credentialRepository.count', async () => { + // ARRANGE + const filter = { where: {} }; + + // ACT + await hooksService.credentialsCount(filter); + + // ASSERT + expect(credentialsRepository.count).toHaveBeenCalledWith(filter); + }); + + it('hooksService.settingsCount should call settingsRepository.count', async () => { + // ARRANGE + const filter = { where: { key: 'test' } }; + + // ACT + await hooksService.settingsCount(filter); + + // ASSERT + expect(settingsRepository.count).toHaveBeenCalledWith(filter); + }); + + it('hooksService.authMiddleware should call authService.authMiddleware', async () => { + // ARRANGE + const res = mock(); + + const req = mock(); + + const next = jest.fn(); + + // ACT + await hooksService.authMiddleware(req, res, next); + + // ASSERT + expect(authService.authMiddleware).toHaveBeenCalledWith(req, res, next); + }); + + it('hooksService.dbCollections should return valid repositories', async () => { + // ACT + const collections = hooksService.dbCollections(); + + // ASSERT + expect(collections).toHaveProperty('User'); + expect(collections).toHaveProperty('Settings'); + expect(collections).toHaveProperty('Credentials'); + expect(collections).toHaveProperty('Workflow'); + }); +});