feat: RBAC (#8922)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Val <68596159+valya@users.noreply.github.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
Co-authored-by: Valya Bullions <valya@n8n.io>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Danny Martini <despair.blue@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: oleg <me@olegivaniv.com>
Co-authored-by: Michael Kret <michael.k@radency.com>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Ayato Hayashi <go12limchangyong@gmail.com>
This commit is contained in:
Csaba Tuncsik
2024-05-17 10:53:15 +02:00
committed by GitHub
parent b1f977ebd0
commit 596c472ecc
292 changed files with 14129 additions and 3989 deletions

View File

@@ -11,6 +11,7 @@ import { ApplicationError } from 'n8n-workflow';
import config from '@/config';
import { entities } from './entities';
import { subscribers } from './subscribers';
import { mysqlMigrations } from './migrations/mysqldb';
import { postgresMigrations } from './migrations/postgresdb';
import { sqliteMigrations } from './migrations/sqlite';
@@ -32,6 +33,7 @@ const getCommonOptions = () => {
return {
entityPrefix,
entities: Object.values(entities),
subscribers: Object.values(subscribers),
migrationsTableName: `${entityPrefix}migrations`,
migrationsRun: false,
synchronize: false,

View File

@@ -94,9 +94,11 @@ export class Column {
options.type = isPostgres ? 'timestamptz' : 'datetime';
} else if (type === 'json' && isSqlite) {
options.type = 'text';
} else if (type === 'uuid' && isMysql) {
} else if (type === 'uuid') {
// mysql does not support uuid type
options.type = 'varchar(36)';
if (isMysql) options.type = 'varchar(36)';
// we haven't been defining length on "uuid" varchar on sqlite
if (isSqlite) options.type = 'varchar';
}
if ((type === 'varchar' || type === 'timestamp') && length !== 'auto') {

View File

@@ -46,7 +46,13 @@ export class CreateTable extends TableOperation {
withForeignKey(
columnName: string,
ref: { tableName: string; columnName: string; onDelete?: 'CASCADE'; onUpdate?: 'CASCADE' },
ref: {
tableName: string;
columnName: string;
onDelete?: 'CASCADE';
onUpdate?: 'CASCADE';
name?: string;
},
) {
const foreignKey: TableForeignKeyOptions = {
columnNames: [columnName],
@@ -55,6 +61,7 @@ export class CreateTable extends TableOperation {
};
if (ref.onDelete) foreignKey.onDelete = ref.onDelete;
if (ref.onUpdate) foreignKey.onUpdate = ref.onUpdate;
if (ref.name) foreignKey.name = ref.name;
this.foreignKeys.add(foreignKey);
return this;
}

View File

@@ -0,0 +1,25 @@
import { Column, Entity, OneToMany } from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './AbstractEntity';
import type { ProjectRelation } from './ProjectRelation';
import type { SharedCredentials } from './SharedCredentials';
import type { SharedWorkflow } from './SharedWorkflow';
export type ProjectType = 'personal' | 'team';
@Entity()
export class Project extends WithTimestampsAndStringId {
@Column({ length: 255, nullable: true })
name: string;
@Column({ length: 36 })
type: ProjectType;
@OneToMany('ProjectRelation', 'project')
projectRelations: ProjectRelation[];
@OneToMany('SharedCredentials', 'project')
sharedCredentials: SharedCredentials[];
@OneToMany('SharedWorkflow', 'project')
sharedWorkflows: SharedWorkflow[];
}

View File

@@ -0,0 +1,25 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
// personalOwner is only used for personal projects
export type ProjectRole = 'project:personalOwner' | 'project:admin' | 'project:editor';
@Entity()
export class ProjectRelation extends WithTimestamps {
@Column()
role: ProjectRole;
@ManyToOne('User', 'projectRelations')
user: User;
@PrimaryColumn('uuid')
userId: string;
@ManyToOne('Project', 'projectRelations')
project: Project;
@PrimaryColumn()
projectId: string;
}

View File

@@ -1,7 +1,7 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { CredentialsEntity } from './CredentialsEntity';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
export type CredentialSharingRole = 'credential:owner' | 'credential:user';
@@ -10,15 +10,15 @@ export class SharedCredentials extends WithTimestamps {
@Column()
role: CredentialSharingRole;
@ManyToOne('User', 'sharedCredentials')
user: User;
@PrimaryColumn()
userId: string;
@ManyToOne('CredentialsEntity', 'shared')
credentials: CredentialsEntity;
@PrimaryColumn()
credentialsId: string;
@ManyToOne('Project', 'sharedCredentials')
project: Project;
@PrimaryColumn()
projectId: string;
}

View File

@@ -1,24 +1,24 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WorkflowEntity } from './WorkflowEntity';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor' | 'workflow:user';
export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor';
@Entity()
export class SharedWorkflow extends WithTimestamps {
@Column()
role: WorkflowSharingRole;
@ManyToOne('User', 'sharedWorkflows')
user: User;
@PrimaryColumn()
userId: string;
@ManyToOne('WorkflowEntity', 'shared')
workflow: WorkflowEntity;
@PrimaryColumn()
workflowId: string;
@ManyToOne('Project', 'sharedWorkflows')
project: Project;
@PrimaryColumn()
projectId: string;
}

View File

@@ -18,16 +18,21 @@ import { objectRetriever, lowerCaser } from '../utils/transformers';
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthIdentity } from './AuthIdentity';
import { ownerPermissions, memberPermissions, adminPermissions } from '@/permissions/roles';
import {
GLOBAL_OWNER_SCOPES,
GLOBAL_MEMBER_SCOPES,
GLOBAL_ADMIN_SCOPES,
} from '@/permissions/global-roles';
import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions';
import type { ProjectRelation } from './ProjectRelation';
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;
const STATIC_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
'global:owner': ownerPermissions,
'global:member': memberPermissions,
'global:admin': adminPermissions,
'global:owner': GLOBAL_OWNER_SCOPES,
'global:member': GLOBAL_MEMBER_SCOPES,
'global:admin': GLOBAL_ADMIN_SCOPES,
};
@Entity()
@@ -85,6 +90,9 @@ export class User extends WithTimestamps implements IUser {
@OneToMany('SharedCredentials', 'user')
sharedCredentials: SharedCredentials[];
@OneToMany('ProjectRelation', 'user')
projectRelations: ProjectRelation[];
@Column({ type: Boolean, default: false })
disabled: boolean;
@@ -138,6 +146,7 @@ export class User extends WithTimestamps implements IUser {
{
global: this.globalScopes,
},
undefined,
scopeOptions,
);
}
@@ -146,4 +155,14 @@ export class User extends WithTimestamps implements IUser {
const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this;
return rest;
}
createPersonalProjectName() {
if (this.firstName && this.lastName && this.email) {
return `${this.firstName} ${this.lastName} <${this.email}>`;
} else if (this.email) {
return `<${this.email}>`;
} else {
return 'Unnamed Project';
}
}
}

View File

@@ -19,6 +19,8 @@ import { WorkflowStatistics } from './WorkflowStatistics';
import { ExecutionMetadata } from './ExecutionMetadata';
import { ExecutionData } from './ExecutionData';
import { WorkflowHistory } from './WorkflowHistory';
import { Project } from './Project';
import { ProjectRelation } from './ProjectRelation';
export const entities = {
AuthIdentity,
@@ -41,4 +43,6 @@ export const entities = {
ExecutionMetadata,
ExecutionData,
WorkflowHistory,
Project,
ProjectRelation,
};

View File

@@ -35,6 +35,11 @@ export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration {
return;
}
if (!privateKey && !publicKey) {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}
const settings = escape.tableName('settings');
const key = escape.columnName('key');
const value = escape.columnName('value');

View File

@@ -0,0 +1,328 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
import type { User } from '@/databases/entities/User';
import { generateNanoId } from '@/databases/utils/generators';
import { ApplicationError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
const projectAdminRole: ProjectRole = 'project:personalOwner';
type RelationTable = 'shared_workflow' | 'shared_credentials';
const table = {
sharedCredentials: 'shared_credentials',
sharedCredentialsTemp: 'shared_credentials_2',
sharedWorkflow: 'shared_workflow',
sharedWorkflowTemp: 'shared_workflow_2',
project: 'project',
user: 'user',
projectRelation: 'project_relation',
} as const;
function escapeNames(escape: MigrationContext['escape']) {
const t = {
project: escape.tableName(table.project),
projectRelation: escape.tableName(table.projectRelation),
sharedCredentials: escape.tableName(table.sharedCredentials),
sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp),
sharedWorkflow: escape.tableName(table.sharedWorkflow),
sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp),
user: escape.tableName(table.user),
};
const c = {
createdAt: escape.columnName('createdAt'),
updatedAt: escape.columnName('updatedAt'),
workflowId: escape.columnName('workflowId'),
credentialsId: escape.columnName('credentialsId'),
userId: escape.columnName('userId'),
projectId: escape.columnName('projectId'),
firstName: escape.columnName('firstName'),
lastName: escape.columnName('lastName'),
};
return { t, c };
}
export class CreateProject1714133768519 implements ReversibleMigration {
async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table.project).withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(255).notNull,
column('type').varchar(36).notNull,
).withTimestamps;
await createTable(table.projectRelation)
.withColumns(
column('projectId').varchar(36).primary.notNull,
column('userId').uuid.primary.notNull,
column('role').varchar().notNull,
)
.withIndexOn('projectId')
.withIndexOn('userId')
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async alterSharedTable(
relationTableName: RelationTable,
{
escape,
isMysql,
runQuery,
schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column },
}: MigrationContext,
) {
const projectIdColumn = column('projectId').varchar(36).default('NULL');
await addColumns(relationTableName, [projectIdColumn]);
const relationTable = escape.tableName(relationTableName);
const { t, c } = escapeNames(escape);
// Populate projectId
const subQuery = `
SELECT P.id as ${c.projectId}, T.${c.userId}
FROM ${t.projectRelation} T
LEFT JOIN ${t.project} P
ON T.${c.projectId} = P.id AND P.type = 'personal'
LEFT JOIN ${relationTable} S
ON T.${c.userId} = S.${c.userId}
WHERE P.id IS NOT NULL
`;
const swQuery = isMysql
? `UPDATE ${relationTable}, (${subQuery}) as mapping
SET ${relationTable}.${c.projectId} = mapping.${c.projectId}
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`
: `UPDATE ${relationTable}
SET ${c.projectId} = mapping.${c.projectId}
FROM (${subQuery}) as mapping
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`;
await runQuery(swQuery);
await addForeignKey(relationTableName, 'projectId', ['project', 'id']);
await addNotNull(relationTableName, 'projectId');
// Index the new projectId column
await createIndex(relationTableName, ['projectId']);
}
async alterSharedCredentials({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedCredentialsTemp)
.withColumns(
column('credentialsId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials};
`);
await dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
}
async alterSharedWorkflow({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedWorkflowTemp)
.withColumns(
column('workflowId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow};
`);
await dropTable(table.sharedWorkflow);
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
}
async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) {
const { c, t } = escapeNames(escape);
const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`;
await runInBatches<Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>>(
getUserQuery,
async (users) => {
await Promise.all(
users.map(async (user) => {
const projectId = generateNanoId();
const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email);
await runQuery(
`INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`,
{
projectId,
name,
},
);
await runQuery(
`INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`,
{
projectId,
userId: user.id,
projectRole: projectAdminRole,
},
);
}),
);
},
);
}
// Duplicated from packages/cli/src/databases/entities/User.ts
// Reason:
// This migration should work the same even if we refactor the function in
// `User.ts`.
createPersonalProjectName(firstName?: string, lastName?: string, email?: string) {
if (firstName && lastName && email) {
return `${firstName} ${lastName} <${email}>`;
} else if (email) {
return `<${email}>`;
} else {
return 'Unnamed Project';
}
}
async up(context: MigrationContext) {
await this.setupTables(context);
await this.createUserPersonalProjects(context);
await this.alterSharedTable(table.sharedCredentials, context);
await this.alterSharedCredentials(context);
await this.alterSharedTable(table.sharedWorkflow, context);
await this.alterSharedWorkflow(context);
}
async down({ isMysql, logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) {
const { t, c } = escapeNames(escape);
// 0. check if all projects are personal projects
const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>(
`SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`,
);
if (nonPersonalProjects > 0) {
const message =
'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.';
logger.error(message);
throw new ApplicationError(message);
}
// 1. create temp table for shared workflows
await sb
.createTable(table.sharedWorkflowTemp)
.withColumns(
sb.column('workflowId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the current shared_workflow table's foreignKey for workflowId would
// clash with this one if we don't create a random name.
name: isMysql ? nanoid() : undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// 2. migrate data into temp table
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId})
SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId}
FROM ${t.sharedWorkflow} SW
LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
// 3. drop shared workflow table
await sb.dropTable(table.sharedWorkflow);
// 4. rename temp table
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
// 5. same for shared creds
await sb
.createTable(table.sharedCredentialsTemp)
.withColumns(
sb.column('credentialsId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the current shared_credentials table's foreignKey for credentialsId would
// clash with this one if we don't create a random name.
name: isMysql ? nanoid() : undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId})
SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId}
FROM ${t.sharedCredentials} SC
LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
await sb.dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
// 6. drop project and project relation table
await sb.dropTable(table.projectRelation);
await sb.dropTable(table.project);
}
}

View File

@@ -51,6 +51,7 @@ import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-Execut
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@@ -113,4 +114,5 @@ export const mysqlMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
];

View File

@@ -50,6 +50,7 @@ import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWor
import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTimestampTz';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@@ -111,4 +112,5 @@ export const postgresMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
];

View File

@@ -48,6 +48,7 @@ import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftD
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@@ -107,6 +108,7 @@ const sqliteMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
];
export { sqliteMigrations };

View File

@@ -1,8 +1,7 @@
import { Service } from 'typedi';
import { DataSource, In, Not, Repository, Like } from '@n8n/typeorm';
import type { FindManyOptions, DeleteResult, EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Repository, Like } from '@n8n/typeorm';
import type { FindManyOptions } from '@n8n/typeorm';
import { CredentialsEntity } from '../entities/CredentialsEntity';
import { SharedCredentials } from '../entities/SharedCredentials';
import type { ListQuery } from '@/requests';
@Service()
@@ -11,18 +10,6 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
super(CredentialsEntity, dataSource.manager);
}
async pruneSharings(
transaction: EntityManager,
credentialId: string,
userIds: string[],
): Promise<DeleteResult> {
const conditions: FindOptionsWhere<SharedCredentials> = {
credentialsId: credentialId,
userId: Not(In(userIds)),
};
return await transaction.delete(SharedCredentials, conditions);
}
async findStartingWith(credentialName: string) {
return await this.find({
select: ['name'],
@@ -45,7 +32,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
type Select = Array<keyof CredentialsEntity>;
const defaultRelations = ['shared', 'shared.user'];
const defaultRelations = ['shared', 'shared.project'];
const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt'];
if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations };
@@ -60,6 +47,11 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
filter.type = Like(`%${filter.type}%`);
}
if (typeof filter?.projectId === 'string' && filter.projectId !== '') {
filter.shared = { projectId: filter.projectId };
delete filter.projectId;
}
if (filter) findManyOptions.where = filter;
if (select) findManyOptions.select = select;
if (take) findManyOptions.take = take;
@@ -81,7 +73,11 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
const findManyOptions: FindManyOptions<CredentialsEntity> = { where: { id: In(ids) } };
if (withSharings) {
findManyOptions.relations = ['shared', 'shared.user'];
findManyOptions.relations = {
shared: {
project: true,
},
};
}
return await this.find(findManyOptions);

View File

@@ -0,0 +1,45 @@
import { Service } from 'typedi';
import type { EntityManager } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm';
import { Project } from '../entities/Project';
@Service()
export class ProjectRepository extends Repository<Project> {
constructor(dataSource: DataSource) {
super(Project, dataSource.manager);
}
async getPersonalProjectForUser(userId: string, entityManager?: EntityManager) {
const em = entityManager ?? this.manager;
return await em.findOne(Project, {
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
});
}
async getPersonalProjectForUserOrFail(userId: string) {
return await this.findOneOrFail({
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
});
}
async getAccessibleProjects(userId: string) {
return await this.find({
where: [
{ type: 'personal' },
{
projectRelations: {
userId,
},
},
],
});
}
async getProjectCounts() {
return {
personal: await this.count({ where: { type: 'personal' } }),
team: await this.count({ where: { type: 'team' } }),
};
}
}

View File

@@ -0,0 +1,55 @@
import { Service } from 'typedi';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectRelation, type ProjectRole } from '../entities/ProjectRelation';
@Service()
export class ProjectRelationRepository extends Repository<ProjectRelation> {
constructor(dataSource: DataSource) {
super(ProjectRelation, dataSource.manager);
}
async getPersonalProjectOwners(projectIds: string[]) {
return await this.find({
where: {
projectId: In(projectIds),
role: 'project:personalOwner',
},
relations: { user: true },
});
}
async getPersonalProjectsForUsers(userIds: string[]) {
const projectRelations = await this.find({
where: {
userId: In(userIds),
role: 'project:personalOwner',
},
});
return projectRelations.map((pr) => pr.projectId);
}
/**
* Find the role of a user in a project.
*/
async findProjectRole({ userId, projectId }: { userId: string; projectId: string }) {
const relation = await this.findOneBy({ projectId, userId });
return relation?.role ?? null;
}
/** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */
async countUsersByRole() {
const rows = (await this.createQueryBuilder()
.select(['role', 'COUNT(role) as count'])
.groupBy('role')
.execute()) as Array<{ role: ProjectRole; count: string }>;
return rows.reduce(
(acc, row) => {
acc[row.role] = parseInt(row.count, 10);
return acc;
},
{} as Record<ProjectRole, number>,
);
}
}

View File

@@ -1,22 +1,53 @@
import { Service } from 'typedi';
import type { EntityManager } from '@n8n/typeorm';
import type { EntityManager, FindOptionsRelations, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Not, Repository } from '@n8n/typeorm';
import { type CredentialSharingRole, SharedCredentials } from '../entities/SharedCredentials';
import type { User } from '../entities/User';
import { RoleService } from '@/services/role.service';
import type { Scope } from '@n8n/permissions';
import type { Project } from '../entities/Project';
import type { ProjectRole } from '../entities/ProjectRelation';
@Service()
export class SharedCredentialsRepository extends Repository<SharedCredentials> {
constructor(dataSource: DataSource) {
constructor(
dataSource: DataSource,
private readonly roleService: RoleService,
) {
super(SharedCredentials, dataSource.manager);
}
/** Get a credential if it has been shared with a user */
async findCredentialForUser(credentialsId: string, user: User) {
async findCredentialForUser(
credentialsId: string,
user: User,
scopes: Scope[],
_relations?: FindOptionsRelations<SharedCredentials>,
) {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
...where,
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedCredential = await this.findOne({
relations: ['credentials'],
where: {
credentialsId,
...(!user.hasGlobalScope('credential:read') ? { userId: user.id } : {}),
where,
// TODO: write a small relations merger and use that one here
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
if (!sharedCredential) return null;
@@ -25,7 +56,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
return await this.find({
relations: ['credentials', 'user'],
relations: { credentials: true, project: { projectRelations: { user: true } } },
where: {
credentialsId: In(credentialIds),
role,
@@ -33,37 +64,91 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
});
}
async makeOwnerOfAllCredentials(user: User) {
return await this.update({ userId: Not(user.id), role: 'credential:owner' }, { user });
async makeOwnerOfAllCredentials(project: Project) {
return await this.update(
{
projectId: Not(project.id),
role: 'credential:owner',
},
{ project },
);
}
/** Get the IDs of all credentials owned by a user */
async getOwnedCredentialIds(userIds: string[]) {
return await this.getCredentialIdsByUserAndRole(userIds, ['credential:owner']);
async makeOwner(credentialIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.upsert(
SharedCredentials,
credentialIds.map(
(credentialsId) =>
({
projectId,
credentialsId,
role: 'credential:owner',
}) as const,
),
['projectId', 'credentialsId'],
);
}
/** Get the IDs of all credentials owned by or shared with a user */
async getAccessibleCredentialIds(userIds: string[]) {
return await this.getCredentialIdsByUserAndRole(userIds, [
'credential:owner',
'credential:user',
]);
}
async getCredentialIdsByUserAndRole(
userIds: string[],
options:
| { scopes: Scope[] }
| { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] },
) {
const projectRoles =
'scopes' in options
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const credentialRoles =
'scopes' in options
? this.roleService.rolesWithScope('credential', options.scopes)
: options.credentialRoles;
private async getCredentialIdsByUserAndRole(userIds: string[], roles: CredentialSharingRole[]) {
const sharings = await this.find({
where: {
userId: In(userIds),
role: In(roles),
role: In(credentialRoles),
project: {
projectRelations: {
userId: In(userIds),
role: In(projectRoles),
},
},
},
});
return sharings.map((s) => s.credentialsId);
}
async deleteByIds(transaction: EntityManager, sharedCredentialsIds: string[], user?: User) {
return await transaction.delete(SharedCredentials, {
user,
async deleteByIds(sharedCredentialsIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.delete(SharedCredentials, {
projectId,
credentialsId: In(sharedCredentialsIds),
});
}
async getFilteredAccessibleCredentials(
projectIds: string[],
credentialsIds: string[],
): Promise<string[]> {
return (
await this.find({
where: {
projectId: In(projectIds),
credentialsId: In(credentialsIds),
},
select: ['credentialsId'],
})
).map((s) => s.credentialsId);
}
async findCredentialOwningProject(credentialsId: string) {
return (
await this.findOne({
where: { credentialsId, role: 'credential:owner' },
relations: { project: true },
})
)?.project;
}
}

View File

@@ -4,33 +4,18 @@ import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/type
import { SharedWorkflow, type WorkflowSharingRole } from '../entities/SharedWorkflow';
import { type User } from '../entities/User';
import type { Scope } from '@n8n/permissions';
import type { WorkflowEntity } from '../entities/WorkflowEntity';
import { RoleService } from '@/services/role.service';
import type { Project } from '../entities/Project';
@Service()
export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
constructor(dataSource: DataSource) {
constructor(
dataSource: DataSource,
private roleService: RoleService,
) {
super(SharedWorkflow, dataSource.manager);
}
async hasAccess(workflowId: string, user: User) {
const where: FindOptionsWhere<SharedWorkflow> = {
workflowId,
};
if (!user.hasGlobalScope('workflow:read')) {
where.userId = user.id;
}
return await this.exist({ where });
}
/** Get the IDs of all users this workflow is shared with */
async getSharedUserIds(workflowId: string) {
const sharedWorkflows = await this.find({
select: ['userId'],
where: { workflowId },
});
return sharedWorkflows.map((sharing) => sharing.userId);
}
async getSharedWorkflowIds(workflowIds: string[]) {
const sharedWorkflows = await this.find({
select: ['workflowId'],
@@ -43,11 +28,11 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
async findByWorkflowIds(workflowIds: string[]) {
return await this.find({
relations: ['user'],
where: {
role: 'workflow:owner',
workflowId: In(workflowIds),
},
relations: { project: { projectRelations: { user: true } } },
});
}
@@ -55,90 +40,49 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
userId: string,
workflowId: string,
): Promise<WorkflowSharingRole | undefined> {
return await this.findOne({
select: ['role'],
where: { workflowId, userId },
}).then((shared) => shared?.role);
}
async findSharing(
workflowId: string,
user: User,
scope: Scope,
{ roles, extraRelations }: { roles?: WorkflowSharingRole[]; extraRelations?: string[] } = {},
) {
const where: FindOptionsWhere<SharedWorkflow> = {
workflow: { id: workflowId },
};
if (!user.hasGlobalScope(scope)) {
where.user = { id: user.id };
}
if (roles) {
where.role = In(roles);
}
const relations = ['workflow'];
if (extraRelations) relations.push(...extraRelations);
return await this.findOne({ relations, where });
}
async makeOwnerOfAllWorkflows(user: User) {
return await this.update({ userId: Not(user.id), role: 'workflow:owner' }, { user });
}
async getSharing(
user: User,
workflowId: string,
options: { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: false },
relations: string[] = ['workflow'],
): Promise<SharedWorkflow | null> {
const where: FindOptionsWhere<SharedWorkflow> = { workflowId };
// Omit user from where if the requesting user has relevant
// global workflow permissions. This allows the user to
// access workflows they don't own.
if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) {
where.userId = user.id;
}
return await this.findOne({ where, relations });
}
async getSharedWorkflows(
user: User,
options: {
relations?: string[];
workflowIds?: string[];
},
): Promise<SharedWorkflow[]> {
return await this.find({
where: {
...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }),
...(options.workflowIds && { workflowId: In(options.workflowIds) }),
const sharing = await this.findOne({
// NOTE: We have to select everything that is used in the `where` clause. Otherwise typeorm will create an invalid query and we get this error:
// QueryFailedError: SQLITE_ERROR: no such column: distinctAlias.SharedWorkflow_...
select: {
role: true,
workflowId: true,
projectId: true,
},
where: {
workflowId,
project: { projectRelations: { role: 'project:personalOwner', userId } },
},
...(options.relations && { relations: options.relations }),
});
return sharing?.role;
}
async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[]) {
const newSharedWorkflows = users.reduce<SharedWorkflow[]>((acc, user) => {
if (user.isPending) {
return acc;
}
const entity: Partial<SharedWorkflow> = {
workflowId: workflow.id,
userId: user.id,
role: 'workflow:editor',
};
acc.push(this.create(entity));
return acc;
}, []);
async makeOwnerOfAllWorkflows(project: Project) {
return await this.update(
{
projectId: Not(project.id),
role: 'workflow:owner',
},
{ project },
);
}
return await transaction.save(newSharedWorkflows);
async makeOwner(workflowIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.upsert(
SharedWorkflow,
workflowIds.map(
(workflowId) =>
({
workflowId,
projectId,
role: 'workflow:owner',
}) as const,
),
['projectId', 'workflowId'],
);
}
async findWithFields(
@@ -153,10 +97,107 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
});
}
async deleteByIds(transaction: EntityManager, sharedWorkflowIds: string[], user?: User) {
return await transaction.delete(SharedWorkflow, {
user,
async deleteByIds(sharedWorkflowIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.delete(SharedWorkflow, {
projectId,
workflowId: In(sharedWorkflowIds),
});
}
async findWorkflowForUser(
workflowId: string,
user: User,
scopes: Scope[],
{ includeTags = false, em = this.manager } = {},
) {
let where: FindOptionsWhere<SharedWorkflow> = { workflowId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
...where,
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflow = await em.findOne(SharedWorkflow, {
where,
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
tags: includeTags,
},
},
});
if (!sharedWorkflow) {
return null;
}
return sharedWorkflow.workflow;
}
async findAllWorkflowsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
...where,
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflows = await this.find({
where,
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
return sharedWorkflows.map((sw) => sw.workflow);
}
/**
* Find the IDs of all the projects where a workflow is accessible.
*/
async findProjectIds(workflowId: string) {
const rows = await this.find({ where: { workflowId }, select: ['projectId'] });
const projectIds = rows.reduce<string[]>((acc, row) => {
if (row.projectId) acc.push(row.projectId);
return acc;
}, []);
return [...new Set(projectIds)];
}
async getWorkflowOwningProject(workflowId: string) {
return (
await this.findOne({
where: { workflowId, role: 'workflow:owner' },
relations: { project: true },
})
)?.project;
}
}

View File

@@ -1,9 +1,11 @@
import { Service } from 'typedi';
import type { EntityManager, FindManyOptions } from '@n8n/typeorm';
import type { DeepPartial, EntityManager, FindManyOptions } from '@n8n/typeorm';
import { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
import type { ListQuery } from '@/requests';
import { type GlobalRole, User } from '../entities/User';
import { Project } from '../entities/Project';
import { ProjectRelation } from '../entities/ProjectRelation';
@Service()
export class UserRepository extends Repository<User> {
constructor(dataSource: DataSource) {
@@ -16,6 +18,19 @@ export class UserRepository extends Repository<User> {
});
}
/**
* @deprecated Use `UserRepository.save` instead if you can.
*
* We need to use `save` so that that the subscriber in
* packages/cli/src/databases/entities/Project.ts receives the full user.
* With `update` it would only receive the updated fields, e.g. the `id`
* would be missing. test('does not use `Repository.update`, but
* `Repository.save` instead'.
*/
async update(...args: Parameters<Repository<User>['update']>) {
return await super.update(...args);
}
async deleteAllExcept(user: User) {
await this.delete({ id: Not(user.id) });
}
@@ -104,4 +119,34 @@ export class UserRepository extends Repository<User> {
where: { id: In(userIds), password: Not(IsNull()) },
});
}
async createUserWithProject(
user: DeepPartial<User>,
transactionManager?: EntityManager,
): Promise<{ user: User; project: Project }> {
const createInner = async (entityManager: EntityManager) => {
const newUser = entityManager.create(User, user);
const savedUser = await entityManager.save<User>(newUser);
const savedProject = await entityManager.save<Project>(
entityManager.create(Project, {
type: 'personal',
name: savedUser.createPersonalProjectName(),
}),
);
await entityManager.save<ProjectRelation>(
entityManager.create(ProjectRelation, {
projectId: savedProject.id,
userId: savedUser.id,
role: 'project:personalOwner',
}),
);
return { user: savedUser, project: savedProject };
};
if (transactionManager) {
return await createInner(transactionManager);
}
// TODO: use a transactions
// This is blocked by TypeORM having concurrency issues with transactions
return await createInner(this.manager);
}
}

View File

@@ -8,15 +8,12 @@ import {
type FindOptionsWhere,
type FindOptionsSelect,
type FindManyOptions,
type EntityManager,
type DeleteResult,
Not,
type FindOptionsRelations,
} from '@n8n/typeorm';
import type { ListQuery } from '@/requests';
import { isStringArray } from '@/utils';
import config from '@/config';
import { WorkflowEntity } from '../entities/WorkflowEntity';
import { SharedWorkflow } from '../entities/SharedWorkflow';
import { WebhookEntity } from '../entities/WebhookEntity';
@Service()
@@ -25,7 +22,10 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
super(WorkflowEntity, dataSource.manager);
}
async get(where: FindOptionsWhere<WorkflowEntity>, options?: { relations: string[] }) {
async get(
where: FindOptionsWhere<WorkflowEntity>,
options?: { relations: string[] | FindOptionsRelations<WorkflowEntity> },
) {
return await this.findOne({
where,
relations: options?.relations,
@@ -35,7 +35,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getAllActive() {
return await this.find({
where: { active: true },
relations: ['shared', 'shared.user'],
relations: { shared: { project: { projectRelations: true } } },
});
}
@@ -50,7 +50,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async findById(workflowId: string) {
return await this.findOne({
where: { id: workflowId },
relations: ['shared', 'shared.user'],
relations: { shared: { project: { projectRelations: true } } },
});
}
@@ -71,29 +71,6 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
return totalTriggerCount ?? 0;
}
async getSharings(
transaction: EntityManager,
workflowId: string,
relations = ['shared'],
): Promise<SharedWorkflow[]> {
const workflow = await transaction.findOne(WorkflowEntity, {
where: { id: workflowId },
relations,
});
return workflow?.shared ?? [];
}
async pruneSharings(
transaction: EntityManager,
workflowId: string,
userIds: string[],
): Promise<DeleteResult> {
return await transaction.delete(SharedWorkflow, {
workflowId,
userId: Not(In(userIds)),
});
}
async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise<UpdateResult> {
const qb = this.createQueryBuilder('workflow');
return await qb
@@ -114,6 +91,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) {
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 };
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
options.filter.shared = { projectId: options.filter.projectId };
delete options.filter.projectId;
}
const where: FindOptionsWhere<WorkflowEntity> = {
...options?.filter,
id: In(sharedWorkflowIds),
@@ -135,7 +117,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
createdAt: true,
updatedAt: true,
versionId: true,
shared: { userId: true, role: true },
shared: { role: true },
};
delete select?.ownedBy; // remove non-entity field, handled after query
@@ -152,7 +134,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
select.tags = { id: true, name: true };
}
if (isOwnedByIncluded) relations.push('shared', 'shared.user');
if (isOwnedByIncluded) relations.push('shared', 'shared.project');
if (typeof where.name === 'string' && where.name !== '') {
where.name = Like(`%${where.name}%`);

View File

@@ -1,10 +1,8 @@
import { Service } from 'typedi';
import { DataSource, QueryFailedError, Repository } from '@n8n/typeorm';
import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
import config from '@/config';
import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics';
import type { User } from '@/databases/entities/User';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists';
type StatisticsUpsertResult = StatisticsInsertResult | 'update';
@@ -102,18 +100,18 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
}
async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise<number> {
return await this.createQueryBuilder('workflow_statistics')
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId')
.innerJoin(
SharedWorkflow,
'shared_workflow',
'shared_workflow.workflowId = workflow_statistics.workflowId',
)
.where('shared_workflow.userId = :userId', { userId })
.andWhere('workflow.active = :isActive', { isActive: true })
.andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess })
.andWhere('workflow_statistics.count >= 5')
.andWhere('role = :roleName', { roleName: 'workflow:owner' })
.getCount();
return await this.count({
where: {
workflow: {
shared: {
role: 'workflow:owner',
project: { projectRelations: { userId, role: 'project:personalOwner' } },
},
active: true,
},
name: StatisticsNames.productionSuccess,
count: MoreThanOrEqual(5),
},
});
}
}

View File

@@ -0,0 +1,73 @@
import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm';
import { EventSubscriber } from '@n8n/typeorm';
import { User } from '../entities/User';
import Container from 'typedi';
import { ProjectRepository } from '../repositories/project.repository';
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import { Logger } from '@/Logger';
import { UserRepository } from '../repositories/user.repository';
import { Project } from '../entities/Project';
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
listenTo() {
return User;
}
async afterUpdate(event: UpdateEvent<User>): Promise<void> {
if (event.entity) {
const newUserData = event.entity;
if (event.databaseEntity) {
const fields = event.updatedColumns.map((c) => c.propertyName);
if (
fields.includes('firstName') ||
fields.includes('lastName') ||
fields.includes('email')
) {
const oldUser = event.databaseEntity;
const name =
newUserData instanceof User
? newUserData.createPersonalProjectName()
: Container.get(UserRepository).create(newUserData).createPersonalProjectName();
const project = await Container.get(ProjectRepository).getPersonalProjectForUser(
oldUser.id,
);
if (!project) {
// Since this is benign we're not throwing the exception. We don't
// know if we're running inside a transaction and thus there is a risk
// that this could cause further data inconsistencies.
const message = "Could not update the personal project's name";
Container.get(Logger).warn(message, event.entity);
const exception = new ApplicationError(message);
ErrorReporterProxy.warn(exception, event.entity);
return;
}
project.name = name;
await event.manager.save(Project, project);
}
} else {
// This means the user was updated using `Repository.update`. In this
// case we're missing the user's id and cannot update their project.
//
// When updating the user's firstName, lastName or email we must use
// `Repository.save`, so this is a bug and we should report it to sentry.
//
if (event.entity.firstName || event.entity.lastName || event.entity.email) {
// Since this is benign we're not throwing the exception. We don't
// know if we're running inside a transaction and thus there is a risk
// that this could cause further data inconsistencies.
const message = "Could not update the personal project's name";
Container.get(Logger).warn(message, event.entity);
const exception = new ApplicationError(message);
ErrorReporterProxy.warn(exception, event.entity);
}
}
}
}
}

View File

@@ -0,0 +1,5 @@
import { UserSubscriber } from './UserSubscriber';
export const subscribers = {
UserSubscriber,
};