diff --git a/apps/meteor/ee/app/authorization/server/validateUserRoles.js b/apps/meteor/ee/app/authorization/server/validateUserRoles.js index fe8e3410bc01..6263fc51d694 100644 --- a/apps/meteor/ee/app/authorization/server/validateUserRoles.js +++ b/apps/meteor/ee/app/authorization/server/validateUserRoles.js @@ -1,29 +1,42 @@ import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { isEnterprise, getMaxGuestUsers } from '../../license/server'; +import { i18n } from '../../../../server/lib/i18n'; +import { isEnterprise, canAddNewGuestUser, canAddNewUser } from '../../license/server/license'; export const validateUserRoles = async function (userId, userData) { if (!isEnterprise()) { return; } - if (!userData.roles.includes('guest')) { + const isGuest = Boolean(userData.roles?.includes('guest') && userData.roles.length === 1); + const currentUserData = userData._id ? await Users.findOneById(userData._id) : null; + const wasGuest = Boolean(currentUserData.roles?.includes('guest') && currentUserData.roles.length === 1); + + if (currentUserData?.type === 'app') { return; } - if (userData.roles.length >= 2) { - throw new Meteor.Error('error-guests-cant-have-other-roles', "Guest users can't receive any other role", { - method: 'insertOrUpdateUser', - field: 'Assign_role', - }); + if (isGuest) { + if (wasGuest) { + return; + } + + if (!(await canAddNewGuestUser())) { + throw new Meteor.Error('error-max-guests-number-reached', 'Maximum number of guests reached.', { + method: 'insertOrUpdateUser', + field: 'Assign_role', + }); + } + + return; + } + + if (!wasGuest && userData._id) { + return; } - const guestCount = await Users.getActiveLocalGuestCount(userData._id); - if (guestCount >= getMaxGuestUsers()) { - throw new Meteor.Error('error-max-guests-number-reached', 'Maximum number of guests reached.', { - method: 'insertOrUpdateUser', - field: 'Assign_role', - }); + if (!(await canAddNewUser())) { + throw new Meteor.Error('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached')); } }; diff --git a/apps/meteor/ee/app/license/server/index.ts b/apps/meteor/ee/app/license/server/index.ts index f7d83ed388b8..f13e315d2866 100644 --- a/apps/meteor/ee/app/license/server/index.ts +++ b/apps/meteor/ee/app/license/server/index.ts @@ -2,6 +2,6 @@ import './settings'; import './methods'; import './startup'; -export { onLicense, overwriteClassOnLicense, isEnterprise, getMaxGuestUsers } from './license'; +export { onLicense, overwriteClassOnLicense, isEnterprise } from './license'; export { getStatistics } from './getStatistics'; diff --git a/apps/meteor/ee/app/license/server/lib/getAppCount.ts b/apps/meteor/ee/app/license/server/lib/getAppCount.ts new file mode 100644 index 000000000000..f408143218de --- /dev/null +++ b/apps/meteor/ee/app/license/server/lib/getAppCount.ts @@ -0,0 +1,21 @@ +import { Apps } from '@rocket.chat/core-services'; +import type { LicenseAppSources } from '@rocket.chat/core-typings'; + +import { getInstallationSourceFromAppStorageItem } from '../../../../../lib/apps/getInstallationSourceFromAppStorageItem'; + +export async function getAppCount(source: LicenseAppSources): Promise { + if (!(await Apps.isInitialized())) { + return 0; + } + + const apps = await Apps.getApps({ enabled: true }); + + if (!apps || !Array.isArray(apps)) { + return 0; + } + + const storageItems = await Promise.all(apps.map((app) => Apps.getAppStorageItemById(app.id))); + const activeAppsFromSameSource = storageItems.filter((item) => item && getInstallationSourceFromAppStorageItem(item) === source); + + return activeAppsFromSameSource.length; +} diff --git a/apps/meteor/ee/app/license/server/license.ts b/apps/meteor/ee/app/license/server/license.ts index 4da768b42e77..838afc143536 100644 --- a/apps/meteor/ee/app/license/server/license.ts +++ b/apps/meteor/ee/app/license/server/license.ts @@ -2,21 +2,23 @@ import { EventEmitter } from 'events'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; import { Apps } from '@rocket.chat/core-services'; -import type { ILicenseV2, ILicenseTag, ILicenseV3, Timestamp, LicenseBehavior } from '@rocket.chat/core-typings'; +import type { ILicenseV2, ILicenseTag, ILicenseV3, Timestamp, LicenseBehavior, IUser, LicenseLimitKind } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { Users } from '@rocket.chat/models'; +import { Users, Subscriptions } from '@rocket.chat/models'; import { getInstallationSourceFromAppStorageItem } from '../../../../lib/apps/getInstallationSourceFromAppStorageItem'; import type { BundleFeature } from './bundles'; import { getBundleModules, isBundle } from './bundles'; import decrypt from './decrypt'; import { fromV2toV3 } from './fromV2toV3'; -import { isUnderAppLimits } from './lib/isUnderAppLimits'; +import { getAppCount } from './lib/getAppCount'; const EnterpriseLicenses = new EventEmitter(); const logger = new Logger('License'); +type LimitContext = T extends 'roomsPerGuest' ? { userId: IUser['_id'] } : Record; + class LicenseClass { private url: string | null = null; @@ -248,12 +250,16 @@ class LicenseClass { ).reduce((prev, curr) => [...new Set([...prev, ...curr])], []); } - private async shouldPreventAction(action: keyof ILicenseV3['limits'], newCount = 1): Promise { + private async shouldPreventAction( + action: T, + context?: Partial>, + newCount = 1, + ): Promise { if (!this.valid) { return false; } - const currentValue = (await this.getCurrentValueForLicenseLimit(action)) + newCount; + const currentValue = (await this.getCurrentValueForLicenseLimit(action, context)) + newCount; return Boolean( this.license?.limits[action] ?.filter(({ behavior, max }) => behavior === 'prevent_action' && max >= 0) @@ -303,7 +309,10 @@ class LicenseClass { this.showLicense(); } - private async getCurrentValueForLicenseLimit(limitKey: keyof ILicenseV3['limits']): Promise { + private async getCurrentValueForLicenseLimit( + limitKey: T, + context?: Partial>, + ): Promise { switch (limitKey) { case 'activeUsers': return this.getCurrentActiveUsers(); @@ -313,6 +322,11 @@ class LicenseClass { return this.getCurrentPrivateAppsCount(); case 'marketplaceApps': return this.getCurrentMarketplaceAppsCount(); + case 'roomsPerGuest': + if (context?.userId) { + return Subscriptions.countByUserId(context.userId); + } + return 0; default: return 0; } @@ -323,22 +337,35 @@ class LicenseClass { } private async getCurrentGuestUsers(): Promise { - // #TODO: Load current count - return 0; + return Users.getActiveLocalGuestCount(); } private async getCurrentPrivateAppsCount(): Promise { - // #TODO: Load current count - return 0; + return getAppCount('private'); } private async getCurrentMarketplaceAppsCount(): Promise { - // #TODO: Load current count - return 0; + return getAppCount('marketplace'); } public async canAddNewUser(userCount = 1): Promise { - return !(await this.shouldPreventAction('activeUsers', userCount)); + return !(await this.shouldPreventAction('activeUsers', {}, userCount)); + } + + public async canAddNewGuestUser(guestCount = 1): Promise { + return !(await this.shouldPreventAction('guestUsers', {}, guestCount)); + } + + public async canAddNewPrivateApp(appCount = 1): Promise { + return !(await this.shouldPreventAction('privateApps', {}, appCount)); + } + + public async canAddNewMarketplaceApp(appCount = 1): Promise { + return !(await this.shouldPreventAction('marketplaceApps', {}, appCount)); + } + + public async canAddNewGuestSubscription(guest: IUser['_id'], roomCount = 1): Promise { + return !(await this.shouldPreventAction('roomsPerGuest', { userId: guest }, roomCount)); } public async canEnableApp(app: IAppStorageItem): Promise { @@ -352,7 +379,13 @@ class LicenseClass { return true; } - return isUnderAppLimits(getAppsConfig(), getInstallationSourceFromAppStorageItem(app)); + const source = getInstallationSourceFromAppStorageItem(app); + switch (source) { + case 'private': + return this.canAddNewPrivateApp(); + default: + return this.canAddNewMarketplaceApp(); + } } private showLicense(): void { @@ -378,13 +411,22 @@ class LicenseClass { console.log('-------------------------'); } - public getMaxActiveUsers(): number { - return (this.valid && this.license?.limits.activeUsers?.find(({ behavior }) => behavior === 'prevent_action')?.max) || 0; - } - public startedFairPolicy(): boolean { return Boolean(this.valid && this.inFairPolicy); } + + public getLicenseLimit(kind: LicenseLimitKind): number | undefined { + if (!this.valid || !this.license) { + return; + } + + const limitList = this.license.limits[kind]; + if (!limitList?.length) { + return; + } + + return Math.min(...limitList.map(({ max }) => max)); + } } const License = new LicenseClass(); @@ -445,19 +487,9 @@ export function isEnterprise(): boolean { return License.hasValidLicense(); } -export function getMaxGuestUsers(): number { - // #TODO: Adjust any place currently using this function to stop doing so. - return 0; -} - -export function getMaxRoomsPerGuest(): number { - // #TODO: Adjust any place currently using this function to stop doing so. - return 0; -} - export function getMaxActiveUsers(): number { // #TODO: Adjust any place currently using this function to stop doing so. - return License.getMaxActiveUsers(); + return License.getLicenseLimit('activeUsers') ?? 0; } export function getUnmodifiedLicense(): ILicenseV3 | ILicenseV2 | undefined { @@ -475,8 +507,8 @@ export function getTags(): ILicenseTag[] { export function getAppsConfig(): NonNullable { // #TODO: Adjust any place currently using this function to stop doing so. return { - maxPrivateApps: -1, - maxMarketplaceApps: -1, + maxPrivateApps: License.getLicenseLimit('privateApps') ?? -1, + maxMarketplaceApps: License.getLicenseLimit('marketplaceApps') ?? -1, }; } @@ -484,6 +516,22 @@ export async function canAddNewUser(userCount = 1): Promise { return License.canAddNewUser(userCount); } +export async function canAddNewGuestUser(guestCount = 1): Promise { + return License.canAddNewGuestUser(guestCount); +} + +export async function canAddNewGuestSubscription(guest: IUser['_id'], roomCount = 1): Promise { + return License.canAddNewGuestSubscription(guest, roomCount); +} + +export async function canAddNewPrivateApp(appCount = 1): Promise { + return License.canAddNewPrivateApp(appCount); +} + +export async function canAddNewMarketplaceApp(appCount = 1): Promise { + return License.canAddNewMarketplaceApp(appCount); +} + export async function canEnableApp(app: IAppStorageItem): Promise { return License.canEnableApp(app); } diff --git a/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts b/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts index f4e2452ec806..9a8bac6d1d2a 100644 --- a/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts +++ b/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts @@ -1,17 +1,14 @@ -import { Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../../server/lib/i18n'; -import { getMaxRoomsPerGuest } from '../../app/license/server/license'; +import { canAddNewGuestSubscription } from '../../app/license/server/license'; callbacks.add( 'beforeAddedToRoom', async ({ user }) => { if (user.roles?.includes('guest')) { - const totalSubscriptions = await Subscriptions.countByUserId(user._id); - - if (totalSubscriptions >= getMaxRoomsPerGuest()) { + if (!(await canAddNewGuestSubscription(user._id))) { throw new Meteor.Error('error-max-rooms-per-guest-reached', i18n.t('error-max-rooms-per-guest-reached')); } } diff --git a/apps/meteor/ee/server/startup/seatsCap.ts b/apps/meteor/ee/server/startup/seatsCap.ts index b390539ad6b1..36ec066ab86f 100644 --- a/apps/meteor/ee/server/startup/seatsCap.ts +++ b/apps/meteor/ee/server/startup/seatsCap.ts @@ -62,31 +62,7 @@ callbacks.add( callbacks.add( 'validateUserRoles', - async (userData: Partial) => { - const isGuest = userData.roles?.includes('guest'); - if (isGuest) { - await validateUserRoles(Meteor.userId(), userData); - return; - } - - if (!userData._id) { - return; - } - - const currentUserData = await Users.findOneById(userData._id); - if (currentUserData?.type === 'app') { - return; - } - - const wasGuest = currentUserData?.roles?.length === 1 && currentUserData.roles.includes('guest'); - if (!wasGuest) { - return; - } - - if (!(await canAddNewUser())) { - throw new Meteor.Error('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached')); - } - }, + async (userData: Partial) => validateUserRoles(Meteor.userId(), userData), callbacks.priority.MEDIUM, 'check-max-user-seats', ); diff --git a/packages/core-typings/src/ee/ILicense/ILicenseV3.ts b/packages/core-typings/src/ee/ILicense/ILicenseV3.ts index 935e6fa62228..06a60dcb8c0f 100644 --- a/packages/core-typings/src/ee/ILicense/ILicenseV3.ts +++ b/packages/core-typings/src/ee/ILicense/ILicenseV3.ts @@ -90,3 +90,5 @@ export interface ILicenseV3 { }; cloudMeta?: Record; } + +export type LicenseLimitKind = keyof ILicenseV3['limits']; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index f8eda6a3639b..9a8693c762d6 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -371,7 +371,7 @@ export interface IUsersModel extends IBaseModel { getUsersToSendOfflineEmail(userIds: string[]): FindCursor>; countActiveUsersByService(service: string, options?: FindOptions): Promise; getActiveLocalUserCount(): Promise; - getActiveLocalGuestCount(): Promise; + getActiveLocalGuestCount(exceptions?: IUser['_id'] | IUser['_id'][]): Promise; removeOlderResumeTokensByUserId(userId: string, fromDate: Date): Promise; findAllUsersWithPendingAvatar(): FindCursor; updateCustomFieldsById(userId: string, customFields: Record): Promise;