mirror of
https://github.com/n8n-io/n8n.git
synced 2025-12-05 19:27:26 -06:00
fix: PAY-4074 - Owner registration in multi-main setup (#22520)
Signed-off-by: James Gee <james@justec.io> Signed-off-by: James Gee <1285296+geemanjs@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
InvalidAuthTokenRepository,
|
||||
UserRepository,
|
||||
} from '@n8n/db';
|
||||
import { GLOBAL_OWNER_ROLE } from '@n8n/db';
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import jwt from 'jsonwebtoken';
|
||||
@@ -15,6 +16,7 @@ import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import type { MfaService } from '@/mfa/mfa.service';
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
import type { License } from '@/license';
|
||||
|
||||
describe('AuthService', () => {
|
||||
const browserId = 'test-browser-id';
|
||||
@@ -35,10 +37,11 @@ describe('AuthService', () => {
|
||||
const userRepository = mock<UserRepository>();
|
||||
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
|
||||
const mfaService = mock<MfaService>();
|
||||
const license = mock<License>();
|
||||
const authService = new AuthService(
|
||||
globalConfig,
|
||||
mock(),
|
||||
mock(),
|
||||
license,
|
||||
jwtService,
|
||||
urlService,
|
||||
userRepository,
|
||||
@@ -61,6 +64,7 @@ describe('AuthService', () => {
|
||||
globalConfig.userManagement.jwtSessionDurationHours = 168;
|
||||
globalConfig.userManagement.jwtRefreshTimeoutHours = 0;
|
||||
globalConfig.auth.cookie = { secure: true, samesite: 'lax' };
|
||||
license.isWithinUsersLimit.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('createJWTHash', () => {
|
||||
@@ -520,6 +524,29 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user limit is reached', () => {
|
||||
it('should block issuance if the user is not the global owner', async () => {
|
||||
license.isWithinUsersLimit.mockReturnValue(false);
|
||||
expect(() => {
|
||||
authService.issueCookie(res, user, false, browserId);
|
||||
}).toThrowError('Maximum number of users reached');
|
||||
});
|
||||
|
||||
it('should allow issuance if the user is the global owner', async () => {
|
||||
license.isWithinUsersLimit.mockReturnValue(false);
|
||||
user.role = GLOBAL_OWNER_ROLE;
|
||||
expect(() => {
|
||||
authService.issueCookie(res, user, false, browserId);
|
||||
}).not.toThrowError('Maximum number of users reached');
|
||||
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
|
||||
httpOnly: true,
|
||||
maxAge: 604800000,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should issue a cookie with the correct options, when 2FA was used', () => {
|
||||
authService.issueCookie(res, user, true, browserId);
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { NextFunction, Response } from 'express';
|
||||
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
import type { StringValue as TimeUnitValue } from 'ms';
|
||||
|
||||
import config from '@/config';
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { License } from '@/license';
|
||||
@@ -171,11 +170,7 @@ export class AuthService {
|
||||
// TODO: move this check to the login endpoint in AuthController
|
||||
// If the instance has exceeded its user quota, prevent non-owners from logging in
|
||||
const isWithinUsersLimit = this.license.isWithinUsersLimit();
|
||||
if (
|
||||
config.getEnv('userManagement.isInstanceOwnerSetUp') &&
|
||||
user.role.slug !== GLOBAL_OWNER_ROLE.slug &&
|
||||
!isWithinUsersLimit
|
||||
) {
|
||||
if (user.role.slug !== GLOBAL_OWNER_ROLE.slug && !isWithinUsersLimit) {
|
||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
User,
|
||||
CredentialsRepository,
|
||||
ProjectRepository,
|
||||
SettingsRepository,
|
||||
SharedCredentialsRepository,
|
||||
SharedWorkflowRepository,
|
||||
UserRepository,
|
||||
@@ -19,6 +18,7 @@ const defaultUserProps = {
|
||||
lastName: null,
|
||||
email: null,
|
||||
password: null,
|
||||
lastActiveAt: null,
|
||||
role: 'global:owner',
|
||||
};
|
||||
|
||||
@@ -53,11 +53,6 @@ export class Reset extends BaseCommand {
|
||||
);
|
||||
await Container.get(SharedCredentialsRepository).save(newSharedCredentials);
|
||||
|
||||
await Container.get(SettingsRepository).update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: 'false' },
|
||||
);
|
||||
|
||||
this.logger.info('Successfully reset the database to default user state.');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import { Container } from '@n8n/di';
|
||||
export const schema = {
|
||||
userManagement: {
|
||||
/**
|
||||
* @important Do not remove until after cloud hooks are updated to stop using convict config.
|
||||
* @important Do not remove isInstanceOwnerSetUp until after cloud hooks (user-management) are updated to stop using
|
||||
* this property
|
||||
* @deprecated
|
||||
*/
|
||||
isInstanceOwnerSetUp: {
|
||||
// n8n loads this setting from DB on startup
|
||||
// n8n loads this setting from SettingsRepository (DB) on startup
|
||||
doc: "Whether the instance owner's account has been set up",
|
||||
format: Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -76,7 +76,6 @@ type ToReturnType<T extends ConfigOptionPath> = T extends NumericPath
|
||||
type ExceptionPaths = {
|
||||
'queue.bull.redis': RedisOptions;
|
||||
processedDataManager: IProcessedDataConfig;
|
||||
'userManagement.isInstanceOwnerSetUp': boolean;
|
||||
'ui.banners.dismissed': string[] | undefined;
|
||||
easyAIWorkflowOnboarded: boolean | undefined;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import config from '@/config';
|
||||
import type { AuthlessRequest } from '@/requests';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
describe('InvitationController', () => {
|
||||
const logger: Logger = mockInstance(Logger);
|
||||
@@ -33,22 +34,29 @@ describe('InvitationController', () => {
|
||||
const userRepository: UserRepository = mockInstance(UserRepository);
|
||||
const postHog: PostHogClient = mockInstance(PostHogClient);
|
||||
const eventService: EventService = mockInstance(EventService);
|
||||
const ownershipService: OwnershipService = mockInstance(OwnershipService);
|
||||
|
||||
function defaultInvitationController() {
|
||||
return new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
ownershipService,
|
||||
);
|
||||
}
|
||||
|
||||
describe('inviteUser', () => {
|
||||
it('throws a BadRequestError if SSO is enabled', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
@@ -77,18 +85,9 @@ describe('InvitationController', () => {
|
||||
it('throws a ForbiddenError if the user limit quota has been reached', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(false);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
@@ -112,18 +111,9 @@ describe('InvitationController', () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
|
||||
jest.spyOn(config, 'getEnv').mockReturnValue(false);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(false));
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
@@ -148,18 +138,9 @@ describe('InvitationController', () => {
|
||||
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
|
||||
jest.spyOn(config, 'getEnv').mockReturnValue(true);
|
||||
jest.spyOn(license, 'isAdvancedPermissionsLicensed').mockReturnValue(false);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
@@ -209,17 +190,9 @@ describe('InvitationController', () => {
|
||||
jest.spyOn(config, 'getEnv').mockReturnValue(true);
|
||||
jest.spyOn(license, 'isAdvancedPermissionsLicensed').mockReturnValue(true);
|
||||
jest.spyOn(userService, 'inviteUsers').mockResolvedValue(inviteUsersResult);
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
@@ -255,19 +228,11 @@ describe('InvitationController', () => {
|
||||
describe('acceptInvitation', () => {
|
||||
it('throws a BadRequestError if SSO is enabled', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
@@ -291,19 +256,11 @@ describe('InvitationController', () => {
|
||||
|
||||
it('throws a BadRequestError if the inviter ID and invitee ID are not found in the database', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
@@ -332,6 +289,8 @@ describe('InvitationController', () => {
|
||||
|
||||
it('throws a BadRequestError if the invitee already has a password', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const invitee = mock<User>({
|
||||
id: '123',
|
||||
email: 'valid@email.com',
|
||||
@@ -346,17 +305,7 @@ describe('InvitationController', () => {
|
||||
jest.spyOn(userRepository, 'find').mockResolvedValue([inviter, invitee]);
|
||||
const id = uuidv4();
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
@@ -379,6 +328,8 @@ describe('InvitationController', () => {
|
||||
|
||||
it('accepts the invitation successfully', async () => {
|
||||
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
|
||||
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const id = uuidv4();
|
||||
const inviter = mock<User>({
|
||||
id: '124',
|
||||
@@ -400,17 +351,7 @@ describe('InvitationController', () => {
|
||||
jest.spyOn(userService, 'toPublic').mockResolvedValue(invitee as unknown as PublicUser);
|
||||
jest.spyOn(externalHooks, 'run').mockResolvedValue(invitee as never);
|
||||
|
||||
const invitationController = new InvitationController(
|
||||
logger,
|
||||
externalHooks,
|
||||
authService,
|
||||
userService,
|
||||
license,
|
||||
passwordUtility,
|
||||
userRepository,
|
||||
postHog,
|
||||
eventService,
|
||||
);
|
||||
const invitationController = defaultInvitationController();
|
||||
|
||||
const payload = new AcceptInvitationRequestDto({
|
||||
inviterId: id,
|
||||
|
||||
@@ -1,103 +1,40 @@
|
||||
import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
type User,
|
||||
type PublicUser,
|
||||
type SettingsRepository,
|
||||
type UserRepository,
|
||||
GLOBAL_OWNER_ROLE,
|
||||
} from '@n8n/db';
|
||||
import type { Response } from 'express';
|
||||
import type { DismissBannerRequestDto } from '@n8n/api-types';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { OwnerController } from '@/controllers/owner.controller';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { BannerService } from '@/services/banner.service';
|
||||
import type { PasswordUtility } from '@/services/password.utility';
|
||||
import type { UserService } from '@/services/user.service';
|
||||
import type { OwnershipService } from '@/services/ownership.service';
|
||||
import type { PostHogClient } from '@/posthog';
|
||||
|
||||
describe('OwnerController', () => {
|
||||
const configGetSpy = jest.spyOn(config, 'getEnv');
|
||||
const configSetSpy = jest.spyOn(config, 'set');
|
||||
|
||||
const logger = mock<Logger>();
|
||||
const eventService = mock<EventService>();
|
||||
const authService = mock<AuthService>();
|
||||
const bannerService = mock<BannerService>();
|
||||
const userService = mock<UserService>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
const passwordUtility = mock<PasswordUtility>();
|
||||
const ownershipService = mock<OwnershipService>();
|
||||
const postHogClient = mock<PostHogClient>();
|
||||
|
||||
const controller = new OwnerController(
|
||||
logger,
|
||||
eventService,
|
||||
settingsRepository,
|
||||
authService,
|
||||
bannerService,
|
||||
userService,
|
||||
passwordUtility,
|
||||
mock(),
|
||||
userRepository,
|
||||
postHogClient,
|
||||
ownershipService,
|
||||
);
|
||||
|
||||
describe('setupOwner', () => {
|
||||
it('should throw a BadRequestError if the instance owner is already setup', async () => {
|
||||
configGetSpy.mockReturnValue(true);
|
||||
it('should pass on errors from the service', async () => {
|
||||
jest
|
||||
.spyOn(ownershipService, 'setupOwner')
|
||||
.mockRejectedValueOnce(new BadRequestError('Instance owner already setup'));
|
||||
|
||||
await expect(controller.setupOwner(mock(), mock(), mock())).rejects.toThrowError(
|
||||
new BadRequestError('Instance owner already setup'),
|
||||
);
|
||||
|
||||
expect(userRepository.findOneOrFail).not.toHaveBeenCalled();
|
||||
expect(userRepository.save).not.toHaveBeenCalled();
|
||||
expect(authService.issueCookie).not.toHaveBeenCalled();
|
||||
expect(settingsRepository.update).not.toHaveBeenCalled();
|
||||
expect(configSetSpy).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Request to claim instance ownership failed because instance owner already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup the instance owner successfully', async () => {
|
||||
const user = mock<User>({
|
||||
id: 'userId',
|
||||
role: GLOBAL_OWNER_ROLE,
|
||||
authIdentities: [],
|
||||
});
|
||||
const browserId = 'test-browser-id';
|
||||
const req = mock<AuthenticatedRequest>({ user, browserId, authInfo: { usedMfa: false } });
|
||||
const res = mock<Response>();
|
||||
const payload = mock<OwnerSetupRequestDto>({
|
||||
email: 'valid@email.com',
|
||||
password: 'NewPassword123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
configGetSpy.mockReturnValue(false);
|
||||
userRepository.findOneOrFail.mockResolvedValue(user);
|
||||
userRepository.save.mockResolvedValue(user);
|
||||
userService.toPublic.mockResolvedValue(mock<PublicUser>({ id: 'newUserId' }));
|
||||
|
||||
const result = await controller.setupOwner(req, res, payload);
|
||||
|
||||
expect(userRepository.findOneOrFail).toHaveBeenCalledWith({
|
||||
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
|
||||
relations: ['role'],
|
||||
});
|
||||
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
||||
expect(authService.issueCookie).toHaveBeenCalledWith(res, user, false, browserId);
|
||||
expect(settingsRepository.update).toHaveBeenCalledWith(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: JSON.stringify(true) },
|
||||
);
|
||||
expect(configSetSpy).toHaveBeenCalledWith('userManagement.isInstanceOwnerSetUp', true);
|
||||
expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { userId: 'userId' });
|
||||
expect(result.id).toEqual('newUserId');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Request } from 'express';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import config from '@/config';
|
||||
import { inE2ETests } from '@/constants';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import type { FeatureReturnType } from '@/license';
|
||||
@@ -223,8 +222,7 @@ export class E2EController {
|
||||
|
||||
@Get('/env-feature-flags', { skipAuth: true })
|
||||
async getEnvFeatureFlags() {
|
||||
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
|
||||
return currentFlags;
|
||||
return (await this.frontendService.getSettings()).envFeatureFlags;
|
||||
}
|
||||
|
||||
@Patch('/env-feature-flags', { skipAuth: true })
|
||||
@@ -254,7 +252,7 @@ export class E2EController {
|
||||
}
|
||||
|
||||
// Return the current environment feature flags
|
||||
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
|
||||
const currentFlags = (await this.frontendService.getSettings()).envFeatureFlags;
|
||||
return {
|
||||
success: true,
|
||||
message: 'Environment feature flags updated',
|
||||
@@ -364,13 +362,6 @@ export class E2EController {
|
||||
mfaRecoveryCodes: encryptedRecoveryCodes,
|
||||
});
|
||||
}
|
||||
|
||||
await this.settingsRepo.update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: 'true' },
|
||||
);
|
||||
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
}
|
||||
|
||||
private async resetCache() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Post, GlobalScope, RestController, Body, Param } from '@n8n/decorators'
|
||||
import { Response } from 'express';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
@@ -17,6 +16,7 @@ import { PostHogClient } from '@/posthog';
|
||||
import { AuthlessRequest } from '@/requests';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { isSsoCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
|
||||
|
||||
@RestController('/invitations')
|
||||
@@ -31,6 +31,7 @@ export class InvitationController {
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly postHog: PostHogClient,
|
||||
private readonly eventService: EventService,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -64,7 +65,7 @@ export class InvitationController {
|
||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
|
||||
if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||
if (!(await this.ownershipService.hasInstanceOwner())) {
|
||||
this.logger.debug(
|
||||
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
|
||||
);
|
||||
|
||||
@@ -1,82 +1,31 @@
|
||||
import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
AuthenticatedRequest,
|
||||
GLOBAL_OWNER_ROLE,
|
||||
SettingsRepository,
|
||||
UserRepository,
|
||||
} from '@n8n/db';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, GlobalScope, Post, RestController } from '@n8n/decorators';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { validateEntity } from '@/generic-helpers';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { BannerService } from '@/services/banner.service';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
@RestController('/owner')
|
||||
export class OwnerController {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly eventService: EventService,
|
||||
private readonly settingsRepository: SettingsRepository,
|
||||
private readonly authService: AuthService,
|
||||
private readonly bannerService: BannerService,
|
||||
private readonly userService: UserService,
|
||||
private readonly passwordUtility: PasswordUtility,
|
||||
private readonly postHog: PostHogClient,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Promote a shell into the owner of the n8n instance,
|
||||
* and enable `isInstanceOwnerSetUp` setting.
|
||||
* Promote a shell into the owner of the n8n instance
|
||||
*/
|
||||
@Post('/setup', { skipAuth: true })
|
||||
async setupOwner(req: AuthenticatedRequest, res: Response, @Body payload: OwnerSetupRequestDto) {
|
||||
const { email, firstName, lastName, password } = payload;
|
||||
|
||||
if (config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||
this.logger.debug(
|
||||
'Request to claim instance ownership failed because instance owner already exists',
|
||||
);
|
||||
throw new BadRequestError('Instance owner already setup');
|
||||
}
|
||||
|
||||
let owner = await this.userRepository.findOneOrFail({
|
||||
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
|
||||
relations: ['role'],
|
||||
});
|
||||
owner.email = email;
|
||||
owner.firstName = firstName;
|
||||
owner.lastName = lastName;
|
||||
owner.password = await this.passwordUtility.hash(password);
|
||||
|
||||
// TODO: move XSS validation out into the DTO class
|
||||
await validateEntity(owner);
|
||||
|
||||
owner = await this.userRepository.save(owner, { transaction: false });
|
||||
|
||||
this.logger.info('Owner was set up successfully');
|
||||
|
||||
await this.settingsRepository.update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: JSON.stringify(true) },
|
||||
);
|
||||
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
|
||||
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully');
|
||||
|
||||
const owner = await this.ownershipService.setupOwner(payload);
|
||||
this.authService.issueCookie(res, owner, req.authInfo?.usedMfa ?? false, req.browserId);
|
||||
|
||||
this.eventService.emit('instance-owner-setup', { userId: owner.id });
|
||||
|
||||
return await this.userService.toPublic(owner, { posthog: this.postHog, withScopes: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export class Server extends AbstractServer {
|
||||
|
||||
const { frontendService } = this;
|
||||
if (frontendService) {
|
||||
await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]);
|
||||
await this.externalHooks.run('frontend.settings', [await frontendService.getSettings()]);
|
||||
}
|
||||
|
||||
await this.postHogClient.init();
|
||||
@@ -215,7 +215,7 @@ export class Server extends AbstractServer {
|
||||
const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint);
|
||||
this.app.use(...apiRouters);
|
||||
if (frontendService) {
|
||||
frontendService.settings.publicApi.latestVersion = apiLatestVersion;
|
||||
(await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +487,9 @@ export class Server extends AbstractServer {
|
||||
`/${this.restEndpoint}/settings`,
|
||||
authService.createAuthMiddleware({ allowSkipMFA: false, allowUnauthenticated: true }),
|
||||
ResponseHelper.send(async (req: AuthenticatedRequest) => {
|
||||
return req.user ? frontendService.getSettings() : frontendService.getPublicSettings();
|
||||
return req.user
|
||||
? await frontendService.getSettings()
|
||||
: await frontendService.getPublicSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { PushConfig } from '@/push/push.config';
|
||||
import { FrontendService, type PublicFrontendSettings } from '@/services/frontend.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
import type { UserManagementMailer } from '@/user-management/email';
|
||||
import type { OwnershipService } from '../ownership.service';
|
||||
|
||||
// Mock the workflow history helper functions to avoid DI container issues in tests
|
||||
jest.mock('@/workflows/workflow-history/workflow-history-helper', () => ({
|
||||
@@ -148,6 +149,10 @@ describe('FrontendService', () => {
|
||||
isMFAEnforced: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
const ownershipService = mock<OwnershipService>({
|
||||
hasInstanceOwner: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
const createMockService = () => {
|
||||
Container.set(
|
||||
CommunityPackagesConfig,
|
||||
@@ -173,6 +178,7 @@ describe('FrontendService', () => {
|
||||
licenseState,
|
||||
moduleRegistry,
|
||||
mfaService,
|
||||
ownershipService,
|
||||
),
|
||||
license,
|
||||
};
|
||||
@@ -188,9 +194,9 @@ describe('FrontendService', () => {
|
||||
});
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('should return frontend settings', () => {
|
||||
it('should return frontend settings', async () => {
|
||||
const { service } = createMockService();
|
||||
const settings = service.getSettings();
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(settings).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -201,7 +207,7 @@ describe('FrontendService', () => {
|
||||
});
|
||||
|
||||
describe('getPublicSettings', () => {
|
||||
it('should return public settings', () => {
|
||||
it('should return public settings', async () => {
|
||||
const expectedPublicSettings: PublicFrontendSettings = {
|
||||
settingsMode: 'public',
|
||||
userManagement: {
|
||||
@@ -223,7 +229,7 @@ describe('FrontendService', () => {
|
||||
};
|
||||
|
||||
const { service } = createMockService();
|
||||
const settings = service.getPublicSettings();
|
||||
const settings = await service.getPublicSettings();
|
||||
|
||||
expect(settings).toEqual(expectedPublicSettings);
|
||||
});
|
||||
@@ -282,29 +288,31 @@ describe('FrontendService', () => {
|
||||
});
|
||||
|
||||
describe('settings integration', () => {
|
||||
it('should include envFeatureFlags in initial settings', () => {
|
||||
it('should include envFeatureFlags in initial settings', async () => {
|
||||
process.env = {
|
||||
N8N_ENV_FEAT_INIT_FLAG: 'true',
|
||||
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
|
||||
};
|
||||
|
||||
const { service } = createMockService();
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(service.settings.envFeatureFlags).toEqual({
|
||||
expect(settings.envFeatureFlags).toEqual({
|
||||
N8N_ENV_FEAT_INIT_FLAG: 'true',
|
||||
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh envFeatureFlags when getSettings is called', () => {
|
||||
it('should refresh envFeatureFlags when getSettings is called', async () => {
|
||||
process.env = {
|
||||
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
|
||||
};
|
||||
|
||||
const { service } = createMockService();
|
||||
const initialSettings = await service.getSettings();
|
||||
|
||||
// Verify initial state
|
||||
expect(service.settings.envFeatureFlags).toEqual({
|
||||
expect(initialSettings.envFeatureFlags).toEqual({
|
||||
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
|
||||
});
|
||||
|
||||
@@ -315,7 +323,7 @@ describe('FrontendService', () => {
|
||||
};
|
||||
|
||||
// getSettings should refresh the flags
|
||||
const settings = service.getSettings();
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(settings.envFeatureFlags).toEqual({
|
||||
N8N_ENV_FEAT_INITIAL_FLAG: 'false',
|
||||
@@ -326,33 +334,33 @@ describe('FrontendService', () => {
|
||||
});
|
||||
|
||||
describe('aiBuilder setting', () => {
|
||||
it('should initialize aiBuilder setting as disabled by default', () => {
|
||||
it('should initialize aiBuilder setting as disabled by default', async () => {
|
||||
const { service } = createMockService();
|
||||
|
||||
expect(service.settings.aiBuilder).toEqual({
|
||||
const initialSettings = await service.getSettings();
|
||||
expect(initialSettings.aiBuilder).toEqual({
|
||||
enabled: false,
|
||||
setup: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set aiBuilder.enabled to true when license has feat:aiBuilder', () => {
|
||||
it('should set aiBuilder.enabled to true when license has feat:aiBuilder', async () => {
|
||||
const { service, license } = createMockService();
|
||||
|
||||
license.isLicensed.mockImplementation((feature) => {
|
||||
return feature === 'feat:aiBuilder';
|
||||
});
|
||||
|
||||
const settings = service.getSettings();
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(settings.aiBuilder.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', () => {
|
||||
it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', async () => {
|
||||
const { service, license } = createMockService();
|
||||
|
||||
license.isLicensed.mockReturnValue(false);
|
||||
|
||||
const settings = service.getSettings();
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(settings.aiBuilder.enabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { GLOBAL_OWNER_ROLE } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { HooksService } from '@/services/hooks.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { createUserShell } from '@test-integration/db/users';
|
||||
|
||||
let hookService: HooksService;
|
||||
let ownershipService: OwnershipService;
|
||||
|
||||
// See PAY-4247 - This test case can be deleted when the ticket is complete
|
||||
describe('Ownership Service integration test', () => {
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
await createUserShell(GLOBAL_OWNER_ROLE);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
hookService = Container.get(HooksService);
|
||||
ownershipService = Container.get(OwnershipService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
it('should recognise ownership creation from cloud hooks', async () => {
|
||||
expect(await ownershipService.hasInstanceOwner()).toBeFalsy();
|
||||
const shellOwnerUser = await hookService.findOneUser({
|
||||
where: {
|
||||
role: {
|
||||
slug: GLOBAL_OWNER_ROLE.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
// @ts-expect-error - this is how this function is called in the cloud hook so I match it here
|
||||
await hookService.saveUser({
|
||||
firstName: 'FN',
|
||||
lastName: 'LN',
|
||||
email: 'fn@ln.com',
|
||||
password: '<hashed_password>',
|
||||
id: shellOwnerUser!.id,
|
||||
});
|
||||
expect(await ownershipService.hasInstanceOwner()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should recognise ownership creation from api', async () => {
|
||||
expect(await ownershipService.hasInstanceOwner()).toBeFalsy();
|
||||
await ownershipService.setupOwner({
|
||||
firstName: 'TEST',
|
||||
lastName: 'LN',
|
||||
password: 'PW',
|
||||
email: 'EM@em.com',
|
||||
});
|
||||
expect(await ownershipService.hasInstanceOwner()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { SharedCredentials } from '@n8n/db';
|
||||
import {
|
||||
Project,
|
||||
SharedWorkflow,
|
||||
@@ -12,10 +12,15 @@ import {
|
||||
GLOBAL_OWNER_ROLE,
|
||||
PROJECT_OWNER_ROLE,
|
||||
} from '@n8n/db';
|
||||
import type { SharedCredentials, SettingsRepository } from '@n8n/db';
|
||||
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { mockCredential, mockProject } from '@test/mock-objects';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
@@ -25,11 +30,20 @@ describe('OwnershipService', () => {
|
||||
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
|
||||
const projectRelationRepository = mockInstance(ProjectRelationRepository);
|
||||
const cacheService = mockInstance(CacheService);
|
||||
const passwordUtility = mockInstance(PasswordUtility);
|
||||
const logger = mockInstance(Logger);
|
||||
const eventService = mock<EventService>();
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
|
||||
const ownershipService = new OwnershipService(
|
||||
cacheService,
|
||||
userRepository,
|
||||
eventService,
|
||||
logger,
|
||||
passwordUtility,
|
||||
projectRelationRepository,
|
||||
sharedWorkflowRepository,
|
||||
userRepository,
|
||||
settingsRepository,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -67,7 +81,8 @@ describe('OwnershipService', () => {
|
||||
owner.role = GLOBAL_OWNER_ROLE;
|
||||
const projectRelation = new ProjectRelation();
|
||||
projectRelation.role = PROJECT_OWNER_ROLE;
|
||||
(projectRelation.project = project), (projectRelation.user = owner);
|
||||
projectRelation.project = project;
|
||||
projectRelation.user = owner;
|
||||
|
||||
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
|
||||
|
||||
@@ -94,8 +109,9 @@ describe('OwnershipService', () => {
|
||||
owner.id = uuid();
|
||||
owner.role = GLOBAL_OWNER_ROLE;
|
||||
const projectRelation = new ProjectRelation();
|
||||
projectRelation.role = { slug: PROJECT_OWNER_ROLE_SLUG } as any;
|
||||
(projectRelation.project = project), (projectRelation.user = owner);
|
||||
projectRelation.role = PROJECT_OWNER_ROLE;
|
||||
projectRelation.project = project;
|
||||
projectRelation.user = owner;
|
||||
|
||||
cacheService.getHashValue.mockResolvedValueOnce(owner);
|
||||
userRepository.create.mockReturnValueOnce(owner);
|
||||
@@ -226,4 +242,50 @@ describe('OwnershipService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupOwner()', () => {
|
||||
it('should throw a BadRequestError if the instance owner is already setup', async () => {
|
||||
jest.spyOn(userRepository, 'exists').mockResolvedValueOnce(true);
|
||||
|
||||
await expect(ownershipService.setupOwner(mock())).rejects.toThrowError(
|
||||
new BadRequestError('Instance owner already setup'),
|
||||
);
|
||||
|
||||
expect(userRepository.save).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Request to claim instance ownership failed because instance owner already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup the instance owner successfully', async () => {
|
||||
const user = mock<User>({
|
||||
id: 'userId',
|
||||
role: GLOBAL_OWNER_ROLE,
|
||||
authIdentities: [],
|
||||
});
|
||||
|
||||
const payload = {
|
||||
email: 'valid@email.com',
|
||||
password: 'NewPassword123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
};
|
||||
|
||||
// not quite perfect as we hash the password.
|
||||
const expected = { ...user, ...payload, id: 'newUserId' };
|
||||
|
||||
userRepository.exists.mockResolvedValueOnce(false);
|
||||
userRepository.findOneOrFail.mockResolvedValueOnce(user);
|
||||
userRepository.save.mockResolvedValueOnce(expected);
|
||||
|
||||
const actual = await ownershipService.setupOwner(payload);
|
||||
|
||||
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
||||
expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', {
|
||||
userId: 'newUserId',
|
||||
});
|
||||
expect(actual.id).toEqual('newUserId');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
|
||||
import { License } from '@/license';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { MfaService } from '@/mfa/mfa.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
|
||||
import type { CommunityPackagesService } from '@/modules/community-packages/community-packages.service';
|
||||
import { isApiEnabled } from '@/public-api';
|
||||
@@ -93,7 +94,7 @@ export type PublicFrontendSettings = {
|
||||
|
||||
@Service()
|
||||
export class FrontendService {
|
||||
settings: FrontendSettings;
|
||||
private settings: FrontendSettings;
|
||||
|
||||
private communityPackagesService?: CommunityPackagesService;
|
||||
|
||||
@@ -113,12 +114,10 @@ export class FrontendService {
|
||||
private readonly licenseState: LicenseState,
|
||||
private readonly moduleRegistry: ModuleRegistry,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
) {
|
||||
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
|
||||
void this.generateTypes();
|
||||
|
||||
this.initSettings();
|
||||
|
||||
// @TODO: Move to community-packages module
|
||||
if (Container.get(CommunityPackagesConfig).enabled) {
|
||||
void import('@/modules/community-packages/community-packages.service').then(
|
||||
@@ -141,7 +140,7 @@ export class FrontendService {
|
||||
return envFeatureFlags;
|
||||
}
|
||||
|
||||
private initSettings() {
|
||||
private async initSettings() {
|
||||
const instanceBaseUrl = this.urlService.getInstanceBaseUrl();
|
||||
const restEndpoint = this.globalConfig.endpoints.rest;
|
||||
|
||||
@@ -230,7 +229,7 @@ export class FrontendService {
|
||||
defaultLocale: this.globalConfig.defaultLocale,
|
||||
userManagement: {
|
||||
quota: this.license.getUsersLimit(),
|
||||
showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'),
|
||||
showSetupOnFirstLoad: !(await this.ownershipService.hasInstanceOwner()),
|
||||
smtpSetup: this.mailer.isEmailSetUp,
|
||||
authenticationMethod: getCurrentAuthenticationMethod(),
|
||||
},
|
||||
@@ -374,7 +373,10 @@ export class FrontendService {
|
||||
this.writeStaticJSON('credentials', credentials);
|
||||
}
|
||||
|
||||
getSettings(): FrontendSettings {
|
||||
async getSettings(): Promise<FrontendSettings> {
|
||||
if (!this.settings) {
|
||||
await this.initSettings();
|
||||
}
|
||||
const restEndpoint = this.globalConfig.endpoints.rest;
|
||||
|
||||
// Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel`
|
||||
@@ -390,7 +392,7 @@ export class FrontendService {
|
||||
Object.assign(this.settings.userManagement, {
|
||||
quota: this.license.getUsersLimit(),
|
||||
authenticationMethod: getCurrentAuthenticationMethod(),
|
||||
showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'),
|
||||
showSetupOnFirstLoad: !(await this.ownershipService.hasInstanceOwner()),
|
||||
});
|
||||
|
||||
let dismissedBanners: string[] = [];
|
||||
@@ -517,7 +519,7 @@ export class FrontendService {
|
||||
* Only add settings that are absolutely necessary for non-authenticated pages
|
||||
* @returns Public settings for unauthenticated users
|
||||
*/
|
||||
getPublicSettings(): PublicFrontendSettings {
|
||||
async getPublicSettings(): Promise<PublicFrontendSettings> {
|
||||
// Get full settings to ensure all required properties are initialized
|
||||
const {
|
||||
userManagement: { authenticationMethod, showSetupOnFirstLoad, smtpSetup },
|
||||
@@ -525,7 +527,7 @@ export class FrontendService {
|
||||
authCookie,
|
||||
previewMode,
|
||||
enterprise: { saml, ldap, oidc },
|
||||
} = this.getSettings();
|
||||
} = await this.getSettings();
|
||||
|
||||
const publicSettings: PublicFrontendSettings = {
|
||||
settingsMode: 'public',
|
||||
|
||||
@@ -7,19 +7,31 @@ import {
|
||||
SharedWorkflowRepository,
|
||||
UserRepository,
|
||||
Role,
|
||||
SettingsRepository,
|
||||
Scope,
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { OwnerSetupRequestDto } from '@n8n/api-types';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { PasswordUtility } from './password.utility';
|
||||
import { IsNull } from '@n8n/typeorm/find-options/operator/IsNull';
|
||||
import { Not } from '@n8n/typeorm/find-options/operator/Not';
|
||||
import config from '@/config';
|
||||
|
||||
@Service()
|
||||
export class OwnershipService {
|
||||
constructor(
|
||||
private cacheService: CacheService,
|
||||
private userRepository: UserRepository,
|
||||
private eventService: EventService,
|
||||
private logger: Logger,
|
||||
private passwordUtility: PasswordUtility,
|
||||
private projectRelationRepository: ProjectRelationRepository,
|
||||
private sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private userRepository: UserRepository,
|
||||
private settingsRepository: SettingsRepository,
|
||||
) {}
|
||||
|
||||
// To make use of the cache service we should store POJOs, these
|
||||
@@ -179,4 +191,64 @@ export class OwnershipService {
|
||||
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
|
||||
});
|
||||
}
|
||||
|
||||
async hasInstanceOwner() {
|
||||
return await this.userRepository.exists({
|
||||
where: [
|
||||
{
|
||||
role: { slug: GLOBAL_OWNER_ROLE.slug },
|
||||
// We use this to avoid selecting the "shell" user
|
||||
lastActiveAt: Not(IsNull()),
|
||||
},
|
||||
// OR
|
||||
// This condition only exists because of PAY-4247
|
||||
{
|
||||
role: { slug: GLOBAL_OWNER_ROLE.slug },
|
||||
// We use this to avoid selecting the "shell" user
|
||||
password: Not(IsNull()),
|
||||
},
|
||||
],
|
||||
relations: ['role'],
|
||||
});
|
||||
}
|
||||
|
||||
async setupOwner(payload: OwnerSetupRequestDto) {
|
||||
const { email, firstName, lastName, password } = payload;
|
||||
if (await this.hasInstanceOwner()) {
|
||||
this.logger.debug(
|
||||
'Request to claim instance ownership failed because instance owner already exists',
|
||||
);
|
||||
throw new BadRequestError('Instance owner already setup');
|
||||
}
|
||||
|
||||
let shellUser = await this.userRepository.findOneOrFail({
|
||||
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
|
||||
relations: ['role'],
|
||||
});
|
||||
|
||||
shellUser.email = email;
|
||||
shellUser.firstName = firstName;
|
||||
shellUser.lastName = lastName;
|
||||
shellUser.lastActiveAt = new Date();
|
||||
shellUser.password = await this.passwordUtility.hash(password);
|
||||
|
||||
shellUser = await this.userRepository.save(shellUser, { transaction: false });
|
||||
|
||||
this.logger.info('Owner was set up successfully');
|
||||
this.eventService.emit('instance-owner-setup', { userId: shellUser.id });
|
||||
|
||||
// The next block needs to be deleted and is temporary for now
|
||||
// See packages/cli/src/config/schema.ts for more info
|
||||
// We update the SettingsRepository so when we "startup" next time
|
||||
// the config state is restored.
|
||||
// #region Delete me
|
||||
await this.settingsRepository.update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: JSON.stringify(true) },
|
||||
);
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
// #endregion
|
||||
|
||||
return shellUser;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,6 @@ describe('Member', () => {
|
||||
password: memberPassword,
|
||||
role: GLOBAL_MEMBER_ROLE,
|
||||
});
|
||||
await utils.setInstanceOwnerSetUp(true);
|
||||
});
|
||||
|
||||
test('POST /api-keys should create an api key with no expiration', async () => {
|
||||
|
||||
@@ -31,7 +31,6 @@ beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
config.set('ldap.disabled', true);
|
||||
await utils.setInstanceOwnerSetUp(true);
|
||||
});
|
||||
|
||||
describe('POST /login', () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@n8n/backend-test-utils';
|
||||
import {
|
||||
CredentialsEntity,
|
||||
SettingsRepository,
|
||||
CredentialsRepository,
|
||||
SharedCredentialsRepository,
|
||||
SharedWorkflowRepository,
|
||||
@@ -54,12 +53,6 @@ test('user-management:reset should reset DB to default user state', async () =>
|
||||
await encryptCredentialData(Object.assign(new CredentialsEntity(), randomCredentialPayload())),
|
||||
);
|
||||
|
||||
// mark instance as set up
|
||||
await Container.get(SettingsRepository).update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: 'true' },
|
||||
);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
@@ -100,9 +93,4 @@ test('user-management:reset should reset DB to default user state', async () =>
|
||||
await expect(
|
||||
Container.get(SharedCredentialsRepository).findBy({ credentialsId: danglingCredential.id }),
|
||||
).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]);
|
||||
|
||||
// the instance is marked as not set up:
|
||||
await expect(
|
||||
Container.get(SettingsRepository).findBy({ key: 'userManagement.isInstanceOwnerSetUp' }),
|
||||
).resolves.toMatchObject([{ value: 'false' }]);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { mock } from 'jest-mock-extended';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import config from '@/config';
|
||||
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import { License } from '@/license';
|
||||
@@ -112,7 +111,6 @@ beforeAll(async () => {
|
||||
authOwnerAgent = testServer.authAgentFor(owner);
|
||||
const member = await createUser();
|
||||
authMemberAgent = testServer.authAgentFor(member);
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
Container.set(
|
||||
ExternalSecretsManager,
|
||||
new ExternalSecretsManager(
|
||||
|
||||
@@ -64,8 +64,6 @@ beforeEach(async () => {
|
||||
|
||||
jest.mock('@/telemetry');
|
||||
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
|
||||
await setCurrentAuthenticationMethod('email');
|
||||
});
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ describe('Member', () => {
|
||||
role: { slug: 'global:member' },
|
||||
});
|
||||
authMemberAgent = testServer.authAgentFor(member);
|
||||
await utils.setInstanceOwnerSetUp(true);
|
||||
});
|
||||
|
||||
test('PATCH /me should succeed with valid inputs', async () => {
|
||||
@@ -286,7 +285,6 @@ describe('Chat User', () => {
|
||||
role: { slug: 'global:chatUser' },
|
||||
});
|
||||
authMemberAgent = testServer.authAgentFor(member);
|
||||
await utils.setInstanceOwnerSetUp(true);
|
||||
});
|
||||
|
||||
test('PATCH /me should succeed with valid inputs', async () => {
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import { GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { Container } from '@n8n/di';
|
||||
import validator from 'validator';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
import { createUserShell } from './shared/db/users';
|
||||
import * as utils from './shared/utils/';
|
||||
|
||||
@@ -21,7 +20,6 @@ let ownerShell: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
ownerShell = await createUserShell(GLOBAL_OWNER_ROLE);
|
||||
config.set('userManagement.isInstanceOwnerSetUp', false);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -71,10 +69,7 @@ describe('POST /owner/setup', () => {
|
||||
expect(storedOwner.firstName).toBe(newOwnerData.firstName);
|
||||
expect(storedOwner.lastName).toBe(newOwnerData.lastName);
|
||||
|
||||
const isInstanceOwnerSetUpConfig = config.getEnv('userManagement.isInstanceOwnerSetUp');
|
||||
expect(isInstanceOwnerSetUpConfig).toBe(true);
|
||||
|
||||
const isInstanceOwnerSetUpSetting = await utils.isInstanceOwnerSetUp();
|
||||
const isInstanceOwnerSetUpSetting = await Container.get(OwnershipService).hasInstanceOwner();
|
||||
expect(isInstanceOwnerSetUpSetting).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,13 +43,14 @@ async function handlePasswordSetup(password: string | null | undefined): Promise
|
||||
|
||||
/** Store a new user object, defaulting to a `member` */
|
||||
export async function newUser(attributes: DeepPartial<User> = {}): Promise<User> {
|
||||
const { email, password, firstName, lastName, role, ...rest } = attributes;
|
||||
const { email, password, firstName, lastName, role, lastActiveAt, ...rest } = attributes;
|
||||
return Container.get(UserRepository).create({
|
||||
email: email ?? randomEmail(),
|
||||
password: await handlePasswordSetup(password),
|
||||
firstName: firstName ?? randomName(),
|
||||
lastName: lastName ?? randomName(),
|
||||
role: role ?? GLOBAL_MEMBER_ROLE,
|
||||
lastActiveAt: lastActiveAt ?? new Date(),
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { SettingsRepository, WorkflowEntity } from '@n8n/db';
|
||||
import { WorkflowEntity } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import {
|
||||
@@ -25,7 +25,6 @@ import type { INodeTypeData, INode } from 'n8n-workflow';
|
||||
import type request from 'supertest';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import config from '@/config';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import { ExecutionService } from '@/executions/execution.service';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
@@ -159,27 +158,6 @@ export function getAuthToken(response: request.Response, authCookieName = AUTH_C
|
||||
return match.groups.token;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// settings
|
||||
// ----------------------------------
|
||||
|
||||
export async function isInstanceOwnerSetUp() {
|
||||
const { value } = await Container.get(SettingsRepository).findOneByOrFail({
|
||||
key: 'userManagement.isInstanceOwnerSetUp',
|
||||
});
|
||||
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
export const setInstanceOwnerSetUp = async (value: boolean) => {
|
||||
config.set('userManagement.isInstanceOwnerSetUp', value);
|
||||
|
||||
await Container.get(SettingsRepository).update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: JSON.stringify(value) },
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------
|
||||
// community nodes
|
||||
// ----------------------------------
|
||||
|
||||
@@ -10,7 +10,6 @@ import request from 'supertest';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import { ControllerRegistry } from '@/controller.registry';
|
||||
import { License } from '@/license';
|
||||
@@ -129,7 +128,6 @@ export const setupTestServer = ({
|
||||
await testDb.init();
|
||||
|
||||
Container.get(GlobalConfig).userManagement.jwtSecret = 'My JWT secret';
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
|
||||
testServer.license.mock(Container.get(License));
|
||||
testServer.license.mockLicenseState(Container.get(LicenseState));
|
||||
|
||||
Reference in New Issue
Block a user