Skip to content

Commit

Permalink
feat(core): Add filtering, selection and pagination to users (#6994)
Browse files Browse the repository at this point in the history
  • Loading branch information
ivov authored Aug 28, 2023
1 parent a7785b2 commit b716241
Show file tree
Hide file tree
Showing 23 changed files with 535 additions and 211 deletions.
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

0 comments on commit b716241

Please sign in to comment.