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

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

330 lines
10 KiB
TypeScript
Raw Normal View History

import { plainToInstance } from 'class-transformer';
import { AuthService } from '@/auth/auth.service';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { GlobalScope, Delete, Get, RestController, Patch, Licensed } from '@/decorators';
import {
ListQuery,
UserRequest,
UserRoleChangePayload,
UserSettingsUpdatePayload,
} from '@/requests';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
import { AuthIdentity } from '@db/entities/AuthIdentity';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { UserRepository } from '@db/repositories/user.repository';
import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares';
import { Logger } from '@/Logger';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
import { validateEntity } from '@/GenericHelpers';
@RestController('/users')
export class UsersController {
constructor(
private readonly logger: Logger,
private readonly externalHooks: ExternalHooks,
private readonly internalHooks: InternalHooks,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly userRepository: UserRepository,
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
static ERROR_MESSAGES = {
CHANGE_ROLE: {
NO_USER: 'Target user not found',
NO_ADMIN_ON_OWNER: 'Admin cannot change role on global owner',
NO_OWNER_ON_OWNER: 'Owner cannot change role on global owner',
},
} as const;
private removeSupplementaryFields(
publicUsers: Array<Partial<PublicUser>>,
listQueryOptions: ListQuery.Options,
) {
const { take, select, filter } = listQueryOptions;
// remove fields added to satisfy query
if (take && select && !select?.id) {
for (const user of publicUsers) delete user.id;
}
if (filter?.isOwner) {
for (const user of publicUsers) delete user.role;
}
// remove computed fields (unselectable)
if (select) {
for (const user of publicUsers) {
delete user.isOwner;
delete user.isPending;
delete user.signInType;
delete user.hasRecoveryCodesLeft;
}
}
return publicUsers;
}
@Get('/', { middlewares: listQueryMiddleware })
@GlobalScope('user:list')
async listUsers(req: ListQuery.Request) {
const { listQueryOptions } = req;
const findManyOptions = await this.userRepository.toFindManyOptions(listQueryOptions);
const users = await this.userRepository.find(findManyOptions);
const publicUsers: Array<Partial<PublicUser>> = await Promise.all(
users.map(
async (u) =>
await this.userService.toPublic(u, { withInviteUrl: true, inviterId: req.user.id }),
),
);
return listQueryOptions
? this.removeSupplementaryFields(publicUsers, listQueryOptions)
: publicUsers;
}
@Get('/:id/password-reset-link')
@GlobalScope('user:resetPassword')
async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) {
const user = await this.userRepository.findOneOrFail({
where: { id: req.params.id },
});
if (!user) {
throw new NotFoundError('User not found');
}
const link = this.authService.generatePasswordResetUrl(user);
return { link };
}
@Patch('/:id/settings')
@GlobalScope('user:update')
async updateUserSettings(req: UserRequest.UserSettingsUpdate) {
const payload = plainToInstance(UserSettingsUpdatePayload, req.body, {
excludeExtraneousValues: true,
});
const id = req.params.id;
await this.userService.updateSettings(id, payload);
const user = await this.userRepository.findOneOrFail({
select: ['settings'],
where: { id },
});
return user.settings;
}
/**
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/
@Delete('/:id')
@GlobalScope('user:delete')
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 userIds = transferId ? [transferId, idToDelete] : [idToDelete];
const users = await this.userRepository.findManyByIds(userIds);
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;
}
if (transferId) {
const transferee = users.find((user) => user.id === transferId);
await this.userService.getManager().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, role: 'workflow:owner' },
})
.then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await this.sharedWorkflowRepository.deleteByIds(
transactionManager,
sharedWorkflowIds,
transferee,
);
// Transfer ownership of owned workflows
await transactionManager.update(
SharedWorkflow,
{ user: userToDelete, role: 'workflow:owner' },
{ 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, role: 'credential:owner' },
})
.then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await this.sharedCredentialsRepository.deleteByIds(
transactionManager,
sharedCredentialIds,
transferee,
);
// Transfer ownership of owned credentials
await transactionManager.update(
SharedCredentials,
{ user: userToDelete, role: 'credential:owner' },
{ 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', [await this.userService.toPublic(userToDelete)]);
return { success: true };
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
this.sharedWorkflowRepository.find({
relations: ['workflow'],
where: { userId: userToDelete.id, role: 'workflow:owner' },
}),
this.sharedCredentialsRepository.find({
relations: ['credentials'],
where: { userId: userToDelete.id, role: 'credential:owner' },
}),
]);
await this.userService.getManager().transaction(async (transactionManager) => {
const ownedWorkflows = await Promise.all(
ownedSharedWorkflows.map(async ({ workflow }) => {
if (workflow.active) {
// deactivate before deleting
await this.activeWorkflowManager.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', [await this.userService.toPublic(userToDelete)]);
return { success: true };
}
@Patch('/:id/role')
@GlobalScope('user:changeRole')
@Licensed('feat:advancedPermissions')
async changeGlobalRole(req: UserRequest.ChangeRole) {
const { NO_ADMIN_ON_OWNER, NO_USER, NO_OWNER_ON_OWNER } =
UsersController.ERROR_MESSAGES.CHANGE_ROLE;
const payload = plainToInstance(UserRoleChangePayload, req.body, {
excludeExtraneousValues: true,
});
await validateEntity(payload);
const targetUser = await this.userRepository.findOne({
where: { id: req.params.id },
});
if (targetUser === null) {
throw new NotFoundError(NO_USER);
}
if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') {
throw new UnauthorizedError(NO_ADMIN_ON_OWNER);
}
if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') {
throw new UnauthorizedError(NO_OWNER_ON_OWNER);
}
await this.userService.update(targetUser.id, { role: payload.newRoleName });
void this.internalHooks.onUserRoleChange({
user: req.user,
target_user_id: targetUser.id,
target_user_new_role: ['global', payload.newRoleName].join(' '),
public_api: false,
});
return { success: true };
}
}