Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 21 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,9 +764,7 @@ export interface PublicUser {
disabled: boolean;
settings?: IUserSettings | null;
inviteAcceptUrl?: string;
}

export interface CurrentUser extends PublicUser {
isOwner?: boolean;
featureFlags?: FeatureFlags;
}

Expand Down
61 changes: 1 addition & 60 deletions packages/cli/src/UserManagement/UserManagementHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import { Container } from 'typedi';

import * as Db from '@/Db';
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 { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User';
import config from '@/config';
import { License } from '@/License';
import { getWebhookBaseUrl } from '@/WebhookHelpers';
import type { PostHogClient } from '@/posthog';
import { RoleService } from '@/services/role.service';

export function isEmailSetUp(): boolean {
Expand Down Expand Up @@ -84,64 +83,6 @@ export function validatePassword(password?: string): string {
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> {
const user = await Db.collections.User.findOneOrFail({
where: { id: userId },
Expand Down
17 changes: 10 additions & 7 deletions packages/cli/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@ import {
InternalServerError,
UnauthorizedError,
} from '@/ResponseHelper';
import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper';
import { issueCookie, resolveJwt } from '@/auth/jwt';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Request, Response } from 'express';
import type { ILogger } from 'n8n-workflow';
import type { User } from '@db/entities/User';
import { LoginRequest, UserRequest } from '@/requests';
import type { Config } from '@/config';
import type { PublicUser, IInternalHooksClass, CurrentUser } from '@/Interfaces';
import type { PublicUser, IInternalHooksClass } from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import type { PostHogClient } from '@/posthog';
import {
Expand Down Expand Up @@ -121,7 +120,8 @@ export class AuthController {
user,
authenticationMethod: usedAuthenticationMethod,
});
return withFeatureFlags(this.postHog, sanitizeUser(user));

return this.userService.toPublic(user, { posthog: this.postHog });
}
void Container.get(InternalHooks).onUserLoginFailed({
user: email,
Expand All @@ -135,7 +135,7 @@ export class AuthController {
* Manually check the `n8n-auth` cookie.
*/
@Get('/login')
async currentUser(req: Request, res: Response): Promise<CurrentUser> {
async currentUser(req: Request, res: Response): Promise<PublicUser> {
// Manually check the existing cookie.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined;
Expand All @@ -146,7 +146,7 @@ export class AuthController {
try {
user = await resolveJwt(cookieContents);

return await withFeatureFlags(this.postHog, sanitizeUser(user));
return await this.userService.toPublic(user, { posthog: this.postHog });
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
Expand All @@ -169,7 +169,7 @@ export class AuthController {
}

await issueCookie(res, user);
return withFeatureFlags(this.postHog, sanitizeUser(user));
return this.userService.toPublic(user, { posthog: this.postHog });
}

/**
Expand Down Expand Up @@ -206,7 +206,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) {
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',
Expand Down
13 changes: 5 additions & 8 deletions packages/cli/src/controllers/me.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import validator from 'validator';
import { plainToInstance } from 'class-transformer';
import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators';
import {
compareHash,
hashPassword,
sanitizeUser,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import { compareHash, hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper';
import { BadRequestError } from '@/ResponseHelper';
import { validateEntity } from '@/GenericHelpers';
import { issueCookie } from '@/auth/jwt';
Expand Down Expand Up @@ -105,9 +100,11 @@ export class MeController {
fields_changed: updatedKeys,
});

await this.externalHooks.run('user.profile.update', [currentEmail, sanitizeUser(user)]);
const publicUser = await this.userService.toPublic(user);

await this.externalHooks.run('user.profile.update', [currentEmail, publicUser]);

return sanitizeUser(user);
return publicUser;
}

/**
Expand Down
9 changes: 2 additions & 7 deletions packages/cli/src/controllers/owner.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import validator from 'validator';
import { validateEntity } from '@/GenericHelpers';
import { Authorized, Post, RestController } from '@/decorators';
import { BadRequestError } from '@/ResponseHelper';
import {
hashPassword,
sanitizeUser,
validatePassword,
withFeatureFlags,
} from '@/UserManagement/UserManagementHelper';
import { hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import { Response } from 'express';
import type { ILogger } from 'n8n-workflow';
Expand Down Expand Up @@ -131,7 +126,7 @@ export class OwnerController {

void this.internalHooks.onInstanceOwnerSetup({ user_id: userId });

return withFeatureFlags(this.postHog, sanitizeUser(owner));
return this.userService.toPublic(owner, { posthog: this.postHog });
}

@Post('/dismiss-banner')
Expand Down
77 changes: 61 additions & 16 deletions packages/cli/src/controllers/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import validator from 'validator';
import { In } from 'typeorm';
import type { FindManyOptions } from 'typeorm';
import { In, Not } from 'typeorm';
import type { ILogger } from 'n8n-workflow';
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators';
import {
addInviteLinkToUser,
generateUserInviteUrl,
getInstanceBaseUrl,
hashPassword,
isEmailSetUp,
sanitizeUser,
validatePassword,
withFeatureFlags,
} from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt';
import {
Expand All @@ -25,14 +23,14 @@ import {
} from '@/ResponseHelper';
import { Response } from 'express';
import type { Config } from '@/config';
import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests';
import type { UserManagementMailer } from '@/UserManagement/email';
import type {
PublicUser,
IDatabaseCollections,
IExternalHooksClass,
IInternalHooksClass,
ITelemetryUserDeletionData,
PublicUser,
} from '@/Interfaces';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity';
Expand All @@ -46,6 +44,7 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { JwtService } from '@/services/jwt.service';
import { RoleService } from '@/services/role.service';
import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares';

@Authorized(['global', 'owner'])
@RestController('/users')
Expand Down Expand Up @@ -179,6 +178,7 @@ export class UsersController {
// remove/exclude existing users from creation
const existingUsers = await this.userService.findMany({
where: { email: In(Object.keys(createUsers)) },
relations: ['globalRole'],
});
existingUsers.forEach((user) => {
if (user.password) {
Expand Down Expand Up @@ -354,20 +354,64 @@ export class UsersController {
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]);

return withFeatureFlags(this.postHog, sanitizeUser(updatedUser));
return this.userService.toPublic(updatedUser, { posthog: this.postHog });
}

@Authorized('any')
@Get('/')
async listUsers(req: UserRequest.List) {
const users = await this.userService.findMany({ relations: ['globalRole', 'authIdentities'] });
return users.map(
(user): PublicUser =>
addInviteLinkToUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id),
@Get('/', { middlewares: listQueryMiddleware })
async listUsers(req: ListQuery.Request) {
const { listQueryOptions } = req;

const findManyOptions: FindManyOptions<User> = {};

if (!listQueryOptions) {
findManyOptions.relations = ['globalRole', 'authIdentities'];
} else {
const { filter, select, take, skip } = listQueryOptions;

if (filter) findManyOptions.where = filter;
if (select) findManyOptions.select = select;
if (take) findManyOptions.take = take;
if (skip) findManyOptions.skip = skip;

if (filter?.isOwner !== undefined) {
findManyOptions.relations = ['globalRole'];
krynble marked this conversation as resolved.
Show resolved Hide resolved

const { isOwner } = filter;

delete filter.isOwner; // remove computed field

const ownerRole = await this.roleService.findGlobalOwnerRole();

findManyOptions.where = {
...findManyOptions.where,
globalRole: { id: isOwner ? ownerRole.id : Not(ownerRole.id) },
};
}
}

const users = await this.userService.findMany(findManyOptions);

const publicUsers = await Promise.all(
users.map(async (user) => {
return this.userService.toPublic(user, { withInviteUrl: true });
}),
);

if (listQueryOptions?.select !== undefined) {
return publicUsers.map(
({ isOwner, isPending, signInType, hasRecoveryCodesLeft, ...rest }) => {
return rest as PublicUser; // remove unselectable non-entity fields
},
);
}

return publicUsers;
}

@Authorized(['global', 'owner'])
Expand Down Expand Up @@ -441,6 +485,7 @@ export class UsersController {

const users = await this.userService.findMany({
where: { id: In([transferId, idToDelete]) },
relations: ['globalRole'],
});

if (!users.length || (transferId && users.length !== 2)) {
Expand Down Expand Up @@ -531,7 +576,7 @@ export class UsersController {
telemetryData,
publicApi: false,
});
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true };
}

Expand Down Expand Up @@ -569,7 +614,7 @@ export class UsersController {
publicApi: false,
});

await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true };
}

Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/middlewares/listQuery/dtos/base.filter.dto.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
Loading