feat(core): Add filtering, selection and pagination to users (#6994)

https://linear.app/n8n/issue/PAY-646
This commit is contained in:
Iván Ovejero 2023-08-28 16:13:17 +02:00 committed by GitHub
parent a7785b2c5d
commit b716241b42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 535 additions and 211 deletions

View file

@ -772,9 +772,7 @@ export interface PublicUser {
disabled: boolean; disabled: boolean;
settings?: IUserSettings | null; settings?: IUserSettings | null;
inviteAcceptUrl?: string; inviteAcceptUrl?: string;
} isOwner?: boolean;
export interface CurrentUser extends PublicUser {
featureFlags?: FeatureFlags; featureFlags?: FeatureFlags;
} }

View file

@ -4,13 +4,12 @@ import { Container } from 'typedi';
import * as Db from '@/Db'; import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import type { CurrentUser, PublicUser, WhereClause } from '@/Interfaces'; import type { WhereClause } from '@/Interfaces';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User';
import config from '@/config'; import config from '@/config';
import { License } from '@/License'; import { License } from '@/License';
import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { getWebhookBaseUrl } from '@/WebhookHelpers';
import type { PostHogClient } from '@/posthog';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
export function isEmailSetUp(): boolean { export function isEmailSetUp(): boolean {
@ -84,64 +83,6 @@ export function validatePassword(password?: string): string {
return password; return password;
} }
/**
* Remove sensitive properties from the user to return to the client.
*/
export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
const { password, updatedAt, apiKey, authIdentities, mfaSecret, mfaRecoveryCodes, ...rest } =
user;
if (withoutKeys) {
withoutKeys.forEach((key) => {
// @ts-ignore
delete rest[key];
});
}
const sanitizedUser: PublicUser = {
...rest,
signInType: 'email',
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
};
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
if (ldapIdentity) {
sanitizedUser.signInType = 'ldap';
}
return sanitizedUser;
}
export async function withFeatureFlags(
postHog: PostHogClient | undefined,
user: CurrentUser,
): Promise<CurrentUser> {
if (!postHog) {
return user;
}
// native PostHog implementation has default 10s timeout and 3 retries.. which cannot be updated without affecting other functionality
// https://github.com/PostHog/posthog-js-lite/blob/a182de80a433fb0ffa6859c10fb28084d0f825c2/posthog-core/src/index.ts#L67
const timeoutPromise = new Promise<CurrentUser>((resolve) => {
setTimeout(() => {
resolve(user);
}, 1500);
});
const fetchPromise = new Promise<CurrentUser>(async (resolve) => {
user.featureFlags = await postHog.getFeatureFlags(user);
resolve(user);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
export function addInviteLinkToUser(user: PublicUser, inviterId: string): PublicUser {
if (user.isPending) {
user.inviteAcceptUrl = generateUserInviteUrl(inviterId, user.id);
}
return user;
}
export async function getUserById(userId: string): Promise<User> { export async function getUserById(userId: string): Promise<User> {
const user = await Db.collections.User.findOneOrFail({ const user = await Db.collections.User.findOneOrFail({
where: { id: userId }, where: { id: userId },

View file

@ -8,16 +8,15 @@ import {
InternalServerError, InternalServerError,
UnauthorizedError, UnauthorizedError,
} from '@/ResponseHelper'; } from '@/ResponseHelper';
import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper';
import { issueCookie, resolveJwt } from '@/auth/jwt'; import { issueCookie, resolveJwt } from '@/auth/jwt';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ILogger } from 'n8n-workflow'; import { ILogger } from 'n8n-workflow';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { LoginRequest, UserRequest } from '@/requests'; import { LoginRequest, UserRequest } from '@/requests';
import type { PublicUser } from '@/Interfaces';
import { Config } from '@/config'; import { Config } from '@/config';
import { IInternalHooksClass } from '@/Interfaces'; import { IInternalHooksClass } from '@/Interfaces';
import type { PublicUser, CurrentUser } from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth'; import { handleEmailLogin, handleLdapLogin } from '@/auth';
import { PostHogClient } from '@/posthog'; import { PostHogClient } from '@/posthog';
import { import {
@ -98,7 +97,8 @@ export class AuthController {
user, user,
authenticationMethod: usedAuthenticationMethod, authenticationMethod: usedAuthenticationMethod,
}); });
return withFeatureFlags(this.postHog, sanitizeUser(user));
return this.userService.toPublic(user, { posthog: this.postHog });
} }
void Container.get(InternalHooks).onUserLoginFailed({ void Container.get(InternalHooks).onUserLoginFailed({
user: email, user: email,
@ -112,7 +112,7 @@ export class AuthController {
* Manually check the `n8n-auth` cookie. * Manually check the `n8n-auth` cookie.
*/ */
@Get('/login') @Get('/login')
async currentUser(req: Request, res: Response): Promise<CurrentUser> { async currentUser(req: Request, res: Response): Promise<PublicUser> {
// Manually check the existing cookie. // Manually check the existing cookie.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined; const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;
@ -123,7 +123,7 @@ export class AuthController {
try { try {
user = await resolveJwt(cookieContents); user = await resolveJwt(cookieContents);
return await withFeatureFlags(this.postHog, sanitizeUser(user)); return await this.userService.toPublic(user, { posthog: this.postHog });
} catch (error) { } catch (error) {
res.clearCookie(AUTH_COOKIE_NAME); res.clearCookie(AUTH_COOKIE_NAME);
} }
@ -146,7 +146,7 @@ export class AuthController {
} }
await issueCookie(res, user); await issueCookie(res, user);
return withFeatureFlags(this.postHog, sanitizeUser(user)); return this.userService.toPublic(user, { posthog: this.postHog });
} }
/** /**
@ -183,7 +183,10 @@ export class AuthController {
} }
} }
const users = await this.userService.findMany({ where: { id: In([inviterId, inviteeId]) } }); const users = await this.userService.findMany({
where: { id: In([inviterId, inviteeId]) },
relations: ['globalRole'],
});
if (users.length !== 2) { if (users.length !== 2) {
this.logger.debug( this.logger.debug(
'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database', 'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database',

View file

@ -1,12 +1,7 @@
import validator from 'validator'; import validator from 'validator';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators'; import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators';
import { import { compareHash, hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper';
compareHash,
hashPassword,
sanitizeUser,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { BadRequestError } from '@/ResponseHelper'; import { BadRequestError } from '@/ResponseHelper';
import { validateEntity } from '@/GenericHelpers'; import { validateEntity } from '@/GenericHelpers';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
@ -89,9 +84,11 @@ export class MeController {
fields_changed: updatedKeys, fields_changed: updatedKeys,
}); });
await this.externalHooks.run('user.profile.update', [currentEmail, sanitizeUser(user)]); const publicUser = await this.userService.toPublic(user);
return sanitizeUser(user); await this.externalHooks.run('user.profile.update', [currentEmail, publicUser]);
return publicUser;
} }
/** /**

View file

@ -2,12 +2,7 @@ import validator from 'validator';
import { validateEntity } from '@/GenericHelpers'; import { validateEntity } from '@/GenericHelpers';
import { Authorized, Post, RestController } from '@/decorators'; import { Authorized, Post, RestController } from '@/decorators';
import { BadRequestError } from '@/ResponseHelper'; import { BadRequestError } from '@/ResponseHelper';
import { import { hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper';
hashPassword,
sanitizeUser,
validatePassword,
withFeatureFlags,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
import { Response } from 'express'; import { Response } from 'express';
import { ILogger } from 'n8n-workflow'; import { ILogger } from 'n8n-workflow';
@ -106,7 +101,7 @@ export class OwnerController {
void this.internalHooks.onInstanceOwnerSetup({ user_id: userId }); void this.internalHooks.onInstanceOwnerSetup({ user_id: userId });
return withFeatureFlags(this.postHog, sanitizeUser(owner)); return this.userService.toPublic(owner, { posthog: this.postHog });
} }
@Post('/dismiss-banner') @Post('/dismiss-banner')

View file

@ -1,19 +1,17 @@
import validator from 'validator'; import validator from 'validator';
import { In } from 'typeorm'; import type { FindManyOptions } from 'typeorm';
import { In, Not } from 'typeorm';
import { ILogger, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { ILogger, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators'; import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators';
import { import {
addInviteLinkToUser,
generateUserInviteUrl, generateUserInviteUrl,
getInstanceBaseUrl, getInstanceBaseUrl,
hashPassword, hashPassword,
isEmailSetUp, isEmailSetUp,
sanitizeUser,
validatePassword, validatePassword,
withFeatureFlags,
} from '@/UserManagement/UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
import { import {
@ -23,12 +21,12 @@ import {
UnauthorizedError, UnauthorizedError,
} from '@/ResponseHelper'; } from '@/ResponseHelper';
import { Response } from 'express'; import { Response } from 'express';
import { Config } from '@/config'; import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { UserManagementMailer } from '@/UserManagement/email'; import { UserManagementMailer } from '@/UserManagement/email';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { Config } from '@/config';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces'; import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity'; import { AuthIdentity } from '@db/entities/AuthIdentity';
import { PostHogClient } from '@/posthog'; import { PostHogClient } from '@/posthog';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
@ -40,6 +38,7 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { JwtService } from '@/services/jwt.service'; import { JwtService } from '@/services/jwt.service';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares';
@Authorized(['global', 'owner']) @Authorized(['global', 'owner'])
@RestController('/users') @RestController('/users')
@ -131,6 +130,7 @@ export class UsersController {
// remove/exclude existing users from creation // remove/exclude existing users from creation
const existingUsers = await this.userService.findMany({ const existingUsers = await this.userService.findMany({
where: { email: In(Object.keys(createUsers)) }, where: { email: In(Object.keys(createUsers)) },
relations: ['globalRole'],
}); });
existingUsers.forEach((user) => { existingUsers.forEach((user) => {
if (user.password) { if (user.password) {
@ -306,20 +306,98 @@ export class UsersController {
was_disabled_ldap_user: false, was_disabled_ldap_user: false,
}); });
await this.externalHooks.run('user.profile.update', [invitee.email, sanitizeUser(invitee)]); const publicInvitee = await this.userService.toPublic(invitee);
await this.externalHooks.run('user.profile.update', [invitee.email, publicInvitee]);
await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]); await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]);
return withFeatureFlags(this.postHog, sanitizeUser(updatedUser)); return this.userService.toPublic(updatedUser, { posthog: this.postHog });
}
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;
}
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.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;
} }
@Authorized('any') @Authorized('any')
@Get('/') @Get('/', { middlewares: listQueryMiddleware })
async listUsers(req: UserRequest.List) { async listUsers(req: ListQuery.Request) {
const users = await this.userService.findMany({ relations: ['globalRole', 'authIdentities'] }); const { listQueryOptions } = req;
return users.map(
(user): PublicUser => const findManyOptions = await this.toFindManyOptions(listQueryOptions);
addInviteLinkToUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id),
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 })),
); );
return listQueryOptions
? this.removeSupplementaryFields(publicUsers, listQueryOptions)
: publicUsers;
} }
@Authorized(['global', 'owner']) @Authorized(['global', 'owner'])
@ -393,6 +471,7 @@ export class UsersController {
const users = await this.userService.findMany({ const users = await this.userService.findMany({
where: { id: In([transferId, idToDelete]) }, where: { id: In([transferId, idToDelete]) },
relations: ['globalRole'],
}); });
if (!users.length || (transferId && users.length !== 2)) { if (!users.length || (transferId && users.length !== 2)) {
@ -483,7 +562,7 @@ export class UsersController {
telemetryData, telemetryData,
publicApi: false, publicApi: false,
}); });
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true }; return { success: true };
} }
@ -521,7 +600,7 @@ export class UsersController {
publicApi: false, publicApi: false,
}); });
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true }; return { success: true };
} }

View file

@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { isObjectLiteral } from '@/utils';
import { plainToInstance, instanceToPlain } from 'class-transformer';
import { validate } from 'class-validator';
import { jsonParse } from 'n8n-workflow';
export class BaseFilter {
protected static async toFilter(rawFilter: string, Filter: typeof BaseFilter) {
const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' });
if (!isObjectLiteral(dto)) throw new Error('Filter must be an object literal');
const instance = plainToInstance(Filter, dto, {
excludeExtraneousValues: true, // remove fields not in class
});
await instance.validate();
return instanceToPlain(instance, {
exposeUnsetFields: false, // remove in-class undefined fields
});
}
private async validate() {
const result = await validate(this);
if (result.length > 0) throw new Error('Parsed filter does not fit the schema');
}
}

View file

@ -0,0 +1,19 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { isStringArray } from '@/utils';
import { jsonParse } from 'n8n-workflow';
export class BaseSelect {
static selectableFields: Set<string>;
protected static toSelect(rawFilter: string, Select: typeof BaseSelect) {
const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' });
if (!isStringArray(dto)) throw new Error('Parsed select is not a string array');
return dto.reduce<Record<string, true>>((acc, field) => {
if (!Select.selectableFields.has(field)) return acc;
return (acc[field] = true), acc;
}, {});
}
}

View file

@ -0,0 +1,29 @@
import { IsOptional, IsString, IsBoolean } from 'class-validator';
import { Expose } from 'class-transformer';
import { BaseFilter } from './base.filter.dto';
export class UserFilter extends BaseFilter {
@IsString()
@IsOptional()
@Expose()
email?: string;
@IsString()
@IsOptional()
@Expose()
firstName?: string;
@IsString()
@IsOptional()
@Expose()
lastName?: string;
@IsBoolean()
@IsOptional()
@Expose()
isOwner?: boolean;
static async fromString(rawFilter: string) {
return this.toFilter(rawFilter, UserFilter);
}
}

View file

@ -0,0 +1,11 @@
import { BaseSelect } from './base.select.dto';
export class UserSelect extends BaseSelect {
static get selectableFields() {
return new Set(['id', 'email', 'firstName', 'lastName']);
}
static fromString(rawFilter: string) {
return this.toSelect(rawFilter, UserSelect);
}
}

View file

@ -1,9 +1,9 @@
import { IsOptional, IsString, IsBoolean, IsArray, validate } from 'class-validator'; import { IsOptional, IsString, IsBoolean, IsArray } from 'class-validator';
import { Expose, instanceToPlain, plainToInstance } from 'class-transformer'; import { Expose } from 'class-transformer';
import { jsonParse } from 'n8n-workflow';
import { isObjectLiteral } from '@/utils';
export class WorkflowFilter { import { BaseFilter } from './base.filter.dto';
export class WorkflowFilter extends BaseFilter {
@IsString() @IsString()
@IsOptional() @IsOptional()
@Expose() @Expose()
@ -21,23 +21,6 @@ export class WorkflowFilter {
tags?: string[]; tags?: string[];
static async fromString(rawFilter: string) { static async fromString(rawFilter: string) {
const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); return this.toFilter(rawFilter, WorkflowFilter);
if (!isObjectLiteral(dto)) throw new Error('Filter must be an object literal');
const instance = plainToInstance(WorkflowFilter, dto, {
excludeExtraneousValues: true, // remove fields not in class
exposeUnsetFields: false, // remove in-class undefined fields
});
await instance.validate();
return instanceToPlain(instance);
}
private async validate() {
const result = await validate(this);
if (result.length > 0) throw new Error('Parsed filter does not fit the schema');
} }
} }

View file

@ -1,9 +1,6 @@
import { isStringArray } from '@/utils'; import { BaseSelect } from './base.select.dto';
import { jsonParse } from 'n8n-workflow';
export class WorkflowSelect {
fields: string[];
export class WorkflowSelect extends BaseSelect {
static get selectableFields() { static get selectableFields() {
return new Set([ return new Set([
'id', // always included downstream 'id', // always included downstream
@ -18,13 +15,6 @@ export class WorkflowSelect {
} }
static fromString(rawFilter: string) { static fromString(rawFilter: string) {
const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); return this.toSelect(rawFilter, WorkflowSelect);
if (!isStringArray(dto)) throw new Error('Parsed select is not a string array');
return dto.reduce<Record<string, true>>((acc, field) => {
if (!WorkflowSelect.selectableFields.has(field)) return acc;
return (acc[field] = true), acc;
}, {});
} }
} }

View file

@ -2,6 +2,7 @@
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import { WorkflowFilter } from './dtos/workflow.filter.dto'; import { WorkflowFilter } from './dtos/workflow.filter.dto';
import { UserFilter } from './dtos/user.filter.dto';
import { toError } from '@/utils'; import { toError } from '@/utils';
import type { NextFunction, Response } from 'express'; import type { NextFunction, Response } from 'express';
@ -20,6 +21,8 @@ export const filterListQueryMiddleware = async (
if (req.baseUrl.endsWith('workflows')) { if (req.baseUrl.endsWith('workflows')) {
Filter = WorkflowFilter; Filter = WorkflowFilter;
} else if (req.baseUrl.endsWith('users')) {
Filter = UserFilter;
} else { } else {
return next(); return next();
} }

View file

@ -11,9 +11,13 @@ export const paginationListQueryMiddleware: RequestHandler = (
) => { ) => {
const { take: rawTake, skip: rawSkip = '0' } = req.query; const { take: rawTake, skip: rawSkip = '0' } = req.query;
try {
if (!rawTake && req.query.skip) {
throw new Error('Please specify `take` when using `skip`');
}
if (!rawTake) return next(); if (!rawTake) return next();
try {
const { take, skip } = Pagination.fromString(rawTake, rawSkip); const { take, skip } = Pagination.fromString(rawTake, rawSkip);
req.listQueryOptions = { ...req.listQueryOptions, skip, take }; req.listQueryOptions = { ...req.listQueryOptions, skip, take };

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import { WorkflowSelect } from './dtos/workflow.select.dto'; import { WorkflowSelect } from './dtos/workflow.select.dto';
import { UserSelect } from './dtos/user.select.dto';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import { toError } from '@/utils'; import { toError } from '@/utils';
@ -16,6 +17,8 @@ export const selectListQueryMiddleware: RequestHandler = (req: ListQuery.Request
if (req.baseUrl.endsWith('workflows')) { if (req.baseUrl.endsWith('workflows')) {
Select = WorkflowSelect; Select = WorkflowSelect;
} else if (req.baseUrl.endsWith('users')) {
Select = UserSelect;
} else { } else {
return next(); return next();
} }

View file

@ -280,8 +280,6 @@ export declare namespace PasswordResetRequest {
// ---------------------------------- // ----------------------------------
export declare namespace UserRequest { export declare namespace UserRequest {
export type List = AuthenticatedRequest;
export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>; export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>;
export type ResolveSignUp = AuthlessRequest< export type ResolveSignUp = AuthlessRequest<

View file

@ -4,6 +4,9 @@ import { In } from 'typeorm';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import type { IUserSettings } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-workflow';
import { UserRepository } from '@/databases/repositories'; import { UserRepository } from '@/databases/repositories';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import type { PublicUser } from '@/Interfaces';
import type { PostHogClient } from '@/posthog';
@Service() @Service()
export class UserService { export class UserService {
@ -18,7 +21,7 @@ export class UserService {
} }
async findMany(options: FindManyOptions<User>) { async findMany(options: FindManyOptions<User>) {
return this.userRepository.find({ relations: ['globalRole'], ...options }); return this.userRepository.find(options);
} }
async findOneBy(options: FindOptionsWhere<User>) { async findOneBy(options: FindOptionsWhere<User>) {
@ -59,4 +62,54 @@ export class UserService {
return url.toString(); return url.toString();
} }
async toPublic(user: User, options?: { withInviteUrl?: boolean; posthog?: PostHogClient }) {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user;
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
let publicUser: PublicUser = {
...rest,
signInType: ldapIdentity ? 'ldap' : 'email',
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
};
if (options?.withInviteUrl && publicUser.isPending) {
publicUser = this.addInviteUrl(publicUser, user.id);
}
if (options?.posthog) {
publicUser = await this.addFeatureFlags(publicUser, options.posthog);
}
return publicUser;
}
private addInviteUrl(user: PublicUser, inviterId: string) {
const url = new URL(getInstanceBaseUrl());
url.pathname = '/signup';
url.searchParams.set('inviterId', inviterId);
url.searchParams.set('inviteeId', user.id);
user.inviteAcceptUrl = url.toString();
return user;
}
private async addFeatureFlags(publicUser: PublicUser, posthog: PostHogClient) {
// native PostHog implementation has default 10s timeout and 3 retries.. which cannot be updated without affecting other functionality
// https://github.com/PostHog/posthog-js-lite/blob/a182de80a433fb0ffa6859c10fb28084d0f825c2/posthog-core/src/index.ts#L67
const timeoutPromise = new Promise<PublicUser>((resolve) => {
setTimeout(() => {
resolve(publicUser);
}, 1500);
});
const fetchPromise = new Promise<PublicUser>(async (resolve) => {
publicUser.featureFlags = await posthog.getFeatureFlags(publicUser);
resolve(publicUser);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
} }

View file

@ -11,7 +11,6 @@ import { LdapManager } from '@/Ldap/LdapManager.ee';
import { LdapService } from '@/Ldap/LdapService.ee'; import { LdapService } from '@/Ldap/LdapService.ee';
import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers'; import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers';
import type { LdapConfig } from '@/Ldap/types'; import type { LdapConfig } from '@/Ldap/types';
import { sanitizeUser } from '@/UserManagement/UserManagementHelper';
import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { randomEmail, randomName, uniqueId } from './../shared/random'; import { randomEmail, randomName, uniqueId } from './../shared/random';
@ -570,24 +569,3 @@ describe('Instance owner should able to delete LDAP users', () => {
await authOwnerAgent.post(`/users/${member.id}?transferId=${owner.id}`); await authOwnerAgent.post(`/users/${member.id}?transferId=${owner.id}`);
}); });
}); });
test('Sign-type should be returned when listing users', async () => {
const ldapConfig = await createLdapConfig();
LdapManager.updateConfig(ldapConfig);
await testDb.createLdapUser(
{
globalRole: globalMemberRole,
},
uniqueId(),
);
const allUsers = await testDb.getAllUsers();
expect(allUsers.length).toBe(2);
const ownerUser = allUsers.find((u) => u.email === owner.email)!;
expect(sanitizeUser(ownerUser).signInType).toStrictEqual('email');
const memberUser = allUsers.find((u) => u.email !== owner.email)!;
expect(sanitizeUser(memberUser).signInType).toStrictEqual('ldap');
});

View file

@ -246,6 +246,10 @@ export async function createOwner() {
return createUser({ globalRole: await getGlobalOwnerRole() }); return createUser({ globalRole: await getGlobalOwnerRole() });
} }
export async function createMember() {
return createUser({ globalRole: await getGlobalMemberRole() });
}
export async function createUserShell(globalRole: Role): Promise<User> { export async function createUserShell(globalRole: Role): Promise<User> {
if (globalRole.scope !== 'global') { if (globalRole.scope !== 'global') {
throw new Error(`Invalid role received: ${JSON.stringify(globalRole)}`); throw new Error(`Invalid role received: ${JSON.stringify(globalRole)}`);

View file

@ -60,49 +60,6 @@ beforeEach(async () => {
config.set('userManagement.emails.smtp.host', ''); config.set('userManagement.emails.smtp.host', '');
}); });
describe('GET /users', () => {
test('should return all users (for owner)', async () => {
await testDb.createUser({ globalRole: globalMemberRole });
const response = await authOwnerAgent.get('/users');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
response.body.data.map((user: User) => {
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
globalRole,
password,
isPending,
apiKey,
} = user;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
expect(apiKey).not.toBeDefined();
});
});
test('should return all users (for member)', async () => {
const member = await testDb.createUser({ globalRole: globalMemberRole });
const response = await testServer.authAgentFor(member).get('/users');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
});
});
describe('DELETE /users/:id', () => { describe('DELETE /users/:id', () => {
test('should delete the user', async () => { test('should delete the user', async () => {
const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); const userToDelete = await testDb.createUser({ globalRole: globalMemberRole });

View file

@ -0,0 +1,248 @@
import * as testDb from './shared/testDb';
import { setupTestServer } from './shared/utils/';
import type { User } from '@/databases/entities/User';
import type { PublicUser } from '@/Interfaces';
const { any } = expect;
const testServer = setupTestServer({ endpointGroups: ['users'] });
let owner: User;
let member: User;
beforeEach(async () => {
await testDb.truncate(['User']);
owner = await testDb.createOwner();
member = await testDb.createMember();
});
const validatePublicUser = (user: PublicUser) => {
expect(typeof user.id).toBe('string');
expect(user.email).toBeDefined();
expect(user.firstName).toBeDefined();
expect(user.lastName).toBeDefined();
expect(typeof user.isOwner).toBe('boolean');
expect(user.isPending).toBe(false);
expect(user.signInType).toBe('email');
expect(user.settings).toBe(null);
expect(user.personalizationAnswers).toBeNull();
expect(user.password).toBeUndefined();
expect(user.globalRole).toBeDefined();
};
describe('GET /users', () => {
test('should return all users', async () => {
const response = await testServer.authAgentFor(owner).get('/users').expect(200);
expect(response.body.data).toHaveLength(2);
response.body.data.forEach(validatePublicUser);
const _response = await testServer.authAgentFor(member).get('/users').expect(200);
expect(_response.body.data).toHaveLength(2);
_response.body.data.forEach(validatePublicUser);
});
describe('filter', () => {
test('should filter users by field: email', async () => {
const secondMember = await testDb.createMember();
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query(`filter={ "email": "${secondMember.email}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
const [user] = response.body.data;
expect(user.email).toBe(secondMember.email);
const _response = await testServer
.authAgentFor(owner)
.get('/users')
.query('filter={ "email": "non@existing.com" }')
.expect(200);
expect(_response.body.data).toHaveLength(0);
});
test('should filter users by field: firstName', async () => {
const secondMember = await testDb.createMember();
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query(`filter={ "firstName": "${secondMember.firstName}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
const [user] = response.body.data;
expect(user.email).toBe(secondMember.email);
const _response = await testServer
.authAgentFor(owner)
.get('/users')
.query('filter={ "firstName": "Non-Existing" }')
.expect(200);
expect(_response.body.data).toHaveLength(0);
});
test('should filter users by field: lastName', async () => {
const secondMember = await testDb.createMember();
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query(`filter={ "lastName": "${secondMember.lastName}" }`)
.expect(200);
expect(response.body.data).toHaveLength(1);
const [user] = response.body.data;
expect(user.email).toBe(secondMember.email);
const _response = await testServer
.authAgentFor(owner)
.get('/users')
.query('filter={ "lastName": "Non-Existing" }')
.expect(200);
expect(_response.body.data).toHaveLength(0);
});
test('should filter users by computed field: isOwner', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('filter={ "isOwner": true }')
.expect(200);
expect(response.body.data).toHaveLength(1);
const [user] = response.body.data;
expect(user.isOwner).toBe(true);
const _response = await testServer
.authAgentFor(owner)
.get('/users')
.query('filter={ "isOwner": false }')
.expect(200);
expect(_response.body.data).toHaveLength(1);
const [_user] = _response.body.data;
expect(_user.isOwner).toBe(false);
});
});
describe('select', () => {
test('should select user field: id', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('select=["id"]')
.expect(200);
expect(response.body).toEqual({
data: [{ id: any(String) }, { id: any(String) }],
});
});
test('should select user field: email', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('select=["email"]')
.expect(200);
expect(response.body).toEqual({
data: [{ email: any(String) }, { email: any(String) }],
});
});
test('should select user field: firstName', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('select=["firstName"]')
.expect(200);
expect(response.body).toEqual({
data: [{ firstName: any(String) }, { firstName: any(String) }],
});
});
test('should select user field: lastName', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('select=["lastName"]')
.expect(200);
expect(response.body).toEqual({
data: [{ lastName: any(String) }, { lastName: any(String) }],
});
});
});
describe('take', () => {
test('should return n users or less, without skip', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('take=2')
.expect(200);
expect(response.body.data).toHaveLength(2);
response.body.data.forEach(validatePublicUser);
const _response = await testServer
.authAgentFor(owner)
.get('/users')
.query('take=1')
.expect(200);
expect(_response.body.data).toHaveLength(1);
_response.body.data.forEach(validatePublicUser);
});
test('should return n users or less, with skip', async () => {
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('take=1&skip=1')
.expect(200);
expect(response.body.data).toHaveLength(1);
response.body.data.forEach(validatePublicUser);
});
});
describe('combinations', () => {
test('should support options that require auxiliary fields', async () => {
// isOwner requires globalRole
// id-less select with take requires id
const response = await testServer
.authAgentFor(owner)
.get('/users')
.query('filter={ "isOwner": true }&select=["firstName"]&take=10')
.expect(200);
expect(response.body).toEqual({ data: [{ firstName: any(String) }] });
});
});
});

View file

@ -2,7 +2,7 @@ import type { CookieOptions, Response } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mock, anyObject, captor } from 'jest-mock-extended'; import { mock, anyObject, captor } from 'jest-mock-extended';
import type { ILogger } from 'n8n-workflow'; import type { ILogger } from 'n8n-workflow';
import type { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import type { IExternalHooksClass, IInternalHooksClass, PublicUser } from '@/Interfaces';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { MeController } from '@/controllers'; import { MeController } from '@/controllers';
import { AUTH_COOKIE_NAME } from '@/constants'; import { AUTH_COOKIE_NAME } from '@/constants';
@ -45,6 +45,7 @@ describe('MeController', () => {
const res = mock<Response>(); const res = mock<Response>();
userService.findOneOrFail.mockResolvedValue(user); userService.findOneOrFail.mockResolvedValue(user);
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
await controller.updateCurrentUser(req, res); await controller.updateCurrentUser(req, res);

View file

@ -134,12 +134,12 @@ describe('List query middleware', () => {
expect(nextFn).toBeCalledTimes(1); expect(nextFn).toBeCalledTimes(1);
}); });
test('should ignore skip without take', () => { test('should throw on skip without take', () => {
mockReq.query = { skip: '1' }; mockReq.query = { skip: '1' };
paginationListQueryMiddleware(...args); paginationListQueryMiddleware(...args);
expect(mockReq.listQueryOptions).toBeUndefined(); expect(mockReq.listQueryOptions).toBeUndefined();
expect(nextFn).toBeCalledTimes(1); expect(sendErrorResponse).toHaveBeenCalledTimes(1);
}); });
test('should default skip to 0', () => { test('should default skip to 0', () => {