2023-08-28 07:13:17 -07:00
|
|
|
import type { FindManyOptions } from 'typeorm';
|
|
|
|
import { In, Not } from 'typeorm';
|
2023-01-27 02:19:47 -08:00
|
|
|
import { User } from '@db/entities/User';
|
|
|
|
import { SharedCredentials } from '@db/entities/SharedCredentials';
|
|
|
|
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
2023-11-28 03:41:34 -08:00
|
|
|
import { RequireGlobalScope, Authorized, Delete, Get, RestController, Patch } from '@/decorators';
|
2023-08-28 07:13:17 -07:00
|
|
|
import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests';
|
|
|
|
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
|
2023-08-25 04:23:22 -07:00
|
|
|
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
|
|
|
|
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
|
2023-01-27 02:19:47 -08:00
|
|
|
import { AuthIdentity } from '@db/entities/AuthIdentity';
|
2023-11-10 06:04:26 -08:00
|
|
|
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
|
|
|
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
2023-05-30 03:52:02 -07:00
|
|
|
import { plainToInstance } from 'class-transformer';
|
2023-08-22 06:58:05 -07:00
|
|
|
import { RoleService } from '@/services/role.service';
|
|
|
|
import { UserService } from '@/services/user.service';
|
2023-08-28 07:13:17 -07:00
|
|
|
import { listQueryMiddleware } from '@/middlewares';
|
2023-10-25 07:35:22 -07:00
|
|
|
import { Logger } from '@/Logger';
|
2023-11-28 01:19:27 -08:00
|
|
|
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';
|
2023-11-29 04:55:41 -08:00
|
|
|
import { License } from '@/License';
|
2023-01-27 02:19:47 -08:00
|
|
|
|
2023-11-24 02:40:08 -08:00
|
|
|
@Authorized()
|
2023-01-27 02:19:47 -08:00
|
|
|
@RestController('/users')
|
|
|
|
export class UsersController {
|
2023-08-25 04:23:22 -07:00
|
|
|
constructor(
|
2023-10-25 07:35:22 -07:00
|
|
|
private readonly logger: Logger,
|
2023-08-25 04:23:22 -07:00
|
|
|
private readonly externalHooks: IExternalHooksClass,
|
|
|
|
private readonly internalHooks: IInternalHooksClass,
|
|
|
|
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
|
|
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
|
|
|
private readonly activeWorkflowRunner: ActiveWorkflowRunner,
|
|
|
|
private readonly roleService: RoleService,
|
|
|
|
private readonly userService: UserService,
|
2023-11-29 04:55:41 -08:00
|
|
|
private readonly license: License,
|
2023-08-25 04:23:22 -07:00
|
|
|
) {}
|
2023-01-27 02:19:47 -08:00
|
|
|
|
2023-11-24 02:40:08 -08:00
|
|
|
static ERROR_MESSAGES = {
|
|
|
|
CHANGE_ROLE: {
|
|
|
|
NO_MEMBER: 'Member cannot change role for any user',
|
|
|
|
MISSING_NEW_ROLE_KEY: 'Expected `newRole` to exist',
|
|
|
|
MISSING_NEW_ROLE_VALUE: 'Expected `newRole` to have `name` and `scope`',
|
|
|
|
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',
|
2023-11-27 08:35:58 -08:00
|
|
|
NO_USER_TO_OWNER: 'Cannot promote user to global owner',
|
2023-11-29 04:55:41 -08:00
|
|
|
NO_ADMIN_IF_UNLICENSED: 'Admin role is not available without a license',
|
2023-11-24 02:40:08 -08:00
|
|
|
},
|
|
|
|
} as const;
|
|
|
|
|
2023-08-28 07:13:17 -07:00
|
|
|
private async toFindManyOptions(listQueryOptions?: ListQuery.Options) {
|
|
|
|
const findManyOptions: FindManyOptions<User> = {};
|
|
|
|
|
|
|
|
if (!listQueryOptions) {
|
|
|
|
findManyOptions.relations = ['globalRole', 'authIdentities'];
|
|
|
|
return findManyOptions;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { filter, select, take, skip } = listQueryOptions;
|
|
|
|
|
|
|
|
if (select) findManyOptions.select = select;
|
|
|
|
if (take) findManyOptions.take = take;
|
|
|
|
if (skip) findManyOptions.skip = skip;
|
|
|
|
|
|
|
|
if (take && !select) {
|
|
|
|
findManyOptions.relations = ['globalRole', 'authIdentities'];
|
|
|
|
}
|
|
|
|
|
|
|
|
if (take && select && !select?.id) {
|
|
|
|
findManyOptions.select = { ...findManyOptions.select, id: true }; // pagination requires id
|
|
|
|
}
|
|
|
|
|
|
|
|
if (filter) {
|
|
|
|
const { isOwner, ...otherFilters } = filter;
|
|
|
|
|
|
|
|
findManyOptions.where = otherFilters;
|
|
|
|
|
|
|
|
if (isOwner !== undefined) {
|
|
|
|
const ownerRole = await this.roleService.findGlobalOwnerRole();
|
|
|
|
|
|
|
|
findManyOptions.relations = ['globalRole'];
|
|
|
|
findManyOptions.where.globalRole = { id: isOwner ? ownerRole.id : Not(ownerRole.id) };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return findManyOptions;
|
|
|
|
}
|
|
|
|
|
2023-11-24 02:40:08 -08:00
|
|
|
private removeSupplementaryFields(
|
2023-08-28 07:13:17 -07:00
|
|
|
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.globalRole;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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;
|
2023-01-27 02:19:47 -08:00
|
|
|
}
|
|
|
|
|
2023-08-28 07:13:17 -07:00
|
|
|
@Get('/', { middlewares: listQueryMiddleware })
|
2023-11-28 03:41:34 -08:00
|
|
|
@RequireGlobalScope('user:list')
|
2023-08-28 07:13:17 -07:00
|
|
|
async listUsers(req: ListQuery.Request) {
|
|
|
|
const { listQueryOptions } = req;
|
|
|
|
|
|
|
|
const findManyOptions = await this.toFindManyOptions(listQueryOptions);
|
|
|
|
|
|
|
|
const users = await this.userService.findMany(findManyOptions);
|
|
|
|
|
|
|
|
const publicUsers: Array<Partial<PublicUser>> = await Promise.all(
|
|
|
|
users.map(async (u) => this.userService.toPublic(u, { withInviteUrl: true })),
|
2023-01-27 02:19:47 -08:00
|
|
|
);
|
2023-08-28 07:13:17 -07:00
|
|
|
|
|
|
|
return listQueryOptions
|
|
|
|
? this.removeSupplementaryFields(publicUsers, listQueryOptions)
|
|
|
|
: publicUsers;
|
2023-01-27 02:19:47 -08:00
|
|
|
}
|
|
|
|
|
2023-05-30 03:52:02 -07:00
|
|
|
@Get('/:id/password-reset-link')
|
2023-11-28 03:41:34 -08:00
|
|
|
@RequireGlobalScope('user:resetPassword')
|
2023-05-30 03:52:02 -07:00
|
|
|
async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) {
|
2023-08-22 06:58:05 -07:00
|
|
|
const user = await this.userService.findOneOrFail({
|
2023-05-30 03:52:02 -07:00
|
|
|
where: { id: req.params.id },
|
|
|
|
});
|
|
|
|
if (!user) {
|
|
|
|
throw new NotFoundError('User not found');
|
|
|
|
}
|
2023-07-24 14:40:17 -07:00
|
|
|
|
2023-11-07 06:35:43 -08:00
|
|
|
const link = this.userService.generatePasswordResetUrl(user);
|
|
|
|
return { link };
|
2023-05-30 03:52:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
@Patch('/:id/settings')
|
2023-11-28 03:41:34 -08:00
|
|
|
@RequireGlobalScope('user:update')
|
2023-05-30 03:52:02 -07:00
|
|
|
async updateUserSettings(req: UserRequest.UserSettingsUpdate) {
|
|
|
|
const payload = plainToInstance(UserSettingsUpdatePayload, req.body);
|
|
|
|
|
|
|
|
const id = req.params.id;
|
|
|
|
|
2023-08-22 06:58:05 -07:00
|
|
|
await this.userService.updateSettings(id, payload);
|
2023-05-30 03:52:02 -07:00
|
|
|
|
2023-08-22 06:58:05 -07:00
|
|
|
const user = await this.userService.findOneOrFail({
|
2023-05-30 03:52:02 -07:00
|
|
|
select: ['settings'],
|
|
|
|
where: { id },
|
|
|
|
});
|
|
|
|
|
|
|
|
return user.settings;
|
|
|
|
}
|
|
|
|
|
2023-01-27 02:19:47 -08:00
|
|
|
/**
|
|
|
|
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
|
|
|
|
*/
|
2023-11-24 02:40:08 -08:00
|
|
|
@Authorized(['global', 'owner'])
|
2023-01-27 02:19:47 -08:00
|
|
|
@Delete('/:id')
|
2023-11-28 03:41:34 -08:00
|
|
|
@RequireGlobalScope('user:delete')
|
2023-01-27 02:19:47 -08:00
|
|
|
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',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-08-22 06:58:05 -07:00
|
|
|
const users = await this.userService.findMany({
|
2023-01-27 02:19:47 -08:00
|
|
|
where: { id: In([transferId, idToDelete]) },
|
2023-08-28 07:13:17 -07:00
|
|
|
relations: ['globalRole'],
|
2023-01-27 02:19:47 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
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([
|
2023-08-02 23:58:36 -07:00
|
|
|
this.roleService.findWorkflowOwnerRole(),
|
|
|
|
this.roleService.findCredentialOwnerRole(),
|
2023-01-27 02:19:47 -08:00
|
|
|
]);
|
|
|
|
|
|
|
|
if (transferId) {
|
|
|
|
const transferee = users.find((user) => user.id === transferId);
|
|
|
|
|
2023-08-22 06:58:05 -07:00
|
|
|
await this.userService.getManager().transaction(async (transactionManager) => {
|
2023-01-27 02:19:47 -08:00
|
|
|
// 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,
|
|
|
|
});
|
2023-08-28 07:13:17 -07:00
|
|
|
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
|
2023-01-27 02:19:47 -08:00
|
|
|
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 },
|
|
|
|
}),
|
|
|
|
]);
|
|
|
|
|
2023-08-22 06:58:05 -07:00
|
|
|
await this.userService.getManager().transaction(async (transactionManager) => {
|
2023-01-27 02:19:47 -08:00
|
|
|
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,
|
|
|
|
});
|
|
|
|
|
2023-08-28 07:13:17 -07:00
|
|
|
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
|
2023-01-27 02:19:47 -08:00
|
|
|
return { success: true };
|
|
|
|
}
|
2023-11-24 02:40:08 -08:00
|
|
|
|
|
|
|
// @TODO: Add scope check `@RequireGlobalScope('user:changeRole')`
|
|
|
|
// once this has been merged: https://github.com/n8n-io/n8n/pull/7737
|
|
|
|
@Authorized('any')
|
|
|
|
@Patch('/:id/role')
|
|
|
|
async changeRole(req: UserRequest.ChangeRole) {
|
|
|
|
const {
|
|
|
|
NO_MEMBER,
|
|
|
|
MISSING_NEW_ROLE_KEY,
|
|
|
|
MISSING_NEW_ROLE_VALUE,
|
|
|
|
NO_ADMIN_ON_OWNER,
|
2023-11-27 08:35:58 -08:00
|
|
|
NO_USER_TO_OWNER,
|
2023-11-24 02:40:08 -08:00
|
|
|
NO_USER,
|
|
|
|
NO_OWNER_ON_OWNER,
|
2023-11-29 04:55:41 -08:00
|
|
|
NO_ADMIN_IF_UNLICENSED,
|
2023-11-24 02:40:08 -08:00
|
|
|
} = UsersController.ERROR_MESSAGES.CHANGE_ROLE;
|
|
|
|
|
|
|
|
if (req.user.globalRole.scope === 'global' && req.user.globalRole.name === 'member') {
|
|
|
|
throw new UnauthorizedError(NO_MEMBER);
|
|
|
|
}
|
|
|
|
|
|
|
|
const { newRole } = req.body;
|
|
|
|
|
|
|
|
if (!newRole) {
|
|
|
|
throw new BadRequestError(MISSING_NEW_ROLE_KEY);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!newRole.name || !newRole.scope) {
|
|
|
|
throw new BadRequestError(MISSING_NEW_ROLE_VALUE);
|
|
|
|
}
|
|
|
|
|
2023-11-27 08:35:58 -08:00
|
|
|
if (newRole.scope === 'global' && newRole.name === 'owner') {
|
|
|
|
throw new UnauthorizedError(NO_USER_TO_OWNER);
|
2023-11-24 02:40:08 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
const targetUser = await this.userService.findOne({
|
|
|
|
where: { id: req.params.id },
|
|
|
|
});
|
|
|
|
|
|
|
|
if (targetUser === null) {
|
|
|
|
throw new NotFoundError(NO_USER);
|
|
|
|
}
|
|
|
|
|
2023-11-29 04:55:41 -08:00
|
|
|
if (
|
|
|
|
newRole.scope === 'global' &&
|
|
|
|
newRole.name === 'admin' &&
|
|
|
|
!this.license.isAdvancedPermissionsLicensed()
|
|
|
|
) {
|
|
|
|
throw new UnauthorizedError(NO_ADMIN_IF_UNLICENSED);
|
|
|
|
}
|
|
|
|
|
2023-11-24 02:40:08 -08:00
|
|
|
if (
|
|
|
|
req.user.globalRole.scope === 'global' &&
|
|
|
|
req.user.globalRole.name === 'admin' &&
|
|
|
|
targetUser.globalRole.scope === 'global' &&
|
|
|
|
targetUser.globalRole.name === 'owner'
|
|
|
|
) {
|
|
|
|
throw new UnauthorizedError(NO_ADMIN_ON_OWNER);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
req.user.globalRole.scope === 'global' &&
|
|
|
|
req.user.globalRole.name === 'owner' &&
|
|
|
|
targetUser.globalRole.scope === 'global' &&
|
|
|
|
targetUser.globalRole.name === 'owner'
|
|
|
|
) {
|
|
|
|
throw new UnauthorizedError(NO_OWNER_ON_OWNER);
|
|
|
|
}
|
|
|
|
|
|
|
|
const roleToSet = await this.roleService.findCached(newRole.scope, newRole.name);
|
|
|
|
|
|
|
|
await this.userService.update(targetUser.id, { globalRole: roleToSet });
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
}
|
2023-01-27 02:19:47 -08:00
|
|
|
}
|