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

General database fixes for user management #2710

Merged
merged 13 commits into from
Feb 20, 2022
Merged
Show file tree
Hide file tree
Changes from 9 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
14 changes: 3 additions & 11 deletions packages/cli/src/ActiveWorkflowRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,17 +373,9 @@ export class ActiveWorkflowRunner {
* @returns {boolean}
* @memberof ActiveWorkflowRunner
*/
async isActive(id: string, userId?: string): Promise<boolean> {
// TODO UM: make userId mandatory?
const queryBuilder = Db.collections.Workflow!.createQueryBuilder('w');
queryBuilder.andWhere('w.id = :id', { id });
if (userId) {
queryBuilder.innerJoin('w.shared', 'shared');
queryBuilder.andWhere('shared.user = :userId', { userId });
}

const workflow = (await queryBuilder.getOne()) as IWorkflowDb;
return workflow?.active;
async isActive(id: string): Promise<boolean> {
const workflow = (await Db.collections.Workflow!.findOne(id)) as IWorkflowDb;
return workflow.active;
krynble marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down
7 changes: 1 addition & 6 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ export interface IN8nUISettings {
versionNotifications: IVersionNotificationSettings;
instanceId: string;
telemetry: ITelemetrySettings;
personalizationSurvey: IPersonalizationSurvey;
personalizationSurveyEnabled: boolean;
defaultLocale: string;
userManagement: IUserManagementSettings;
workflowTagsDisabled: boolean;
Expand All @@ -445,11 +445,6 @@ export interface IPersonalizationSurveyAnswers {
workArea: string[] | string | null;
}

export interface IPersonalizationSurvey {
answers?: IPersonalizationSurveyAnswers;
shouldShow: boolean;
}

export interface IUserManagementSettings {
enabled: boolean;
showSetupOnFirstLoad?: boolean;
Expand Down
50 changes: 0 additions & 50 deletions packages/cli/src/PersonalizationSurvey.ts

This file was deleted.

12 changes: 4 additions & 8 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ import {
import * as config from '../config';

import * as TagHelpers from './TagHelpers';
import * as PersonalizationSurvey from './PersonalizationSurvey';

import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity';
Expand Down Expand Up @@ -309,9 +308,9 @@ class App {
},
instanceId: '',
telemetry: telemetrySettings,
personalizationSurvey: {
shouldShow: false,
},
personalizationSurveyEnabled:
(config.get('personalization.enabled') as boolean) &&
(config.get('diagnostics.enabled') as boolean),
krynble marked this conversation as resolved.
Show resolved Hide resolved
defaultLocale: config.get('defaultLocale'),
userManagement: {
enabled:
Expand Down Expand Up @@ -350,9 +349,6 @@ class App {

this.frontendSettings.instanceId = await UserSettings.getInstanceId();

this.frontendSettings.personalizationSurvey =
await PersonalizationSurvey.preparePersonalizationSurvey();

await this.externalHooks.run('frontend.settings', [this.frontendSettings]);

const excludeEndpoints = config.get('security.excludeEndpoints') as string;
Expand Down Expand Up @@ -2520,7 +2516,7 @@ class App {
}

if (executingWorkflowIds.length > 0) {
Object.assign(findOptions.where, { id: !In(executingWorkflowIds) });
Object.assign(findOptions.where, { id: Not(In(executingWorkflowIds)) });
krynble marked this conversation as resolved.
Show resolved Hide resolved
}

const executions = await Db.collections.Execution!.find(findOptions);
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/UserManagement/UserManagementHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export function validatePassword(password?: string): string {
* Remove sensitive properties from the user to return to the client.
*/
export function sanitizeUser(user: User): PublicUser {
const { password, resetPasswordToken, createdAt, updatedAt, ...sanitizedUser } = user;
const {
password,
resetPasswordToken,
createdAt,
updatedAt,
resetPasswordTokenExpiration,
...sanitizedUser
} = user;
krynble marked this conversation as resolved.
Show resolved Hide resolved
return sanitizedUser;
}
23 changes: 19 additions & 4 deletions packages/cli/src/UserManagement/routes/passwordReset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,17 @@ export function passwordResetNamespace(this: N8nApp): void {

const user = await Db.collections.User!.findOne({ email });

if (!user) {
if (!user || !user.password) {
return;
csuermann marked this conversation as resolved.
Show resolved Hide resolved
}

user.resetPasswordToken = uuid();

const { id, firstName, lastName, resetPasswordToken } = user;

await Db.collections.User!.update(id, { resetPasswordToken });
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200;

await Db.collections.User!.update(id, { resetPasswordToken, resetPasswordTokenExpiration });

const baseUrl = getBaseUrl();
const url = new URL('/change-password', baseUrl);
Expand Down Expand Up @@ -82,7 +84,13 @@ export function passwordResetNamespace(this: N8nApp): void {

const user = await Db.collections.User!.findOne({ resetPasswordToken, id });

if (!user) {
if (!user || !user.resetPasswordTokenExpiration) {
throw new ResponseHelper.ResponseError('', undefined, 404);
}

// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
if (currentTimestamp > user.resetPasswordTokenExpiration) {
krynble marked this conversation as resolved.
Show resolved Hide resolved
throw new ResponseHelper.ResponseError('', undefined, 404);
}
}),
Expand All @@ -104,13 +112,20 @@ export function passwordResetNamespace(this: N8nApp): void {

const user = await Db.collections.User!.findOne({ id: userId, resetPasswordToken });

if (!user) {
if (!user || !user.resetPasswordTokenExpiration) {
throw new ResponseHelper.ResponseError('', undefined, 404);
}

// Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000);
if (currentTimestamp > user.resetPasswordTokenExpiration) {
throw new ResponseHelper.ResponseError('', undefined, 404);
}
krynble marked this conversation as resolved.
Show resolved Hide resolved

await Db.collections.User!.update(userId, {
password: hashSync(validPassword, genSaltSync(10)),
resetPasswordToken: null,
resetPasswordTokenExpiration: null,
});

await issueCookie(res, user);
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/UserManagement/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ export function usersNamespace(this: N8nApp): void {
this.app.post(
`/${this.restEndpoint}/users`,
ResponseHelper.send(async (req: UserRequest.Invite) => {
if (config.get('userManagement.emails.mode') === '') {
if (!config.get('userManagement.hasOwner')) {
throw new ResponseHelper.ResponseError(
'You must set up your own account before inviting others',
undefined,
400,
);
}

if (!isEmailSetUp) {
throw new ResponseHelper.ResponseError(
'Email sending must be set up in order to invite other users',
undefined,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/databases/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export class User {
@Column({ type: String, nullable: true })
resetPasswordToken?: string | null;

// Expiration timestamp saved in seconds
@Column({ type: Number, nullable: true })
resetPasswordTokenExpiration?: number | null;

@Column({
type: resolveDataType('json') as ColumnOptions['type'],
nullable: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630451444017';

public async up(queryRunner: QueryRunner): Promise<void> {
console.time(this.name);
BHesseldieck marked this conversation as resolved.
Show resolved Hide resolved
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { v4 as uuid } from 'uuid';
import config = require('../../../../config');
import { loadSurveyFromDisk } from '../../utils/migrationHelpers';

export class CreateUserManagement1636626154933 implements MigrationInterface {
name = 'CreateUserManagement1636626154932';

public async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');

await queryRunner.query(
'CREATE TABLE `' + tablePrefix + 'role` ( ' +
'`id` int NOT NULL AUTO_INCREMENT, ' +
'`name` varchar(32) NOT NULL, ' +
'`scope` varchar(250) NOT NULL, ' +
'`createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'`updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'PRIMARY KEY (`id`), ' +
'UNIQUE KEY `UQ_5b49d0f504f7ef31045a1fb2eb8` (`scope`,`name`) ' +
krynble marked this conversation as resolved.
Show resolved Hide resolved
');'
);

await queryRunner.query(
'CREATE TABLE `' + tablePrefix + 'user` ( ' +
'`id` VARCHAR(100) NOT NULL, ' +
BHesseldieck marked this conversation as resolved.
Show resolved Hide resolved
'`email` VARCHAR(254) NULL DEFAULT NULL, ' +
'`firstName` VARCHAR(32) NULL DEFAULT NULL, ' +
'`lastName` VARCHAR(32) NULL DEFAULT NULL, ' +
'`password` VARCHAR(200) NULL DEFAULT NULL, ' +
'`resetPasswordToken` VARCHAR(200) NULL DEFAULT NULL, ' +
krynble marked this conversation as resolved.
Show resolved Hide resolved
'`resetPasswordTokenExpiration` INT NULL DEFAULT NULL, ' +
'`personalizationAnswers` VARCHAR(200) NULL DEFAULT NULL, ' +
'`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'`globalRoleId` INT NOT NULL, ' +
'PRIMARY KEY (`id`), ' +
'UNIQUE INDEX `IDX_e12875dfb3b1d92d7d7c5377e2` (`email` ASC) VISIBLE, ' +
'INDEX `FK_f0609be844f9200ff4365b1bb3d_idx` (`globalRoleId` ASC) VISIBLE, ' +
BHesseldieck marked this conversation as resolved.
Show resolved Hide resolved
'CONSTRAINT `FK_f0609be844f9200ff4365b1bb3d` ' +
'FOREIGN KEY (`globalRoleId`) ' +
'REFERENCES `n8n`.`role` (`id`) ' +
'ON DELETE NO ACTION ' +
'ON UPDATE NO ACTION);'
);

await queryRunner.query(
'CREATE TABLE `' + tablePrefix + 'shared_workflow` ( ' +
'`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'`roleId` INT NOT NULL, ' +
'`userId` VARCHAR(100) NOT NULL, ' +
'`workflowId` INT NOT NULL, ' +
'INDEX `FK_3540da03964527aa24ae014b780_idx` (`roleId` ASC) VISIBLE, ' +
'INDEX `FK_82b2fd9ec4e3e24209af8160282_idx` (`userId` ASC) VISIBLE, ' +
'INDEX `FK_b83f8d2530884b66a9c848c8b88_idx` (`workflowId` ASC) VISIBLE, ' +
krynble marked this conversation as resolved.
Show resolved Hide resolved
'PRIMARY KEY (`userId`, `workflowId`), ' +
'CONSTRAINT `FK_3540da03964527aa24ae014b780` ' +
'FOREIGN KEY (`roleId`) ' +
'REFERENCES `' + tablePrefix + 'role` (`id`) ' +
'ON DELETE NO ACTION ' +
'ON UPDATE NO ACTION, ' +
'CONSTRAINT `FK_82b2fd9ec4e3e24209af8160282` ' +
'FOREIGN KEY (`userId`) ' +
'REFERENCES `' + tablePrefix + 'user` (`id`) ' +
'ON DELETE CASCADE ' +
'ON UPDATE NO ACTION, ' +
'CONSTRAINT `FK_b83f8d2530884b66a9c848c8b88` ' +
'FOREIGN KEY (`workflowId`) ' +
'REFERENCES `' + tablePrefix + 'workflow_entity` (`id`) ' +
'ON DELETE CASCADE ' +
'ON UPDATE NO ACTION);'
);

await queryRunner.query(
'CREATE TABLE `' + tablePrefix + 'shared_credentials` ( ' +
'`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ' +
'`roleId` INT NOT NULL, ' +
'`userId` VARCHAR(100) NOT NULL, ' +
'`credentialsId` INT NOT NULL, ' +
'INDEX `FK_c68e056637562000b68f480815a_idx` (`roleId` ASC) VISIBLE, ' +
'INDEX `FK_484f0327e778648dd04f1d70493_idx` (`userId` ASC) VISIBLE, ' +
BHesseldieck marked this conversation as resolved.
Show resolved Hide resolved
'PRIMARY KEY (`userId`, `credentialsId`), ' +
'CONSTRAINT `FK_c68e056637562000b68f480815a` ' +
'FOREIGN KEY (`roleId`) ' +
'REFERENCES `' + tablePrefix + 'role` (`id`) ' +
'ON DELETE NO ACTION ' +
'ON UPDATE NO ACTION, ' +
'CONSTRAINT `FK_484f0327e778648dd04f1d70493` ' +
'FOREIGN KEY (`userId`) ' +
'REFERENCES `' + tablePrefix + 'user` (`id`) ' +
'ON DELETE CASCADE ' +
'ON UPDATE NO ACTION, ' +
'CONSTRAINT `FK_68661def1d4bcf2451ac8dbd949` ' +
'FOREIGN KEY (`credentialsId`) ' +
'REFERENCES `' + tablePrefix + 'credentials_entity` (`id`) ' +
'ON DELETE NO ACTION ' +
krynble marked this conversation as resolved.
Show resolved Hide resolved
'ON UPDATE NO ACTION);'
);

await queryRunner.query(
'CREATE TABLE `' + tablePrefix + 'settings` ( ' +
'`key` VARCHAR(250) NOT NULL, ' +
'`value` TEXT(10000) NOT NULL, ' +
krynble marked this conversation as resolved.
Show resolved Hide resolved
'`loadOnStartup` TINYINT(1) NOT NULL DEFAULT 0, ' +
'PRIMARY KEY (`key`));'
);

// Insert initial roles
await queryRunner.query('INSERT INTO `' + tablePrefix + 'role` (name, scope) VALUES ("owner", "global");');

const instanceOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId');

await queryRunner.query('INSERT INTO `' + tablePrefix + 'role` (name, scope) VALUES ("member", "global");');

await queryRunner.query('INSERT INTO `' + tablePrefix + 'role` (name, scope) VALUES ("owner", "workflow");');

const workflowOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId');

await queryRunner.query('INSERT INTO `' + tablePrefix + 'role` (name, scope) VALUES ("owner", "credential");');

const credentialOwnerRole = await queryRunner.query('SELECT LAST_INSERT_ID() as insertId');

const survey = loadSurveyFromDisk();

const ownerUserId = uuid();
await queryRunner.query(
'INSERT INTO `' + tablePrefix + 'user` ' +
'(id, globalRoleId, personalizationAnswers) values ' +
'(?, ?, ?)',
[ownerUserId, instanceOwnerRole[0].insertId, survey ?? null]
);

await queryRunner.query(
'INSERT INTO `' + tablePrefix + 'shared_workflow` (createdAt, updatedAt, roleId, userId, workflowId) ' +
' select NOW(), NOW(), "' + workflowOwnerRole[0].insertId + '", "' + ownerUserId + '", id from `' + tablePrefix + 'workflow_entity`'
);

await queryRunner.query(
'INSERT INTO `' + tablePrefix + 'shared_credentials` (createdAt, updatedAt, roleId, userId, credentialsId) ' +
' select NOW(), NOW(), "' + credentialOwnerRole[0].insertId + '", "' + ownerUserId + '", id from `' + tablePrefix + 'credentials_entity`'
);

await queryRunner.query(
'INSERT INTO `' + tablePrefix + 'settings` (`key`, value, loadOnStartup) values ' +
'("userManagement.hasOwner", "false", 1)'
);
krynble marked this conversation as resolved.
Show resolved Hide resolved
}

public async down(queryRunner: QueryRunner): Promise<void> {
krynble marked this conversation as resolved.
Show resolved Hide resolved
await queryRunner.query(`DROP TABLE "shared_credentials"`);
await queryRunner.query(`DROP TABLE "shared_workflow"`);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`DROP TABLE "role"`);
await queryRunner.query(`DROP TABLE "settings"`);
}
}
Loading