mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
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:
parent
26046f6fe8
commit
e5620ab1e4
|
@ -61,6 +61,7 @@ import type {
|
||||||
WorkflowStatisticsRepository,
|
WorkflowStatisticsRepository,
|
||||||
WorkflowTagMappingRepository,
|
WorkflowTagMappingRepository,
|
||||||
} from '@db/repositories';
|
} from '@db/repositories';
|
||||||
|
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
|
||||||
|
|
||||||
export interface IActivationError {
|
export interface IActivationError {
|
||||||
time: number;
|
time: number;
|
||||||
|
@ -716,6 +717,11 @@ export interface IExecutionTrackProperties extends ITelemetryTrackProperties {
|
||||||
// license
|
// license
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
|
type ValuesOf<T> = T[keyof T];
|
||||||
|
|
||||||
|
export type BooleanLicenseFeature = ValuesOf<typeof LICENSE_FEATURES>;
|
||||||
|
export type NumericLicenseFeature = ValuesOf<typeof LICENSE_QUOTAS>;
|
||||||
|
|
||||||
export interface ILicenseReadResponse {
|
export interface ILicenseReadResponse {
|
||||||
usage: {
|
usage: {
|
||||||
executions: {
|
executions: {
|
||||||
|
|
|
@ -36,7 +36,9 @@ import { InternalServerError } from '../ResponseHelper';
|
||||||
/**
|
/**
|
||||||
* Check whether the LDAP feature is disabled in the instance
|
* 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
|
* Check whether the LDAP feature is enabled in the instance
|
||||||
|
|
|
@ -9,8 +9,16 @@ import {
|
||||||
LICENSE_QUOTAS,
|
LICENSE_QUOTAS,
|
||||||
N8N_VERSION,
|
N8N_VERSION,
|
||||||
SETTINGS_LICENSE_CERT_KEY,
|
SETTINGS_LICENSE_CERT_KEY,
|
||||||
|
UNLIMITED_LICENSE_QUOTA,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
import type { BooleanLicenseFeature, NumericLicenseFeature } from './Interfaces';
|
||||||
|
|
||||||
|
type FeatureReturnType = Partial<
|
||||||
|
{
|
||||||
|
planName: string;
|
||||||
|
} & { [K in NumericLicenseFeature]: number } & { [K in BooleanLicenseFeature]: boolean }
|
||||||
|
>;
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class License {
|
export class License {
|
||||||
|
@ -96,12 +104,8 @@ export class License {
|
||||||
await this.manager.renew();
|
await this.manager.renew();
|
||||||
}
|
}
|
||||||
|
|
||||||
isFeatureEnabled(feature: LICENSE_FEATURES): boolean {
|
isFeatureEnabled(feature: BooleanLicenseFeature) {
|
||||||
if (!this.manager) {
|
return this.manager?.hasFeatureEnabled(feature) ?? false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.manager.hasFeatureEnabled(feature);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSharingEnabled() {
|
isSharingEnabled() {
|
||||||
|
@ -140,15 +144,8 @@ export class License {
|
||||||
return this.manager?.getCurrentEntitlements() ?? [];
|
return this.manager?.getCurrentEntitlements() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
getFeatureValue(
|
getFeatureValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T] {
|
||||||
feature: string,
|
return this.manager?.getFeatureValue(feature) as FeatureReturnType[T];
|
||||||
requireValidCert?: boolean,
|
|
||||||
): undefined | boolean | number | string {
|
|
||||||
if (!this.manager) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.manager.getFeatureValue(feature, requireValidCert);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getManagementJwt(): string {
|
getManagementJwt(): string {
|
||||||
|
@ -177,20 +174,20 @@ export class License {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for computed data
|
// Helper functions for computed data
|
||||||
getTriggerLimit(): number {
|
getUsersLimit() {
|
||||||
return (this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? -1) as number;
|
return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVariablesLimit(): number {
|
getTriggerLimit() {
|
||||||
return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number;
|
return this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
|
||||||
}
|
}
|
||||||
|
|
||||||
getUsersLimit(): number {
|
getVariablesLimit() {
|
||||||
return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) as number;
|
return this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlanName(): string {
|
getPlanName(): string {
|
||||||
return (this.getFeatureValue('planName') ?? 'Community') as string;
|
return this.getFeatureValue('planName') ?? 'Community';
|
||||||
}
|
}
|
||||||
|
|
||||||
getInfo(): string {
|
getInfo(): string {
|
||||||
|
@ -200,4 +197,8 @@ export class License {
|
||||||
|
|
||||||
return this.manager.toString();
|
return this.manager.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isWithinUsersLimit() {
|
||||||
|
return this.getUsersLimit() === UNLIMITED_LICENSE_QUOTA;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,9 @@
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { RoleRepository, UserRepository } from '@db/repositories';
|
import { UserRepository } from '@db/repositories';
|
||||||
import type { Role } from '@db/entities/Role';
|
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import pick from 'lodash/pick';
|
import pick from 'lodash/pick';
|
||||||
import { validate as uuidValidate } from 'uuid';
|
import { validate as uuidValidate } from 'uuid';
|
||||||
|
|
||||||
export function isInstanceOwner(user: User): boolean {
|
|
||||||
return user.globalRole.name === 'owner';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWorkflowOwnerRole(): Promise<Role> {
|
|
||||||
return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getSelectableProperties = (table: 'user' | 'role'): string[] => {
|
export const getSelectableProperties = (table: 'user' | 'role'): string[] => {
|
||||||
return {
|
return {
|
||||||
user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'],
|
user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'],
|
||||||
|
|
|
@ -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<Role> {
|
||||||
|
return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail();
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers';
|
||||||
import type { WorkflowRequest } from '../../../types';
|
import type { WorkflowRequest } from '../../../types';
|
||||||
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
|
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
|
||||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||||
import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service.ee';
|
import { getWorkflowOwnerRole } from '../users/users.service';
|
||||||
import {
|
import {
|
||||||
getWorkflowById,
|
getWorkflowById,
|
||||||
getSharedWorkflow,
|
getSharedWorkflow,
|
||||||
|
@ -101,7 +101,7 @@ export = {
|
||||||
...(active !== undefined && { active }),
|
...(active !== undefined && { active }),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isInstanceOwner(req.user)) {
|
if (req.user.isOwner) {
|
||||||
if (tags) {
|
if (tags) {
|
||||||
const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
|
const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
|
||||||
where.id = In(workflowIds);
|
where.id = In(workflowIds);
|
||||||
|
|
|
@ -8,7 +8,6 @@ import * as Db from '@/Db';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
||||||
import { isInstanceOwner } from '../users/users.service.ee';
|
|
||||||
import type { Role } from '@db/entities/Role';
|
import type { Role } from '@db/entities/Role';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { START_NODES } from '@/constants';
|
import { START_NODES } from '@/constants';
|
||||||
|
@ -32,7 +31,7 @@ export async function getSharedWorkflow(
|
||||||
): Promise<SharedWorkflow | null> {
|
): Promise<SharedWorkflow | null> {
|
||||||
return Db.collections.SharedWorkflow.findOne({
|
return Db.collections.SharedWorkflow.findOne({
|
||||||
where: {
|
where: {
|
||||||
...(!isInstanceOwner(user) && { userId: user.id }),
|
...(!user.isOwner && { userId: user.id }),
|
||||||
...(workflowId && { workflowId }),
|
...(workflowId && { workflowId }),
|
||||||
},
|
},
|
||||||
relations: [...insertIf(!config.getEnv('workflowTagsDisabled'), ['workflow.tags']), 'workflow'],
|
relations: [...insertIf(!config.getEnv('workflowTagsDisabled'), ['workflow.tags']), 'workflow'],
|
||||||
|
@ -48,7 +47,7 @@ export async function getSharedWorkflows(
|
||||||
): Promise<SharedWorkflow[]> {
|
): Promise<SharedWorkflow[]> {
|
||||||
return Db.collections.SharedWorkflow.find({
|
return Db.collections.SharedWorkflow.find({
|
||||||
where: {
|
where: {
|
||||||
...(!isInstanceOwner(user) && { userId: user.id }),
|
...(!user.isOwner && { userId: user.id }),
|
||||||
...(options.workflowIds && { workflowId: In(options.workflowIds) }),
|
...(options.workflowIds && { workflowId: In(options.workflowIds) }),
|
||||||
},
|
},
|
||||||
...(options.relations && { relations: options.relations }),
|
...(options.relations && { relations: options.relations }),
|
||||||
|
|
|
@ -149,6 +149,7 @@ import { PostHogClient } from './posthog';
|
||||||
import { eventBus } from './eventbus';
|
import { eventBus } from './eventbus';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { InternalHooks } from './InternalHooks';
|
import { InternalHooks } from './InternalHooks';
|
||||||
|
import { License } from './License';
|
||||||
import {
|
import {
|
||||||
getStatusUsingPreviousExecutionStatusMethod,
|
getStatusUsingPreviousExecutionStatusMethod,
|
||||||
isAdvancedExecutionFiltersEnabled,
|
isAdvancedExecutionFiltersEnabled,
|
||||||
|
@ -259,6 +260,7 @@ export class Server extends AbstractServer {
|
||||||
config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'),
|
config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'),
|
||||||
defaultLocale: config.getEnv('defaultLocale'),
|
defaultLocale: config.getEnv('defaultLocale'),
|
||||||
userManagement: {
|
userManagement: {
|
||||||
|
quota: Container.get(License).getUsersLimit(),
|
||||||
showSetupOnFirstLoad: config.getEnv('userManagement.isInstanceOwnerSetUp') === false,
|
showSetupOnFirstLoad: config.getEnv('userManagement.isInstanceOwnerSetUp') === false,
|
||||||
smtpSetup: isEmailSetUp(),
|
smtpSetup: isEmailSetUp(),
|
||||||
authenticationMethod: getCurrentAuthenticationMethod(),
|
authenticationMethod: getCurrentAuthenticationMethod(),
|
||||||
|
@ -407,6 +409,7 @@ export class Server extends AbstractServer {
|
||||||
getSettingsForFrontend(): IN8nUISettings {
|
getSettingsForFrontend(): IN8nUISettings {
|
||||||
// refresh user management status
|
// refresh user management status
|
||||||
Object.assign(this.frontendSettings.userManagement, {
|
Object.assign(this.frontendSettings.userManagement, {
|
||||||
|
quota: Container.get(License).getUsersLimit(),
|
||||||
authenticationMethod: getCurrentAuthenticationMethod(),
|
authenticationMethod: getCurrentAuthenticationMethod(),
|
||||||
showSetupOnFirstLoad:
|
showSetupOnFirstLoad:
|
||||||
config.getEnv('userManagement.isInstanceOwnerSetUp') === false &&
|
config.getEnv('userManagement.isInstanceOwnerSetUp') === false &&
|
||||||
|
|
|
@ -12,8 +12,8 @@ import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User';
|
||||||
import type { Role } from '@db/entities/Role';
|
import type { Role } from '@db/entities/Role';
|
||||||
import { RoleRepository } from '@db/repositories';
|
import { RoleRepository } from '@db/repositories';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { getWebhookBaseUrl } from '@/WebhookHelpers';
|
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
|
import { getWebhookBaseUrl } from '@/WebhookHelpers';
|
||||||
import type { PostHogClient } from '@/posthog';
|
import type { PostHogClient } from '@/posthog';
|
||||||
|
|
||||||
export async function getWorkflowOwner(workflowId: string): Promise<User> {
|
export async function getWorkflowOwner(workflowId: string): Promise<User> {
|
||||||
|
|
|
@ -4,15 +4,18 @@ import jwt from 'jsonwebtoken';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import * as Db from '@/Db';
|
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 { JwtPayload, JwtToken } from '@/Interfaces';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
export function issueJWT(user: User): JwtToken {
|
export function issueJWT(user: User): JwtToken {
|
||||||
const { id, email, password } = user;
|
const { id, email, password } = user;
|
||||||
const expiresIn = 7 * 86400000; // 7 days
|
const expiresIn = 7 * 86400000; // 7 days
|
||||||
|
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
|
||||||
|
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
id,
|
id,
|
||||||
|
@ -20,6 +23,13 @@ export function issueJWT(user: User): JwtToken {
|
||||||
password: password ?? null,
|
password: password ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.getEnv('userManagement.isInstanceOwnerSetUp') &&
|
||||||
|
!user.isOwner &&
|
||||||
|
!isWithinUsersLimit
|
||||||
|
) {
|
||||||
|
throw new ResponseHelper.UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||||
|
}
|
||||||
if (password) {
|
if (password) {
|
||||||
payload.password = createHash('sha256')
|
payload.password = createHash('sha256')
|
||||||
.update(password.slice(password.length / 2))
|
.update(password.slice(password.length / 2))
|
||||||
|
|
|
@ -47,6 +47,7 @@ export const RESPONSE_ERROR_MESSAGES = {
|
||||||
PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes',
|
PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes',
|
||||||
PACKAGE_LOADING_FAILED: 'The specified package could not be loaded',
|
PACKAGE_LOADING_FAILED: 'The specified package could not be loaded',
|
||||||
DISK_IS_FULL: 'There appears to be insufficient disk space',
|
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';
|
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 SETTINGS_LICENSE_CERT_KEY = 'license.cert';
|
||||||
|
|
||||||
export const enum LICENSE_FEATURES {
|
export const LICENSE_FEATURES = {
|
||||||
SHARING = 'feat:sharing',
|
SHARING: 'feat:sharing',
|
||||||
LDAP = 'feat:ldap',
|
LDAP: 'feat:ldap',
|
||||||
SAML = 'feat:saml',
|
SAML: 'feat:saml',
|
||||||
LOG_STREAMING = 'feat:logStreaming',
|
LOG_STREAMING: 'feat:logStreaming',
|
||||||
ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters',
|
ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
|
||||||
VARIABLES = 'feat:variables',
|
VARIABLES: 'feat:variables',
|
||||||
SOURCE_CONTROL = 'feat:sourceControl',
|
SOURCE_CONTROL: 'feat:sourceControl',
|
||||||
API_DISABLED = 'feat:apiDisabled',
|
API_DISABLED: 'feat:apiDisabled',
|
||||||
}
|
} as const;
|
||||||
|
|
||||||
export const enum LICENSE_QUOTAS {
|
export const LICENSE_QUOTAS = {
|
||||||
TRIGGER_LIMIT = 'quota:activeWorkflows',
|
TRIGGER_LIMIT: 'quota:activeWorkflows',
|
||||||
VARIABLES_LIMIT = 'quota:maxVariables',
|
VARIABLES_LIMIT: 'quota:maxVariables',
|
||||||
USERS_LIMIT = 'quota:users',
|
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';
|
export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6';
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import { Authorized, Get, Post, RestController } from '@/decorators';
|
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 { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper';
|
||||||
import { issueCookie, resolveJwt } from '@/auth/jwt';
|
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 { Request, Response } from 'express';
|
||||||
import type { ILogger } from 'n8n-workflow';
|
import type { ILogger } from 'n8n-workflow';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
|
@ -26,6 +31,7 @@ import {
|
||||||
import type { UserRepository } from '@db/repositories';
|
import type { UserRepository } from '@db/repositories';
|
||||||
import { InternalHooks } from '../InternalHooks';
|
import { InternalHooks } from '../InternalHooks';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
import { License } from '@/License';
|
||||||
|
|
||||||
@RestController()
|
@RestController()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
@ -71,7 +77,6 @@ export class AuthController {
|
||||||
let user: User | undefined;
|
let user: User | undefined;
|
||||||
|
|
||||||
let usedAuthenticationMethod = getCurrentAuthenticationMethod();
|
let usedAuthenticationMethod = getCurrentAuthenticationMethod();
|
||||||
|
|
||||||
if (isSamlCurrentAuthenticationMethod()) {
|
if (isSamlCurrentAuthenticationMethod()) {
|
||||||
// attempt to fetch user data with the credentials, but don't log in yet
|
// attempt to fetch user data with the credentials, but don't log in yet
|
||||||
const preliminaryUser = await handleEmailLogin(email, password);
|
const preliminaryUser = await handleEmailLogin(email, password);
|
||||||
|
@ -120,6 +125,7 @@ export class AuthController {
|
||||||
// If logged in, return user
|
// If logged in, return user
|
||||||
try {
|
try {
|
||||||
user = await resolveJwt(cookieContents);
|
user = await resolveJwt(cookieContents);
|
||||||
|
|
||||||
return await withFeatureFlags(this.postHog, sanitizeUser(user));
|
return await withFeatureFlags(this.postHog, sanitizeUser(user));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.clearCookie(AUTH_COOKIE_NAME);
|
res.clearCookie(AUTH_COOKIE_NAME);
|
||||||
|
@ -155,6 +161,15 @@ export class AuthController {
|
||||||
@Get('/resolve-signup-token')
|
@Get('/resolve-signup-token')
|
||||||
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
|
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
|
||||||
const { inviterId, inviteeId } = req.query;
|
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) {
|
if (!inviterId || !inviteeId) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { License } from '@/License';
|
||||||
import { LICENSE_FEATURES, inE2ETests } from '@/constants';
|
import { LICENSE_FEATURES, inE2ETests } from '@/constants';
|
||||||
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
|
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
|
||||||
import type { UserSetupPayload } from '@/requests';
|
import type { UserSetupPayload } from '@/requests';
|
||||||
|
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||||
|
|
||||||
if (!inE2ETests) {
|
if (!inE2ETests) {
|
||||||
console.error('E2E endpoints only allowed during E2E tests');
|
console.error('E2E endpoints only allowed during E2E tests');
|
||||||
|
@ -51,7 +52,7 @@ type ResetRequest = Request<
|
||||||
@NoAuthRequired()
|
@NoAuthRequired()
|
||||||
@RestController('/e2e')
|
@RestController('/e2e')
|
||||||
export class E2EController {
|
export class E2EController {
|
||||||
private enabledFeatures: Record<LICENSE_FEATURES, boolean> = {
|
private enabledFeatures: Record<BooleanLicenseFeature, boolean> = {
|
||||||
[LICENSE_FEATURES.SHARING]: false,
|
[LICENSE_FEATURES.SHARING]: false,
|
||||||
[LICENSE_FEATURES.LDAP]: false,
|
[LICENSE_FEATURES.LDAP]: false,
|
||||||
[LICENSE_FEATURES.SAML]: false,
|
[LICENSE_FEATURES.SAML]: false,
|
||||||
|
@ -69,7 +70,7 @@ export class E2EController {
|
||||||
private userRepo: UserRepository,
|
private userRepo: UserRepository,
|
||||||
private workflowRunner: ActiveWorkflowRunner,
|
private workflowRunner: ActiveWorkflowRunner,
|
||||||
) {
|
) {
|
||||||
license.isFeatureEnabled = (feature: LICENSE_FEATURES) =>
|
license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
|
||||||
this.enabledFeatures[feature] ?? false;
|
this.enabledFeatures[feature] ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,14 +85,14 @@ export class E2EController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('/feature')
|
@Patch('/feature')
|
||||||
setFeature(req: Request<{}, {}, { feature: LICENSE_FEATURES; enabled: boolean }>) {
|
setFeature(req: Request<{}, {}, { feature: BooleanLicenseFeature; enabled: boolean }>) {
|
||||||
const { enabled, feature } = req.body;
|
const { enabled, feature } = req.body;
|
||||||
this.enabledFeatures[feature] = enabled;
|
this.enabledFeatures[feature] = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetFeatures() {
|
private resetFeatures() {
|
||||||
for (const feature of Object.keys(this.enabledFeatures)) {
|
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 type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
|
||||||
import { issueCookie } from '@/auth/jwt';
|
import { issueCookie } from '@/auth/jwt';
|
||||||
import { isLdapEnabled } from '@/Ldap/helpers';
|
import { isLdapEnabled } from '@/Ldap/helpers';
|
||||||
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers';
|
import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '@/user/user.service';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||||
|
|
||||||
@RestController()
|
@RestController()
|
||||||
export class PasswordResetController {
|
export class PasswordResetController {
|
||||||
|
@ -103,6 +106,12 @@ export class PasswordResetController {
|
||||||
relations: ['authIdentities', 'globalRole'],
|
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 (
|
if (
|
||||||
isSamlCurrentAuthenticationMethod() &&
|
isSamlCurrentAuthenticationMethod() &&
|
||||||
!(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true)
|
!(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true)
|
||||||
|
@ -116,7 +125,6 @@ export class PasswordResetController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
|
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
|
||||||
|
|
||||||
if (!user?.password || (ldapIdentity && user.disabled)) {
|
if (!user?.password || (ldapIdentity && user.disabled)) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Request to send password reset email failed because no user was found for the provided email',
|
'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
|
// Timestamp is saved in seconds
|
||||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const user = await this.userRepository.findOneBy({
|
const user = await this.userRepository.findOne({
|
||||||
|
where: {
|
||||||
id,
|
id,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
|
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) {
|
if (!user) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Request to resolve password token failed because no user was found for the provided user ID and reset password token',
|
'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,
|
withFeatureFlags,
|
||||||
} from '@/UserManagement/UserManagementHelper';
|
} from '@/UserManagement/UserManagementHelper';
|
||||||
import { issueCookie } from '@/auth/jwt';
|
import { issueCookie } from '@/auth/jwt';
|
||||||
import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper';
|
import {
|
||||||
|
BadRequestError,
|
||||||
|
InternalServerError,
|
||||||
|
NotFoundError,
|
||||||
|
UnauthorizedError,
|
||||||
|
} from '@/ResponseHelper';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import type { Config } from '@/config';
|
import type { Config } from '@/config';
|
||||||
import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
|
import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
|
||||||
|
@ -39,8 +44,11 @@ import type {
|
||||||
SharedWorkflowRepository,
|
SharedWorkflowRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
} from '@db/repositories';
|
} from '@db/repositories';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '@/user/user.service';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||||
|
|
||||||
@Authorized(['global', 'owner'])
|
@Authorized(['global', 'owner'])
|
||||||
@RestController('/users')
|
@RestController('/users')
|
||||||
|
@ -107,6 +115,8 @@ export class UsersController {
|
||||||
*/
|
*/
|
||||||
@Post('/')
|
@Post('/')
|
||||||
async sendEmailInvites(req: UserRequest.Invite) {
|
async sendEmailInvites(req: UserRequest.Invite) {
|
||||||
|
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
|
||||||
|
|
||||||
if (isSamlLicensedAndEnabled()) {
|
if (isSamlLicensedAndEnabled()) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
|
'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')) {
|
if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
|
'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')
|
@Post('/:id/reinvite')
|
||||||
async reinviteUser(req: UserRequest.Reinvite) {
|
async reinviteUser(req: UserRequest.Reinvite) {
|
||||||
const { id: idToReinvite } = req.params;
|
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()) {
|
if (!isEmailSetUp()) {
|
||||||
this.logger.error('Request to reinvite a user failed because email sending was not set up');
|
this.logger.error('Request to reinvite a user failed because email sending was not set up');
|
||||||
|
|
|
@ -113,4 +113,14 @@ export class User extends AbstractEntity implements IUser {
|
||||||
computeIsPending(): void {
|
computeIsPending(): void {
|
||||||
this.isPending = this.password === null;
|
this.isPending = this.password === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user is instance owner
|
||||||
|
*/
|
||||||
|
isOwner: boolean;
|
||||||
|
|
||||||
|
@AfterLoad()
|
||||||
|
computeIsOwner(): void {
|
||||||
|
this.isOwner = this.globalRole?.name === 'owner';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@ import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces';
|
||||||
import { LicenseService } from './License.service';
|
import { LicenseService } from './License.service';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
import type { AuthenticatedRequest, LicenseRequest } from '@/requests';
|
import type { AuthenticatedRequest, LicenseRequest } from '@/requests';
|
||||||
import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service.ee';
|
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
|
||||||
|
@ -34,7 +33,7 @@ licenseController.use((req, res, next) => {
|
||||||
*/
|
*/
|
||||||
licenseController.use((req: AuthenticatedRequest, res, next) => {
|
licenseController.use((req: AuthenticatedRequest, res, next) => {
|
||||||
if (OWNER_ROUTES.includes(req.path) && req.user) {
|
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', {
|
LoggerProxy.info('Non-owner attempted to activate or renew a license', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
});
|
});
|
||||||
|
|
|
@ -77,9 +77,6 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle authentication
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
const authCookie: string = req.cookies?.[AUTH_COOKIE_NAME] ?? '';
|
const authCookie: string = req.cookies?.[AUTH_COOKIE_NAME] ?? '';
|
||||||
|
|
|
@ -52,7 +52,9 @@ export function setSamlLoginLabel(label: string): void {
|
||||||
config.set(SAML_LOGIN_LABEL, label);
|
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 {
|
export function isSamlLicensedAndEnabled(): boolean {
|
||||||
return isSamlLoginEnabled() && isSamlLicensed() && isSamlCurrentAuthenticationMethod();
|
return isSamlLoginEnabled() && isSamlLicensed() && isSamlCurrentAuthenticationMethod();
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Application } from 'express';
|
import type { Application } from 'express';
|
||||||
import type { SuperAgentTest } from 'supertest';
|
import type { SuperAgentTest } from 'supertest';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
import { License } from '@/License';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
|
@ -84,6 +86,26 @@ describe('POST /login', () => {
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeDefined();
|
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', () => {
|
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 () => {
|
test('should fail with invalid inputs', async () => {
|
||||||
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });
|
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
} from './shared/constants';
|
} from './shared/constants';
|
||||||
import * as testDb from './shared/testDb';
|
import * as testDb from './shared/testDb';
|
||||||
import * as utils from './shared/utils';
|
import * as utils from './shared/utils';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
let authlessAgent: SuperAgentTest;
|
let authlessAgent: SuperAgentTest;
|
||||||
let authMemberAgent: SuperAgentTest;
|
let authMemberAgent: SuperAgentTest;
|
||||||
|
@ -16,6 +17,8 @@ beforeAll(async () => {
|
||||||
|
|
||||||
authlessAgent = utils.createAgent(app);
|
authlessAgent = utils.createAgent(app);
|
||||||
authMemberAgent = utils.createAuthAgent(app)(member);
|
authMemberAgent = utils.createAuthAgent(app)(member);
|
||||||
|
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { randomCredentialPayload } from './shared/random';
|
||||||
import * as testDb from './shared/testDb';
|
import * as testDb from './shared/testDb';
|
||||||
import type { AuthAgent, SaveCredentialFunction } from './shared/types';
|
import type { AuthAgent, SaveCredentialFunction } from './shared/types';
|
||||||
import * as utils from './shared/utils';
|
import * as utils from './shared/utils';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
let globalMemberRole: Role;
|
let globalMemberRole: Role;
|
||||||
let owner: User;
|
let owner: User;
|
||||||
|
@ -39,6 +40,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||||
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -45,6 +45,8 @@ beforeAll(async () => {
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
authOwnerAgent = authAgent(owner);
|
authOwnerAgent = authAgent(owner);
|
||||||
authMemberAgent = authAgent(member);
|
authMemberAgent = authAgent(member);
|
||||||
|
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -26,6 +26,7 @@ beforeAll(async () => {
|
||||||
authOwnerAgent = authAgent(owner);
|
authOwnerAgent = authAgent(owner);
|
||||||
authMemberAgent = authAgent(member);
|
authMemberAgent = authAgent(member);
|
||||||
|
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
config.set('license.serverUrl', MOCK_SERVER_URL);
|
config.set('license.serverUrl', MOCK_SERVER_URL);
|
||||||
config.set('license.autoRenewEnabled', true);
|
config.set('license.autoRenewEnabled', true);
|
||||||
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
|
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import * as utils from '../shared/utils';
|
||||||
import { sampleConfig } from './sampleMetadata';
|
import { sampleConfig } from './sampleMetadata';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
import { SamlService } from '@/sso/saml/saml.service.ee';
|
import { SamlService } from '@/sso/saml/saml.service.ee';
|
||||||
|
import config from '@/config';
|
||||||
import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes';
|
import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes';
|
||||||
import type { AuthenticationMethod } from 'n8n-workflow';
|
import type { AuthenticationMethod } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -32,6 +33,8 @@ beforeAll(async () => {
|
||||||
authOwnerAgent = utils.createAuthAgent(app)(owner);
|
authOwnerAgent = utils.createAuthAgent(app)(owner);
|
||||||
authMemberAgent = utils.createAgent(app, { auth: true, user: someUser });
|
authMemberAgent = utils.createAgent(app, { auth: true, user: someUser });
|
||||||
noAuthMemberAgent = utils.createAgent(app, { auth: false, user: someUser });
|
noAuthMemberAgent = utils.createAgent(app, { auth: false, user: someUser });
|
||||||
|
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -181,6 +181,7 @@ export async function createUser(attributes: Partial<User> = {}): Promise<User>
|
||||||
firstName: firstName ?? randomName(),
|
firstName: firstName ?? randomName(),
|
||||||
lastName: lastName ?? randomName(),
|
lastName: lastName ?? randomName(),
|
||||||
globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id,
|
globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id,
|
||||||
|
globalRole,
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -683,8 +683,10 @@ export function createAgent(
|
||||||
if (options?.apiPath === undefined || options?.apiPath === 'internal') {
|
if (options?.apiPath === undefined || options?.apiPath === 'internal') {
|
||||||
void agent.use(prefix(REST_PATH_SEGMENT));
|
void agent.use(prefix(REST_PATH_SEGMENT));
|
||||||
if (options?.auth && options?.user) {
|
if (options?.auth && options?.user) {
|
||||||
|
try {
|
||||||
const { token } = issueJWT(options.user);
|
const { token } = issueJWT(options.user);
|
||||||
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { Application } from 'express';
|
||||||
import type { User } from '@/databases/entities/User';
|
import type { User } from '@/databases/entities/User';
|
||||||
import * as testDb from './shared/testDb';
|
import * as testDb from './shared/testDb';
|
||||||
import * as utils from './shared/utils';
|
import * as utils from './shared/utils';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
import type { AuthAgent } from './shared/types';
|
import type { AuthAgent } from './shared/types';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
|
@ -16,6 +17,7 @@ let variablesSpy: jest.SpyInstance<boolean>;
|
||||||
const licenseLike = {
|
const licenseLike = {
|
||||||
isVariablesEnabled: jest.fn().mockReturnValue(true),
|
isVariablesEnabled: jest.fn().mockReturnValue(true),
|
||||||
getVariablesLimit: jest.fn().mockReturnValue(-1),
|
getVariablesLimit: jest.fn().mockReturnValue(-1),
|
||||||
|
isWithinUsersLimit: jest.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
@ -28,6 +30,7 @@ beforeAll(async () => {
|
||||||
memberUser = await testDb.createUser();
|
memberUser = await testDb.createUser();
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { makeWorkflow } from './shared/utils';
|
||||||
import { randomCredentialPayload } from './shared/random';
|
import { randomCredentialPayload } from './shared/random';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
import { getSharedWorkflowIds } from '../../src/WorkflowHelpers';
|
import { getSharedWorkflowIds } from '../../src/WorkflowHelpers';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
let owner: User;
|
let owner: User;
|
||||||
let member: User;
|
let member: User;
|
||||||
|
@ -45,6 +46,7 @@ beforeAll(async () => {
|
||||||
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
|
||||||
|
|
||||||
await utils.initNodeTypes();
|
await utils.initNodeTypes();
|
||||||
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -9,7 +9,7 @@ const MOCK_SERVER_URL = 'https://server.com/v1';
|
||||||
const MOCK_RENEW_OFFSET = 259200;
|
const MOCK_RENEW_OFFSET = 259200;
|
||||||
const MOCK_INSTANCE_ID = 'instance-id';
|
const MOCK_INSTANCE_ID = 'instance-id';
|
||||||
const MOCK_ACTIVATION_KEY = 'activation-key';
|
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';
|
const MOCK_MAIN_PLAN_ID = '1b765dc4-d39d-4ffe-9885-c56dd67c4b26';
|
||||||
|
|
||||||
describe('License', () => {
|
describe('License', () => {
|
||||||
|
@ -71,9 +71,9 @@ describe('License', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('check fetching feature values', async () => {
|
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 () => {
|
test('check management jwt', async () => {
|
||||||
|
|
|
@ -37,6 +37,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||||
settings: {} as IN8nUISettings,
|
settings: {} as IN8nUISettings,
|
||||||
promptsData: {} as IN8nPrompts,
|
promptsData: {} as IN8nPrompts,
|
||||||
userManagement: {
|
userManagement: {
|
||||||
|
quota: -1,
|
||||||
showSetupOnFirstLoad: false,
|
showSetupOnFirstLoad: false,
|
||||||
smtpSetup: false,
|
smtpSetup: false,
|
||||||
authenticationMethod: UserManagementAuthenticationMethod.Email,
|
authenticationMethod: UserManagementAuthenticationMethod.Email,
|
||||||
|
@ -169,6 +170,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||||
isDefaultAuthenticationSaml(): boolean {
|
isDefaultAuthenticationSaml(): boolean {
|
||||||
return this.userManagement.authenticationMethod === UserManagementAuthenticationMethod.Saml;
|
return this.userManagement.authenticationMethod === UserManagementAuthenticationMethod.Saml;
|
||||||
},
|
},
|
||||||
|
isBelowUserQuota(): boolean {
|
||||||
|
const userStore = useUsersStore();
|
||||||
|
return (
|
||||||
|
this.userManagement.quota === -1 || this.userManagement.quota > userStore.allUsers.length
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
setSettings(settings: IN8nUISettings): void {
|
setSettings(settings: IN8nUISettings): void {
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
:disabled="ssoStore.isSamlLoginEnabled"
|
:disabled="ssoStore.isSamlLoginEnabled || !settingsStore.isBelowUserQuota"
|
||||||
:label="$locale.baseText('settings.users.invite')"
|
:label="$locale.baseText('settings.users.invite')"
|
||||||
@click="onInvite"
|
@click="onInvite"
|
||||||
size="large"
|
size="large"
|
||||||
|
@ -19,17 +19,28 @@
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="usersStore.showUMSetupWarning" :class="$style.setupInfoContainer">
|
<div v-if="!settingsStore.isBelowUserQuota" :class="$style.setupInfoContainer">
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
:heading="$locale.baseText('settings.users.setupToInviteUsers')"
|
:heading="
|
||||||
:buttonText="$locale.baseText('settings.users.setupMyAccount')"
|
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
|
||||||
:description="`${
|
"
|
||||||
isSharingEnabled ? '' : $locale.baseText('settings.users.setupToInviteUsersInfo')
|
:description="
|
||||||
}`"
|
$locale.baseText(
|
||||||
@click="redirectToSetup"
|
uiStore.contextBasedTranslationKeys.users.settings.unavailable.description,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:buttonText="
|
||||||
|
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
|
||||||
|
"
|
||||||
|
@click="goToUpgrade"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.usersContainer" v-else>
|
<!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
:class="$style.usersContainer"
|
||||||
|
v-if="settingsStore.isBelowUserQuota || usersStore.allUsers.length > 1"
|
||||||
|
>
|
||||||
<n8n-users-list
|
<n8n-users-list
|
||||||
:actions="usersListActions"
|
:actions="usersListActions"
|
||||||
:users="usersStore.allUsers"
|
:users="usersStore.allUsers"
|
||||||
|
@ -43,6 +54,19 @@
|
||||||
@disallowSSOManualLogin="onDisallowSSOManualLogin"
|
@disallowSSOManualLogin="onDisallowSSOManualLogin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<n8n-action-box
|
||||||
|
v-else
|
||||||
|
:heading="
|
||||||
|
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.description)
|
||||||
|
"
|
||||||
|
:buttonText="
|
||||||
|
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
|
||||||
|
"
|
||||||
|
@click="goToUpgrade"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -83,12 +107,16 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.users.actions.copyInviteLink'),
|
label: this.$locale.baseText('settings.users.actions.copyInviteLink'),
|
||||||
value: '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'),
|
label: this.$locale.baseText('settings.users.actions.reinvite'),
|
||||||
value: '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'),
|
label: this.$locale.baseText('settings.users.actions.delete'),
|
||||||
|
@ -97,6 +125,7 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
|
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
|
||||||
value: 'copyPasswordResetLink',
|
value: 'copyPasswordResetLink',
|
||||||
|
guard: () => this.settingsStore.isBelowUserQuota,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),
|
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),
|
||||||
|
|
|
@ -2056,6 +2056,7 @@ export interface IVersionNotificationSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserManagementSettings {
|
export interface IUserManagementSettings {
|
||||||
|
quota: number;
|
||||||
showSetupOnFirstLoad?: boolean;
|
showSetupOnFirstLoad?: boolean;
|
||||||
smtpSetup: boolean;
|
smtpSetup: boolean;
|
||||||
authenticationMethod: AuthenticationMethod;
|
authenticationMethod: AuthenticationMethod;
|
||||||
|
|
Loading…
Reference in a new issue