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:
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
25
packages/cli/src/databases/entities/Project.ts
Normal file
25
packages/cli/src/databases/entities/Project.ts
Normal 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[];
|
||||
}
|
||||
25
packages/cli/src/databases/entities/ProjectRelation.ts
Normal file
25
packages/cli/src/databases/entities/ProjectRelation.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' } }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}%`);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
73
packages/cli/src/databases/subscribers/UserSubscriber.ts
Normal file
73
packages/cli/src/databases/subscribers/UserSubscriber.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
packages/cli/src/databases/subscribers/index.ts
Normal file
5
packages/cli/src/databases/subscribers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { UserSubscriber } from './UserSubscriber';
|
||||
|
||||
export const subscribers = {
|
||||
UserSubscriber,
|
||||
};
|
||||
Reference in New Issue
Block a user