diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 5b44f87b72..c57ee5df27 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -61,6 +61,7 @@ import type { WorkflowStatisticsRepository, WorkflowTagMappingRepository, } from '@db/repositories'; +import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants'; export interface IActivationError { time: number; @@ -716,6 +717,11 @@ export interface IExecutionTrackProperties extends ITelemetryTrackProperties { // license // ---------------------------------- +type ValuesOf = T[keyof T]; + +export type BooleanLicenseFeature = ValuesOf; +export type NumericLicenseFeature = ValuesOf; + export interface ILicenseReadResponse { usage: { executions: { diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 33ca43916a..664d730081 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -36,7 +36,9 @@ import { InternalServerError } from '../ResponseHelper'; /** * Check whether the LDAP feature is disabled in the instance */ -export const isLdapEnabled = () => Container.get(License).isLdapEnabled(); +export const isLdapEnabled = () => { + return Container.get(License).isLdapEnabled(); +}; /** * Check whether the LDAP feature is enabled in the instance diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 016e616d4a..ed27e8d6b9 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -9,8 +9,16 @@ import { LICENSE_QUOTAS, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY, + UNLIMITED_LICENSE_QUOTA, } from './constants'; import { Service } from 'typedi'; +import type { BooleanLicenseFeature, NumericLicenseFeature } from './Interfaces'; + +type FeatureReturnType = Partial< + { + planName: string; + } & { [K in NumericLicenseFeature]: number } & { [K in BooleanLicenseFeature]: boolean } +>; @Service() export class License { @@ -96,12 +104,8 @@ export class License { await this.manager.renew(); } - isFeatureEnabled(feature: LICENSE_FEATURES): boolean { - if (!this.manager) { - return false; - } - - return this.manager.hasFeatureEnabled(feature); + isFeatureEnabled(feature: BooleanLicenseFeature) { + return this.manager?.hasFeatureEnabled(feature) ?? false; } isSharingEnabled() { @@ -140,15 +144,8 @@ export class License { return this.manager?.getCurrentEntitlements() ?? []; } - getFeatureValue( - feature: string, - requireValidCert?: boolean, - ): undefined | boolean | number | string { - if (!this.manager) { - return undefined; - } - - return this.manager.getFeatureValue(feature, requireValidCert); + getFeatureValue(feature: T): FeatureReturnType[T] { + return this.manager?.getFeatureValue(feature) as FeatureReturnType[T]; } getManagementJwt(): string { @@ -177,20 +174,20 @@ export class License { } // Helper functions for computed data - getTriggerLimit(): number { - return (this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? -1) as number; + getUsersLimit() { + return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; } - getVariablesLimit(): number { - return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number; + getTriggerLimit() { + return this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; } - getUsersLimit(): number { - return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) as number; + getVariablesLimit() { + return this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; } getPlanName(): string { - return (this.getFeatureValue('planName') ?? 'Community') as string; + return this.getFeatureValue('planName') ?? 'Community'; } getInfo(): string { @@ -200,4 +197,8 @@ export class License { return this.manager.toString(); } + + isWithinUsersLimit() { + return this.getUsersLimit() === UNLIMITED_LICENSE_QUOTA; + } } diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts index 0d1536cffa..69c2fa8515 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts @@ -1,18 +1,9 @@ import { Container } from 'typedi'; -import { RoleRepository, UserRepository } from '@db/repositories'; -import type { Role } from '@db/entities/Role'; +import { UserRepository } from '@db/repositories'; import type { User } from '@db/entities/User'; import pick from 'lodash/pick'; import { validate as uuidValidate } from 'uuid'; -export function isInstanceOwner(user: User): boolean { - return user.globalRole.name === 'owner'; -} - -export async function getWorkflowOwnerRole(): Promise { - return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); -} - export const getSelectableProperties = (table: 'user' | 'role'): string[] => { return { user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'], diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts new file mode 100644 index 0000000000..bccab33bb9 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts @@ -0,0 +1,7 @@ +import { Container } from 'typedi'; +import { RoleRepository } from '@db/repositories'; +import type { Role } from '@db/entities/Role'; + +export async function getWorkflowOwnerRole(): Promise { + return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); +} diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index ee4cd38b9c..7305b23cb7 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -11,7 +11,7 @@ import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers'; import type { WorkflowRequest } from '../../../types'; import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; import { encodeNextCursor } from '../../shared/services/pagination.service'; -import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service.ee'; +import { getWorkflowOwnerRole } from '../users/users.service'; import { getWorkflowById, getSharedWorkflow, @@ -101,7 +101,7 @@ export = { ...(active !== undefined && { active }), }; - if (isInstanceOwner(req.user)) { + if (req.user.isOwner) { if (tags) { const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags)); where.id = In(workflowIds); diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index cfcedcbad4..df9515ecdb 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -8,7 +8,6 @@ import * as Db from '@/Db'; import type { User } from '@db/entities/User'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import { isInstanceOwner } from '../users/users.service.ee'; import type { Role } from '@db/entities/Role'; import config from '@/config'; import { START_NODES } from '@/constants'; @@ -32,7 +31,7 @@ export async function getSharedWorkflow( ): Promise { return Db.collections.SharedWorkflow.findOne({ where: { - ...(!isInstanceOwner(user) && { userId: user.id }), + ...(!user.isOwner && { userId: user.id }), ...(workflowId && { workflowId }), }, relations: [...insertIf(!config.getEnv('workflowTagsDisabled'), ['workflow.tags']), 'workflow'], @@ -48,7 +47,7 @@ export async function getSharedWorkflows( ): Promise { return Db.collections.SharedWorkflow.find({ where: { - ...(!isInstanceOwner(user) && { userId: user.id }), + ...(!user.isOwner && { userId: user.id }), ...(options.workflowIds && { workflowId: In(options.workflowIds) }), }, ...(options.relations && { relations: options.relations }), diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b981b6d1c7..9f0ec4c58b 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -149,6 +149,7 @@ import { PostHogClient } from './posthog'; import { eventBus } from './eventbus'; import { Container } from 'typedi'; import { InternalHooks } from './InternalHooks'; +import { License } from './License'; import { getStatusUsingPreviousExecutionStatusMethod, isAdvancedExecutionFiltersEnabled, @@ -259,6 +260,7 @@ export class Server extends AbstractServer { config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'), defaultLocale: config.getEnv('defaultLocale'), userManagement: { + quota: Container.get(License).getUsersLimit(), showSetupOnFirstLoad: config.getEnv('userManagement.isInstanceOwnerSetUp') === false, smtpSetup: isEmailSetUp(), authenticationMethod: getCurrentAuthenticationMethod(), @@ -407,6 +409,7 @@ export class Server extends AbstractServer { getSettingsForFrontend(): IN8nUISettings { // refresh user management status Object.assign(this.frontendSettings.userManagement, { + quota: Container.get(License).getUsersLimit(), authenticationMethod: getCurrentAuthenticationMethod(), showSetupOnFirstLoad: config.getEnv('userManagement.isInstanceOwnerSetUp') === false && diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 3494bef2bc..1f5a3c09aa 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -12,8 +12,8 @@ import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; import type { Role } from '@db/entities/Role'; import { RoleRepository } from '@db/repositories'; import config from '@/config'; -import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { License } from '@/License'; +import { getWebhookBaseUrl } from '@/WebhookHelpers'; import type { PostHogClient } from '@/posthog'; export async function getWorkflowOwner(workflowId: string): Promise { diff --git a/packages/cli/src/auth/jwt.ts b/packages/cli/src/auth/jwt.ts index 5a1db63f03..1fb305cfe6 100644 --- a/packages/cli/src/auth/jwt.ts +++ b/packages/cli/src/auth/jwt.ts @@ -4,15 +4,18 @@ import jwt from 'jsonwebtoken'; import type { Response } from 'express'; import { createHash } from 'crypto'; import * as Db from '@/Db'; -import { AUTH_COOKIE_NAME } from '@/constants'; +import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { JwtPayload, JwtToken } from '@/Interfaces'; import type { User } from '@db/entities/User'; import config from '@/config'; import * as ResponseHelper from '@/ResponseHelper'; +import { License } from '@/License'; +import { Container } from 'typedi'; export function issueJWT(user: User): JwtToken { const { id, email, password } = user; const expiresIn = 7 * 86400000; // 7 days + const isWithinUsersLimit = Container.get(License).isWithinUsersLimit(); const payload: JwtPayload = { id, @@ -20,6 +23,13 @@ export function issueJWT(user: User): JwtToken { password: password ?? null, }; + if ( + config.getEnv('userManagement.isInstanceOwnerSetUp') && + !user.isOwner && + !isWithinUsersLimit + ) { + throw new ResponseHelper.UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + } if (password) { payload.password = createHash('sha256') .update(password.slice(password.length / 2)) diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index d13ae9a26b..a871d7e5ed 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -47,6 +47,7 @@ export const RESPONSE_ERROR_MESSAGES = { PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes', PACKAGE_LOADING_FAILED: 'The specified package could not be loaded', DISK_IS_FULL: 'There appears to be insufficient disk space', + USERS_QUOTA_REACHED: 'Maximum number of users reached', }; export const AUTH_COOKIE_NAME = 'n8n-auth'; @@ -68,21 +69,22 @@ export const WORKFLOW_REACTIVATE_MAX_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day export const SETTINGS_LICENSE_CERT_KEY = 'license.cert'; -export const enum LICENSE_FEATURES { - SHARING = 'feat:sharing', - LDAP = 'feat:ldap', - SAML = 'feat:saml', - LOG_STREAMING = 'feat:logStreaming', - ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters', - VARIABLES = 'feat:variables', - SOURCE_CONTROL = 'feat:sourceControl', - API_DISABLED = 'feat:apiDisabled', -} +export const LICENSE_FEATURES = { + SHARING: 'feat:sharing', + LDAP: 'feat:ldap', + SAML: 'feat:saml', + LOG_STREAMING: 'feat:logStreaming', + ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters', + VARIABLES: 'feat:variables', + SOURCE_CONTROL: 'feat:sourceControl', + API_DISABLED: 'feat:apiDisabled', +} as const; -export const enum LICENSE_QUOTAS { - TRIGGER_LIMIT = 'quota:activeWorkflows', - VARIABLES_LIMIT = 'quota:maxVariables', - USERS_LIMIT = 'quota:users', -} +export const LICENSE_QUOTAS = { + TRIGGER_LIMIT: 'quota:activeWorkflows', + VARIABLES_LIMIT: 'quota:maxVariables', + USERS_LIMIT: 'quota:users', +} as const; +export const UNLIMITED_LICENSE_QUOTA = -1; export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 5ae9fe0d7a..6bbe891886 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -1,9 +1,14 @@ import validator from 'validator'; import { Authorized, Get, Post, RestController } from '@/decorators'; -import { AuthError, BadRequestError, InternalServerError } from '@/ResponseHelper'; +import { + AuthError, + BadRequestError, + InternalServerError, + UnauthorizedError, +} from '@/ResponseHelper'; import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper'; import { issueCookie, resolveJwt } from '@/auth/jwt'; -import { AUTH_COOKIE_NAME } from '@/constants'; +import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants'; import { Request, Response } from 'express'; import type { ILogger } from 'n8n-workflow'; import type { User } from '@db/entities/User'; @@ -26,6 +31,7 @@ import { import type { UserRepository } from '@db/repositories'; import { InternalHooks } from '../InternalHooks'; import Container from 'typedi'; +import { License } from '@/License'; @RestController() export class AuthController { @@ -71,7 +77,6 @@ export class AuthController { let user: User | undefined; let usedAuthenticationMethod = getCurrentAuthenticationMethod(); - if (isSamlCurrentAuthenticationMethod()) { // attempt to fetch user data with the credentials, but don't log in yet const preliminaryUser = await handleEmailLogin(email, password); @@ -120,6 +125,7 @@ export class AuthController { // If logged in, return user try { user = await resolveJwt(cookieContents); + return await withFeatureFlags(this.postHog, sanitizeUser(user)); } catch (error) { res.clearCookie(AUTH_COOKIE_NAME); @@ -155,6 +161,15 @@ export class AuthController { @Get('/resolve-signup-token') async resolveSignupToken(req: UserRequest.ResolveSignUp) { const { inviterId, inviteeId } = req.query; + const isWithinUsersLimit = Container.get(License).isWithinUsersLimit(); + + if (!isWithinUsersLimit) { + this.logger.debug('Request to resolve signup token failed because of users quota reached', { + inviterId, + inviteeId, + }); + throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + } if (!inviterId || !inviteeId) { this.logger.debug( diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 0d1837a11e..573b722e1b 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -11,6 +11,7 @@ import { License } from '@/License'; import { LICENSE_FEATURES, inE2ETests } from '@/constants'; import { NoAuthRequired, Patch, Post, RestController } from '@/decorators'; import type { UserSetupPayload } from '@/requests'; +import type { BooleanLicenseFeature } from '@/Interfaces'; if (!inE2ETests) { console.error('E2E endpoints only allowed during E2E tests'); @@ -51,7 +52,7 @@ type ResetRequest = Request< @NoAuthRequired() @RestController('/e2e') export class E2EController { - private enabledFeatures: Record = { + private enabledFeatures: Record = { [LICENSE_FEATURES.SHARING]: false, [LICENSE_FEATURES.LDAP]: false, [LICENSE_FEATURES.SAML]: false, @@ -69,7 +70,7 @@ export class E2EController { private userRepo: UserRepository, private workflowRunner: ActiveWorkflowRunner, ) { - license.isFeatureEnabled = (feature: LICENSE_FEATURES) => + license.isFeatureEnabled = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false; } @@ -84,14 +85,14 @@ export class E2EController { } @Patch('/feature') - setFeature(req: Request<{}, {}, { feature: LICENSE_FEATURES; enabled: boolean }>) { + setFeature(req: Request<{}, {}, { feature: BooleanLicenseFeature; enabled: boolean }>) { const { enabled, feature } = req.body; this.enabledFeatures[feature] = enabled; } private resetFeatures() { for (const feature of Object.keys(this.enabledFeatures)) { - this.enabledFeatures[feature as LICENSE_FEATURES] = false; + this.enabledFeatures[feature as BooleanLicenseFeature] = false; } } diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index aaaf72896d..afb4bf69a5 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -23,8 +23,11 @@ import { PasswordResetRequest } from '@/requests'; import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import { issueCookie } from '@/auth/jwt'; import { isLdapEnabled } from '@/Ldap/helpers'; -import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; -import { UserService } from '../user/user.service'; +import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; +import { UserService } from '@/user/user.service'; +import { License } from '@/License'; +import { Container } from 'typedi'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; @RestController() export class PasswordResetController { @@ -103,6 +106,12 @@ export class PasswordResetController { relations: ['authIdentities', 'globalRole'], }); + if (!user?.isOwner && !Container.get(License).isWithinUsersLimit()) { + this.logger.debug( + 'Request to send password reset email failed because the user limit was reached', + ); + throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + } if ( isSamlCurrentAuthenticationMethod() && !(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true) @@ -116,7 +125,6 @@ export class PasswordResetController { } const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); - if (!user?.password || (ldapIdentity && user.disabled)) { this.logger.debug( 'Request to send password reset email failed because no user was found for the provided email', @@ -182,12 +190,21 @@ export class PasswordResetController { // Timestamp is saved in seconds const currentTimestamp = Math.floor(Date.now() / 1000); - const user = await this.userRepository.findOneBy({ - id, - resetPasswordToken, - resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + const user = await this.userRepository.findOne({ + where: { + id, + resetPasswordToken, + resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + }, + relations: ['globalRole'], }); - + if (!user?.isOwner && !Container.get(License).isWithinUsersLimit()) { + this.logger.debug( + 'Request to resolve password token failed because the user limit was reached', + { userId: id }, + ); + throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + } if (!user) { this.logger.debug( 'Request to resolve password token failed because no user was found for the provided user ID and reset password token', diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 4b5724b6bc..d97aa55fd4 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -17,7 +17,12 @@ import { withFeatureFlags, } from '@/UserManagement/UserManagementHelper'; import { issueCookie } from '@/auth/jwt'; -import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper'; +import { + BadRequestError, + InternalServerError, + NotFoundError, + UnauthorizedError, +} from '@/ResponseHelper'; import { Response } from 'express'; import type { Config } from '@/config'; import { UserRequest, UserSettingsUpdatePayload } from '@/requests'; @@ -39,8 +44,11 @@ import type { SharedWorkflowRepository, UserRepository, } from '@db/repositories'; -import { UserService } from '../user/user.service'; +import { UserService } from '@/user/user.service'; import { plainToInstance } from 'class-transformer'; +import { License } from '@/License'; +import { Container } from 'typedi'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; @Authorized(['global', 'owner']) @RestController('/users') @@ -107,6 +115,8 @@ export class UsersController { */ @Post('/') async sendEmailInvites(req: UserRequest.Invite) { + const isWithinUsersLimit = Container.get(License).isWithinUsersLimit(); + if (isSamlLicensedAndEnabled()) { this.logger.debug( 'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites', @@ -116,6 +126,13 @@ export class UsersController { ); } + if (!isWithinUsersLimit) { + this.logger.debug( + 'Request to send email invite(s) to user(s) failed because the user limit quota has been reached', + ); + throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + } + if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) { this.logger.debug( 'Request to send email invite(s) to user(s) failed because the owner account is not set up', @@ -551,6 +568,14 @@ export class UsersController { @Post('/:id/reinvite') async reinviteUser(req: UserRequest.Reinvite) { const { id: idToReinvite } = req.params; + const isWithinUsersLimit = Container.get(License).isWithinUsersLimit(); + + if (!isWithinUsersLimit) { + this.logger.debug( + 'Request to send email invite(s) to user(s) failed because the user limit quota has been reached', + ); + throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + } if (!isEmailSetUp()) { this.logger.error('Request to reinvite a user failed because email sending was not set up'); diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 1aa334d489..1ee1570f8f 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -113,4 +113,14 @@ export class User extends AbstractEntity implements IUser { computeIsPending(): void { this.isPending = this.password === null; } + + /** + * Whether the user is instance owner + */ + isOwner: boolean; + + @AfterLoad() + computeIsOwner(): void { + this.isOwner = this.globalRole?.name === 'owner'; + } } diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 04c98cb2bb..46106f7bfb 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -9,7 +9,6 @@ import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; import { LicenseService } from './License.service'; import { License } from '@/License'; import type { AuthenticatedRequest, LicenseRequest } from '@/requests'; -import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service.ee'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; @@ -34,7 +33,7 @@ licenseController.use((req, res, next) => { */ licenseController.use((req: AuthenticatedRequest, res, next) => { if (OWNER_ROUTES.includes(req.path) && req.user) { - if (!isInstanceOwner(req.user)) { + if (!req.user.isOwner) { LoggerProxy.info('Non-owner attempted to activate or renew a license', { userId: req.user.id, }); diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 3fec3edcf8..72289e79e3 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -77,9 +77,6 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => { } return; } - - // Handle authentication - try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const authCookie: string = req.cookies?.[AUTH_COOKIE_NAME] ?? ''; diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index 2de5438624..d2d1c26a14 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -52,7 +52,9 @@ export function setSamlLoginLabel(label: string): void { config.set(SAML_LOGIN_LABEL, label); } -export const isSamlLicensed = () => Container.get(License).isSamlEnabled(); +export function isSamlLicensed(): boolean { + return Container.get(License).isSamlEnabled(); +} export function isSamlLicensedAndEnabled(): boolean { return isSamlLoginEnabled() && isSamlLicensed() && isSamlCurrentAuthenticationMethod(); diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index e81cf06aa3..eb66ff492f 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -1,5 +1,7 @@ import type { Application } from 'express'; import type { SuperAgentTest } from 'supertest'; +import { Container } from 'typedi'; +import { License } from '@/License'; import validator from 'validator'; import config from '@/config'; import * as Db from '@/Db'; @@ -84,6 +86,26 @@ describe('POST /login', () => { const authToken = utils.getAuthToken(response); expect(authToken).toBeDefined(); }); + + test('should throw AuthError for non-owner if not within users limit quota', async () => { + jest.spyOn(Container.get(License), 'isWithinUsersLimit').mockReturnValueOnce(false); + const member = await testDb.createUserShell(globalMemberRole); + + const response = await authAgent(member).get('/login'); + expect(response.statusCode).toBe(401); + }); + + test('should not throw AuthError for owner if not within users limit quota', async () => { + jest.spyOn(Container.get(License), 'isWithinUsersLimit').mockReturnValueOnce(false); + const ownerUser = await testDb.createUser({ + password: randomValidPassword(), + globalRole: globalOwnerRole, + isOwner: true, + }); + + const response = await authAgent(ownerUser).get('/login'); + expect(response.statusCode).toBe(200); + }); }); describe('GET /login', () => { @@ -292,6 +314,18 @@ describe('GET /resolve-signup-token', () => { }); }); + test('should return 403 if user quota reached', async () => { + jest.spyOn(Container.get(License), 'isWithinUsersLimit').mockReturnValueOnce(false); + const memberShell = await testDb.createUserShell(globalMemberRole); + + const response = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId: memberShell.id }); + + expect(response.statusCode).toBe(403); + }); + test('should fail with invalid inputs', async () => { const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole }); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index 1c4c5b72a6..4ae07c11e8 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -5,6 +5,7 @@ import { } from './shared/constants'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils'; +import config from '@/config'; let authlessAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; @@ -16,6 +17,8 @@ beforeAll(async () => { authlessAgent = utils.createAgent(app); authMemberAgent = utils.createAuthAgent(app)(member); + + config.set('userManagement.isInstanceOwnerSetUp', true); }); afterAll(async () => { diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index 32154d362e..cf7ab47929 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -13,6 +13,7 @@ import { randomCredentialPayload } from './shared/random'; import * as testDb from './shared/testDb'; import type { AuthAgent, SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils'; +import config from '@/config'; let globalMemberRole: Role; let owner: User; @@ -39,6 +40,7 @@ beforeAll(async () => { saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); + config.set('userManagement.isInstanceOwnerSetUp', true); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index 597f4528e0..b5fa486810 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -45,6 +45,8 @@ beforeAll(async () => { authAgent = utils.createAuthAgent(app); authOwnerAgent = authAgent(owner); authMemberAgent = authAgent(member); + + config.set('userManagement.isInstanceOwnerSetUp', true); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 3b45f507f4..b4938794eb 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -26,6 +26,7 @@ beforeAll(async () => { authOwnerAgent = authAgent(owner); authMemberAgent = authAgent(member); + config.set('userManagement.isInstanceOwnerSetUp', true); config.set('license.serverUrl', MOCK_SERVER_URL); config.set('license.autoRenewEnabled', true); config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET); diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index a9bda3c53f..99ac51b006 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -11,6 +11,7 @@ import * as utils from '../shared/utils'; import { sampleConfig } from './sampleMetadata'; import { InternalHooks } from '@/InternalHooks'; import { SamlService } from '@/sso/saml/saml.service.ee'; +import config from '@/config'; import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; import type { AuthenticationMethod } from 'n8n-workflow'; @@ -32,6 +33,8 @@ beforeAll(async () => { authOwnerAgent = utils.createAuthAgent(app)(owner); authMemberAgent = utils.createAgent(app, { auth: true, user: someUser }); noAuthMemberAgent = utils.createAgent(app, { auth: false, user: someUser }); + + config.set('userManagement.isInstanceOwnerSetUp', true); }); afterAll(async () => { diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index c9df9026b8..7a537f3ebe 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -181,6 +181,7 @@ export async function createUser(attributes: Partial = {}): Promise firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id, + globalRole, ...rest, }; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 54cef95a21..0053c105d6 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -683,8 +683,10 @@ export function createAgent( if (options?.apiPath === undefined || options?.apiPath === 'internal') { void agent.use(prefix(REST_PATH_SEGMENT)); if (options?.auth && options?.user) { - const { token } = issueJWT(options.user); - agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + try { + const { token } = issueJWT(options.user); + agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + } catch {} } } diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index abe70572a8..c64dd32187 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -3,6 +3,7 @@ import type { Application } from 'express'; import type { User } from '@/databases/entities/User'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils'; +import config from '@/config'; import type { AuthAgent } from './shared/types'; import { License } from '@/License'; @@ -16,6 +17,7 @@ let variablesSpy: jest.SpyInstance; const licenseLike = { isVariablesEnabled: jest.fn().mockReturnValue(true), getVariablesLimit: jest.fn().mockReturnValue(-1), + isWithinUsersLimit: jest.fn().mockReturnValue(true), }; beforeAll(async () => { @@ -28,6 +30,7 @@ beforeAll(async () => { memberUser = await testDb.createUser(); authAgent = utils.createAuthAgent(app); + config.set('userManagement.isInstanceOwnerSetUp', true); }); beforeEach(async () => { diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index f84a6449ae..1b2742a66a 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -14,6 +14,7 @@ import { makeWorkflow } from './shared/utils'; import { randomCredentialPayload } from './shared/random'; import { License } from '@/License'; import { getSharedWorkflowIds } from '../../src/WorkflowHelpers'; +import config from '@/config'; let owner: User; let member: User; @@ -45,6 +46,7 @@ beforeAll(async () => { sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); await utils.initNodeTypes(); + config.set('userManagement.isInstanceOwnerSetUp', true); }); beforeEach(async () => { diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 32351f2e27..c1ec4e7d7a 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -9,7 +9,7 @@ const MOCK_SERVER_URL = 'https://server.com/v1'; const MOCK_RENEW_OFFSET = 259200; const MOCK_INSTANCE_ID = 'instance-id'; const MOCK_ACTIVATION_KEY = 'activation-key'; -const MOCK_FEATURE_FLAG = 'feat:mock'; +const MOCK_FEATURE_FLAG = 'feat:sharing'; const MOCK_MAIN_PLAN_ID = '1b765dc4-d39d-4ffe-9885-c56dd67c4b26'; describe('License', () => { @@ -71,9 +71,9 @@ describe('License', () => { }); test('check fetching feature values', async () => { - await license.getFeatureValue(MOCK_FEATURE_FLAG, false); + license.getFeatureValue(MOCK_FEATURE_FLAG); - expect(LicenseManager.prototype.getFeatureValue).toHaveBeenCalledWith(MOCK_FEATURE_FLAG, false); + expect(LicenseManager.prototype.getFeatureValue).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); }); test('check management jwt', async () => { diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index c846b7753b..2e98811b17 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -37,6 +37,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { settings: {} as IN8nUISettings, promptsData: {} as IN8nPrompts, userManagement: { + quota: -1, showSetupOnFirstLoad: false, smtpSetup: false, authenticationMethod: UserManagementAuthenticationMethod.Email, @@ -169,6 +170,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { isDefaultAuthenticationSaml(): boolean { return this.userManagement.authenticationMethod === UserManagementAuthenticationMethod.Saml; }, + isBelowUserQuota(): boolean { + const userStore = useUsersStore(); + return ( + this.userManagement.quota === -1 || this.userManagement.quota > userStore.allUsers.length + ); + }, }, actions: { setSettings(settings: IN8nUISettings): void { diff --git a/packages/editor-ui/src/views/SettingsUsersView.vue b/packages/editor-ui/src/views/SettingsUsersView.vue index 379d95e240..e84c4d83fc 100644 --- a/packages/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/editor-ui/src/views/SettingsUsersView.vue @@ -9,7 +9,7 @@
-
+
-
+ +
+
@@ -83,12 +107,16 @@ export default defineComponent({ { label: this.$locale.baseText('settings.users.actions.copyInviteLink'), value: 'copyInviteLink', - guard: (user) => !user.firstName && !!user.inviteAcceptUrl, + guard: (user) => + this.settingsStore.isBelowUserQuota && !user.firstName && !!user.inviteAcceptUrl, }, { label: this.$locale.baseText('settings.users.actions.reinvite'), value: 'reinvite', - guard: (user) => !user.firstName && this.settingsStore.isSmtpSetup, + guard: (user) => + this.settingsStore.isBelowUserQuota && + !user.firstName && + this.settingsStore.isSmtpSetup, }, { label: this.$locale.baseText('settings.users.actions.delete'), @@ -97,6 +125,7 @@ export default defineComponent({ { label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'), value: 'copyPasswordResetLink', + guard: () => this.settingsStore.isBelowUserQuota, }, { label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'), diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 8dd3792bee..7252688b66 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2056,6 +2056,7 @@ export interface IVersionNotificationSettings { } export interface IUserManagementSettings { + quota: number; showSetupOnFirstLoad?: boolean; smtpSetup: boolean; authenticationMethod: AuthenticationMethod;