feat(API): Implement users account quota guards (#6434)
* feat(cli): Implement users account quota guards Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Remove comment Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Address PR comments - Getting `usersQuota` from `Settings` repo - Revert `isUserManagementEnabled` helper - Fix FE listing of users Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Refactor isWithinUserQuota getter and fix tests Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Revert testDb.ts changes Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Cleanup & improve types Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Fix duplicated method * Fix failing test * Remove `isUserManagementEnabled` completely Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Check for globalRole.name to determine if user is owner Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Fix unit tests Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Set isInstanceOwnerSetUp in specs * Fix SettingsUserView UM Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * refactor: License typings suggestions for users quota guards (#6636) refactor: License typings suggestions * Update packages/cli/src/Ldap/helpers.ts Co-authored-by: Iván Ovejero <ivov.src@gmail.com> * Update packages/cli/test/integration/shared/utils.ts Co-authored-by: Iván Ovejero <ivov.src@gmail.com> * Address PR comments Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Use 403 for all user quota related errors Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> --------- Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<LICENSE_FEATURES, boolean> = {
|
||||
private enabledFeatures: Record<BooleanLicenseFeature, boolean> = {
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user