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 all 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 @@ -772,9 +772,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,16 +8,15 @@ 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 { ILogger } from 'n8n-workflow';
import type { User } from '@db/entities/User';
import { LoginRequest, UserRequest } from '@/requests';
import type { PublicUser } from '@/Interfaces';
import { Config } from '@/config';
import { IInternalHooksClass } from '@/Interfaces';
import type { PublicUser, CurrentUser } from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import { PostHogClient } from '@/posthog';
import {
Expand Down Expand Up @@ -98,7 +97,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 @@ -112,7 +112,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 @@ -123,7 +123,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 @@ -146,7 +146,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 @@ -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) {
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 @@ -89,9 +84,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 { ILogger } from 'n8n-workflow';
Expand Down Expand Up @@ -106,7 +101,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
113 changes: 96 additions & 17 deletions packages/cli/src/controllers/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
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 { 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 @@ -23,12 +21,12 @@ import {
UnauthorizedError,
} from '@/ResponseHelper';
import { Response } from 'express';
import { Config } from '@/config';
import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { UserManagementMailer } from '@/UserManagement/email';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { Config } from '@/config';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity';
import { PostHogClient } from '@/posthog';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
Expand All @@ -40,6 +38,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 @@ -131,6 +130,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 @@ -306,20 +306,98 @@ 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 });
}

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')
@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 = 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 })),
);

return listQueryOptions
? this.removeSupplementaryFields(publicUsers, listQueryOptions)
: publicUsers;
}

@Authorized(['global', 'owner'])
Expand Down Expand Up @@ -393,6 +471,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 @@ -483,7 +562,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 @@ -521,7 +600,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
Loading