n8n/packages/cli/src/controllers/users.controller.ts

563 lines
17 KiB
TypeScript
Raw Normal View History

import validator from 'validator';
import { In, Repository } from 'typeorm';
import { ErrorReporterProxy as ErrorReporter, ILogger } from 'n8n-workflow';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Delete, Get, Post, RestController } from '@/decorators';
import {
addInviteLinkToUser,
generateUserInviteUrl,
getInstanceBaseUrl,
hashPassword,
isEmailSetUp,
isUserManagementDisabled,
sanitizeUser,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper';
import type { Response } from 'express';
import type { Config } from '@/config';
import type { UserRequest } from '@/requests';
import type { UserManagementMailer } from '@/UserManagement/email';
import type { Role } from '@db/entities/Role';
import type {
PublicUser,
IDatabaseCollections,
IExternalHooksClass,
IInternalHooksClass,
ITelemetryUserDeletionData,
} from '@/Interfaces';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity';
@RestController('/users')
export class UsersController {
private config: Config;
private logger: ILogger;
private externalHooks: IExternalHooksClass;
private internalHooks: IInternalHooksClass;
private userRepository: Repository<User>;
private roleRepository: Repository<Role>;
private sharedCredentialsRepository: Repository<SharedCredentials>;
private sharedWorkflowRepository: Repository<SharedWorkflow>;
private activeWorkflowRunner: ActiveWorkflowRunner;
private mailer: UserManagementMailer;
constructor({
config,
logger,
externalHooks,
internalHooks,
repositories,
activeWorkflowRunner,
mailer,
}: {
config: Config;
logger: ILogger;
externalHooks: IExternalHooksClass;
internalHooks: IInternalHooksClass;
repositories: Pick<
IDatabaseCollections,
'User' | 'Role' | 'SharedCredentials' | 'SharedWorkflow'
>;
activeWorkflowRunner: ActiveWorkflowRunner;
mailer: UserManagementMailer;
}) {
this.config = config;
this.logger = logger;
this.externalHooks = externalHooks;
this.internalHooks = internalHooks;
this.userRepository = repositories.User;
this.roleRepository = repositories.Role;
this.sharedCredentialsRepository = repositories.SharedCredentials;
this.sharedWorkflowRepository = repositories.SharedWorkflow;
this.activeWorkflowRunner = activeWorkflowRunner;
this.mailer = mailer;
}
/**
* Send email invite(s) to one or multiple users and create user shell(s).
*/
@Post('/')
async sendEmailInvites(req: UserRequest.Invite) {
// TODO: this should be checked in the middleware rather than here
if (isUserManagementDisabled()) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because user management is disabled',
);
throw new BadRequestError('User management is disabled');
}
if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
);
throw new BadRequestError('You must set up your own account before inviting others');
}
if (!Array.isArray(req.body)) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the payload is not an array',
{
payload: req.body,
},
);
throw new BadRequestError('Invalid payload');
}
if (!req.body.length) return [];
const createUsers: { [key: string]: string | null } = {};
// Validate payload
req.body.forEach((invite) => {
if (typeof invite !== 'object' || !invite.email) {
throw new BadRequestError(
'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>',
);
}
if (!validator.isEmail(invite.email)) {
this.logger.debug('Invalid email in payload', { invalidEmail: invite.email });
throw new BadRequestError(
`Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`,
);
}
createUsers[invite.email.toLowerCase()] = null;
});
const role = await this.roleRepository.findOneBy({ scope: 'global', name: 'member' });
if (!role) {
this.logger.error(
'Request to send email invite(s) to user(s) failed because no global member role was found in database',
);
throw new InternalServerError('Members role not found in database - inconsistent state');
}
// remove/exclude existing users from creation
const existingUsers = await this.userRepository.find({
where: { email: In(Object.keys(createUsers)) },
});
existingUsers.forEach((user) => {
if (user.password) {
delete createUsers[user.email];
return;
}
createUsers[user.email] = user.id;
});
const usersToSetUp = Object.keys(createUsers).filter((email) => createUsers[email] === null);
const total = usersToSetUp.length;
this.logger.debug(total > 1 ? `Creating ${total} user shells...` : 'Creating 1 user shell...');
try {
await this.userRepository.manager.transaction(async (transactionManager) =>
Promise.all(
usersToSetUp.map(async (email) => {
const newUser = Object.assign(new User(), {
email,
globalRole: role,
});
const savedUser = await transactionManager.save<User>(newUser);
createUsers[savedUser.email] = savedUser.id;
return savedUser;
}),
),
);
} catch (error) {
ErrorReporter.error(error);
this.logger.error('Failed to create user shells', { userShells: createUsers });
throw new InternalServerError('An error occurred during user creation');
}
this.logger.debug('Created user shell(s) successfully', { userId: req.user.id });
this.logger.verbose(total > 1 ? `${total} user shells created` : '1 user shell created', {
userShells: createUsers,
});
const baseUrl = getInstanceBaseUrl();
const usersPendingSetup = Object.entries(createUsers).filter(([email, id]) => id && email);
// send invite email to new or not yet setup users
const emailingResults = await Promise.all(
usersPendingSetup.map(async ([email, id]) => {
if (!id) {
// This should never happen since those are removed from the list before reaching this point
throw new InternalServerError('User ID is missing for user with email address');
}
const inviteAcceptUrl = generateUserInviteUrl(req.user.id, id);
const resp: {
user: { id: string | null; email: string; inviteAcceptUrl: string; emailSent: boolean };
error?: string;
} = {
user: {
id,
email,
inviteAcceptUrl,
emailSent: false,
},
};
try {
const result = await this.mailer.invite({
email,
inviteAcceptUrl,
domain: baseUrl,
});
if (result.emailSent) {
resp.user.emailSent = true;
void this.internalHooks.onUserTransactionalEmail({
user_id: id,
message_type: 'New user invite',
public_api: false,
});
}
void this.internalHooks.onUserInvite({
user: req.user,
target_user_id: Object.values(createUsers) as string[],
public_api: false,
email_sent: result.emailSent,
});
} catch (error) {
if (error instanceof Error) {
void this.internalHooks.onEmailFailed({
user: req.user,
message_type: 'New user invite',
public_api: false,
});
this.logger.error('Failed to send email', {
userId: req.user.id,
inviteAcceptUrl,
domain: baseUrl,
email,
});
resp.error = error.message;
}
}
return resp;
}),
);
await this.externalHooks.run('user.invited', [usersToSetUp]);
this.logger.debug(
usersPendingSetup.length > 1
? `Sent ${usersPendingSetup.length} invite emails successfully`
: 'Sent 1 invite email successfully',
{ userShells: createUsers },
);
return emailingResults;
}
/**
* Fill out user shell with first name, last name, and password.
*/
@Post('/:id')
async updateUser(req: UserRequest.Update, res: Response) {
const { id: inviteeId } = req.params;
const { inviterId, firstName, lastName, password } = req.body;
if (!inviterId || !inviteeId || !firstName || !lastName || !password) {
this.logger.debug(
'Request to fill out a user shell failed because of missing properties in payload',
{ payload: req.body },
);
throw new BadRequestError('Invalid payload');
}
const validPassword = validatePassword(password);
const users = await this.userRepository.find({
where: { id: In([inviterId, inviteeId]) },
relations: ['globalRole'],
});
if (users.length !== 2) {
this.logger.debug(
'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database',
{
inviterId,
inviteeId,
},
);
throw new BadRequestError('Invalid payload or URL');
}
const invitee = users.find((user) => user.id === inviteeId) as User;
if (invitee.password) {
this.logger.debug(
'Request to fill out a user shell failed because the invite had already been accepted',
{ inviteeId },
);
throw new BadRequestError('This invite has been accepted already');
}
invitee.firstName = firstName;
invitee.lastName = lastName;
invitee.password = await hashPassword(validPassword);
const updatedUser = await this.userRepository.save(invitee);
await issueCookie(res, updatedUser);
void this.internalHooks.onUserSignup(updatedUser, {
user_type: 'email',
was_disabled_ldap_user: false,
});
await this.externalHooks.run('user.profile.update', [invitee.email, sanitizeUser(invitee)]);
await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]);
return sanitizeUser(updatedUser);
}
@Get('/')
async listUsers(req: UserRequest.List) {
const users = await this.userRepository.find({ relations: ['globalRole', 'authIdentities'] });
return users.map(
(user): PublicUser =>
addInviteLinkToUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id),
);
}
/**
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/
@Delete('/:id')
async deleteUser(req: UserRequest.Delete) {
const { id: idToDelete } = req.params;
if (req.user.id === idToDelete) {
this.logger.debug(
'Request to delete a user failed because it attempted to delete the requesting user',
{ userId: req.user.id },
);
throw new BadRequestError('Cannot delete your own user');
}
const { transferId } = req.query;
if (transferId === idToDelete) {
throw new BadRequestError(
'Request to delete a user failed because the user to delete and the transferee are the same user',
);
}
const users = await this.userRepository.find({
where: { id: In([transferId, idToDelete]) },
});
if (!users.length || (transferId && users.length !== 2)) {
throw new NotFoundError(
'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB',
);
}
const userToDelete = users.find((user) => user.id === req.params.id) as User;
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
const [workflowOwnerRole, credentialOwnerRole] = await Promise.all([
this.roleRepository.findOneBy({ name: 'owner', scope: 'workflow' }),
this.roleRepository.findOneBy({ name: 'owner', scope: 'credential' }),
]);
if (transferId) {
const transferee = users.find((user) => user.id === transferId);
await this.userRepository.manager.transaction(async (transactionManager) => {
// Get all workflow ids belonging to user to delete
const sharedWorkflowIds = await transactionManager
.getRepository(SharedWorkflow)
.find({
select: ['workflowId'],
where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id },
})
.then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedWorkflow, {
user: transferee,
workflowId: In(sharedWorkflowIds),
});
// Transfer ownership of owned workflows
await transactionManager.update(
SharedWorkflow,
{ user: userToDelete, role: workflowOwnerRole },
{ user: transferee },
);
// Now do the same for creds
// Get all workflow ids belonging to user to delete
const sharedCredentialIds = await transactionManager
.getRepository(SharedCredentials)
.find({
select: ['credentialsId'],
where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id },
})
.then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedCredentials, {
user: transferee,
credentialsId: In(sharedCredentialIds),
});
// Transfer ownership of owned credentials
await transactionManager.update(
SharedCredentials,
{ user: userToDelete, role: credentialOwnerRole },
{ user: transferee },
);
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
// This will remove all shared workflows and credentials not owned
await transactionManager.delete(User, { id: userToDelete.id });
});
void this.internalHooks.onUserDeletion({
user: req.user,
telemetryData,
publicApi: false,
});
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
this.sharedWorkflowRepository.find({
relations: ['workflow'],
where: { userId: userToDelete.id, roleId: workflowOwnerRole?.id },
}),
this.sharedCredentialsRepository.find({
relations: ['credentials'],
where: { userId: userToDelete.id, roleId: credentialOwnerRole?.id },
}),
]);
await this.userRepository.manager.transaction(async (transactionManager) => {
const ownedWorkflows = await Promise.all(
ownedSharedWorkflows.map(async ({ workflow }) => {
if (workflow.active) {
// deactivate before deleting
await this.activeWorkflowRunner.remove(workflow.id);
}
return workflow;
}),
);
await transactionManager.remove(ownedWorkflows);
await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials));
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
await transactionManager.delete(User, { id: userToDelete.id });
});
void this.internalHooks.onUserDeletion({
user: req.user,
telemetryData,
publicApi: false,
});
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}
/**
* Resend email invite to user.
*/
@Post('/:id/reinvite')
async reinviteUser(req: UserRequest.Reinvite) {
const { id: idToReinvite } = req.params;
if (!isEmailSetUp()) {
this.logger.error('Request to reinvite a user failed because email sending was not set up');
throw new InternalServerError('Email sending must be set up in order to invite other users');
}
const reinvitee = await this.userRepository.findOneBy({ id: idToReinvite });
if (!reinvitee) {
this.logger.debug(
'Request to reinvite a user failed because the ID of the reinvitee was not found in database',
);
throw new NotFoundError('Could not find user');
}
if (reinvitee.password) {
this.logger.debug(
'Request to reinvite a user failed because the invite had already been accepted',
{ userId: reinvitee.id },
);
throw new BadRequestError('User has already accepted the invite');
}
const baseUrl = getInstanceBaseUrl();
const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`;
try {
const result = await this.mailer.invite({
email: reinvitee.email,
inviteAcceptUrl,
domain: baseUrl,
});
if (result.emailSent) {
void this.internalHooks.onUserReinvite({
user: req.user,
target_user_id: reinvitee.id,
public_api: false,
});
void this.internalHooks.onUserTransactionalEmail({
user_id: reinvitee.id,
message_type: 'Resend invite',
public_api: false,
});
}
} catch (error) {
void this.internalHooks.onEmailFailed({
user: reinvitee,
message_type: 'Resend invite',
public_api: false,
});
this.logger.error('Failed to send email', {
email: reinvitee.email,
inviteAcceptUrl,
domain: baseUrl,
});
throw new InternalServerError(`Failed to send email to ${reinvitee.email}`);
}
return { success: true };
}
}