mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(core): Rebuild project roles to load from the database (#17909)
This commit is contained in:
committed by
GitHub
parent
ab7998b441
commit
f757790394
@@ -1,3 +1,5 @@
|
|||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
|
|
||||||
import { ChangeUserRoleInProject } from '../change-user-role-in-project.dto';
|
import { ChangeUserRoleInProject } from '../change-user-role-in-project.dto';
|
||||||
|
|
||||||
describe('ChangeUserRoleInProject', () => {
|
describe('ChangeUserRoleInProject', () => {
|
||||||
@@ -38,7 +40,7 @@ describe('ChangeUserRoleInProject', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'personal owner role',
|
name: 'personal owner role',
|
||||||
request: { role: 'project:personalOwner' },
|
request: { role: PROJECT_OWNER_ROLE_SLUG },
|
||||||
expectedErrorPath: ['role'],
|
expectedErrorPath: ['role'],
|
||||||
},
|
},
|
||||||
])('should reject $name', ({ request, expectedErrorPath }) => {
|
])('should reject $name', ({ request, expectedErrorPath }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { projectRoleSchema } from '@n8n/permissions';
|
import { teamRoleSchema } from '@n8n/permissions';
|
||||||
import { Z } from 'zod-class';
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
export class ChangeUserRoleInProject extends Z.class({
|
export class ChangeUserRoleInProject extends Z.class({
|
||||||
role: projectRoleSchema.exclude(['project:personalOwner']),
|
role: teamRoleSchema,
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { projectRoleSchema } from '@n8n/permissions';
|
import { teamRoleSchema } from '@n8n/permissions';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const projectNameSchema = z.string().min(1).max(255);
|
export const projectNameSchema = z.string().min(1).max(255);
|
||||||
@@ -16,6 +16,6 @@ export const projectDescriptionSchema = z.string().max(512);
|
|||||||
|
|
||||||
export const projectRelationSchema = z.object({
|
export const projectRelationSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
role: projectRoleSchema.exclude(['project:personalOwner']),
|
role: teamRoleSchema,
|
||||||
});
|
});
|
||||||
export type ProjectRelation = z.infer<typeof projectRelationSchema>;
|
export type ProjectRelation = z.infer<typeof projectRelationSchema>;
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { Project, User, ProjectRelation } from '@n8n/db';
|
import type { Project, User, ProjectRelation } from '@n8n/db';
|
||||||
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { ProjectRole } from '@n8n/permissions';
|
import { PROJECT_OWNER_ROLE_SLUG, type CustomRole } from '@n8n/permissions';
|
||||||
|
|
||||||
import { randomName } from '../random';
|
import { randomName } from '../random';
|
||||||
|
|
||||||
export const linkUserToProject = async (user: User, project: Project, role: ProjectRole) => {
|
export const linkUserToProject = async (user: User, project: Project, role: CustomRole) => {
|
||||||
const projectRelationRepository = Container.get(ProjectRelationRepository);
|
const projectRelationRepository = Container.get(ProjectRelationRepository);
|
||||||
await projectRelationRepository.save(
|
await projectRelationRepository.save(
|
||||||
projectRelationRepository.create({
|
projectRelationRepository.create({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role,
|
role: { slug: role },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -41,7 +41,7 @@ export const getPersonalProject = async (user: User): Promise<Project> => {
|
|||||||
where: {
|
where: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
},
|
},
|
||||||
type: 'personal',
|
type: 'personal',
|
||||||
},
|
},
|
||||||
@@ -61,19 +61,20 @@ export const getProjectRelations = async ({
|
|||||||
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
|
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
|
||||||
return await Container.get(ProjectRelationRepository).find({
|
return await Container.get(ProjectRelationRepository).find({
|
||||||
where: { projectId, userId, role },
|
where: { projectId, userId, role },
|
||||||
|
relations: { role: true },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectRoleForUser = async (
|
export const getProjectRoleForUser = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<ProjectRole | undefined> => {
|
): Promise<CustomRole | undefined> => {
|
||||||
return (
|
return (
|
||||||
await Container.get(ProjectRelationRepository).findOne({
|
await Container.get(ProjectRelationRepository).findOne({
|
||||||
select: ['role'],
|
|
||||||
where: { projectId, userId },
|
where: { projectId, userId },
|
||||||
|
relations: { role: true },
|
||||||
})
|
})
|
||||||
)?.role;
|
)?.role?.slug;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllProjectRelations = async ({
|
export const getAllProjectRelations = async ({
|
||||||
@@ -81,5 +82,6 @@ export const getAllProjectRelations = async ({
|
|||||||
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
|
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
|
||||||
return await Container.get(ProjectRelationRepository).find({
|
return await Container.get(ProjectRelationRepository).find({
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
|
relations: { role: true },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { GLOBAL_SCOPE_MAP, type GlobalRole } from '@n8n/permissions';
|
import {
|
||||||
|
GLOBAL_SCOPE_MAP,
|
||||||
|
PROJECT_ADMIN_ROLE_SLUG,
|
||||||
|
PROJECT_EDITOR_ROLE_SLUG,
|
||||||
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
|
PROJECT_SCOPE_MAP,
|
||||||
|
PROJECT_VIEWER_ROLE_SLUG,
|
||||||
|
type GlobalRole,
|
||||||
|
type ProjectRole,
|
||||||
|
} from '@n8n/permissions';
|
||||||
|
|
||||||
import type { Role } from 'entities';
|
import type { Role } from 'entities';
|
||||||
|
|
||||||
@@ -16,15 +25,44 @@ export function buildInRoleToRoleObject(role: GlobalRole): Role {
|
|||||||
systemRole: true,
|
systemRole: true,
|
||||||
roleType: 'global',
|
roleType: 'global',
|
||||||
description: `Built-in global role with ${role} permissions.`,
|
description: `Built-in global role with ${role} permissions.`,
|
||||||
};
|
} as Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInProjectRoleToRoleObject(role: ProjectRole): Role {
|
||||||
|
return {
|
||||||
|
slug: role,
|
||||||
|
displayName: role,
|
||||||
|
scopes: PROJECT_SCOPE_MAP[role].map((scope) => {
|
||||||
|
return {
|
||||||
|
slug: scope,
|
||||||
|
displayName: scope,
|
||||||
|
description: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
systemRole: true,
|
||||||
|
roleType: 'project',
|
||||||
|
description: `Built-in project role with ${role} permissions.`,
|
||||||
|
} as Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GLOBAL_OWNER_ROLE = buildInRoleToRoleObject('global:owner');
|
export const GLOBAL_OWNER_ROLE = buildInRoleToRoleObject('global:owner');
|
||||||
export const GLOBAL_ADMIN_ROLE = buildInRoleToRoleObject('global:admin');
|
export const GLOBAL_ADMIN_ROLE = buildInRoleToRoleObject('global:admin');
|
||||||
export const GLOBAL_MEMBER_ROLE = buildInRoleToRoleObject('global:member');
|
export const GLOBAL_MEMBER_ROLE = buildInRoleToRoleObject('global:member');
|
||||||
|
|
||||||
|
export const PROJECT_OWNER_ROLE = buildInProjectRoleToRoleObject(PROJECT_OWNER_ROLE_SLUG);
|
||||||
|
export const PROJECT_ADMIN_ROLE = buildInProjectRoleToRoleObject(PROJECT_ADMIN_ROLE_SLUG);
|
||||||
|
export const PROJECT_EDITOR_ROLE = buildInProjectRoleToRoleObject(PROJECT_EDITOR_ROLE_SLUG);
|
||||||
|
export const PROJECT_VIEWER_ROLE = buildInProjectRoleToRoleObject(PROJECT_VIEWER_ROLE_SLUG);
|
||||||
|
|
||||||
export const GLOBAL_ROLES: Record<GlobalRole, Role> = {
|
export const GLOBAL_ROLES: Record<GlobalRole, Role> = {
|
||||||
'global:owner': GLOBAL_OWNER_ROLE,
|
'global:owner': GLOBAL_OWNER_ROLE,
|
||||||
'global:admin': GLOBAL_ADMIN_ROLE,
|
'global:admin': GLOBAL_ADMIN_ROLE,
|
||||||
'global:member': GLOBAL_MEMBER_ROLE,
|
'global:member': GLOBAL_MEMBER_ROLE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PROJECT_ROLES: Record<ProjectRole, Role> = {
|
||||||
|
[PROJECT_OWNER_ROLE_SLUG]: PROJECT_OWNER_ROLE,
|
||||||
|
[PROJECT_ADMIN_ROLE_SLUG]: PROJECT_ADMIN_ROLE,
|
||||||
|
[PROJECT_EDITOR_ROLE_SLUG]: PROJECT_EDITOR_ROLE,
|
||||||
|
[PROJECT_VIEWER_ROLE_SLUG]: PROJECT_VIEWER_ROLE,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { ProjectRole } from '@n8n/permissions';
|
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
|
||||||
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
|
|
||||||
|
|
||||||
import { WithTimestamps } from './abstract-entity';
|
import { WithTimestamps } from './abstract-entity';
|
||||||
import { Project } from './project';
|
import { Project } from './project';
|
||||||
|
import { Role } from './role';
|
||||||
import { User } from './user';
|
import { User } from './user';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class ProjectRelation extends WithTimestamps {
|
export class ProjectRelation extends WithTimestamps {
|
||||||
@Column({ type: 'varchar' })
|
@ManyToOne('Role', 'projectRelations')
|
||||||
role: ProjectRole;
|
@JoinColumn({ name: 'role', referencedColumnName: 'slug' })
|
||||||
|
role: Role;
|
||||||
|
|
||||||
@ManyToOne('User', 'projectRelations')
|
@ManyToOne('User', 'projectRelations')
|
||||||
user: User;
|
user: User;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Column, Entity, JoinTable, ManyToMany, PrimaryColumn } from '@n8n/typeorm';
|
import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from '@n8n/typeorm';
|
||||||
|
|
||||||
|
import type { ProjectRelation } from './project-relation';
|
||||||
import { Scope } from './scope';
|
import { Scope } from './scope';
|
||||||
|
|
||||||
@Entity({
|
@Entity({
|
||||||
@@ -45,6 +46,9 @@ export class Role {
|
|||||||
*/
|
*/
|
||||||
roleType: 'global' | 'project' | 'workflow' | 'credential';
|
roleType: 'global' | 'project' | 'workflow' | 'credential';
|
||||||
|
|
||||||
|
@OneToMany('ProjectRelation', 'role')
|
||||||
|
projectRelations: ProjectRelation[];
|
||||||
|
|
||||||
@ManyToMany(() => Scope, {
|
@ManyToMany(() => Scope, {
|
||||||
eager: true,
|
eager: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { PROJECT_ROLES, PROJECT_VIEWER_ROLE } from '../../constants';
|
||||||
|
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This migration links the role table to the project relation table, by adding a new foreign key on the 'role' column
|
||||||
|
* It also ensures that all project relations have a valid role set in the 'role' column.
|
||||||
|
* The migration will insert the project roles that we need into the role table if they do not exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class LinkRoleToProjectRelationTable1753953244168 implements ReversibleMigration {
|
||||||
|
async up({ schemaBuilder: { addForeignKey }, escape, dbType, runQuery }: MigrationContext) {
|
||||||
|
const roleTableName = escape.tableName('role');
|
||||||
|
const projectRelationTableName = escape.tableName('project_relation');
|
||||||
|
const slugColumn = escape.columnName('slug');
|
||||||
|
const roleColumn = escape.columnName('role');
|
||||||
|
const roleTypeColumn = escape.columnName('roleType');
|
||||||
|
const systemRoleColumn = escape.columnName('systemRole');
|
||||||
|
|
||||||
|
const isPostgresOrSqlite = dbType === 'postgresdb' || dbType === 'sqlite';
|
||||||
|
const query = isPostgresOrSqlite
|
||||||
|
? `INSERT INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole) ON CONFLICT DO NOTHING`
|
||||||
|
: `INSERT IGNORE INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole)`;
|
||||||
|
|
||||||
|
// Make sure that the project roles that we need exist
|
||||||
|
for (const role of Object.values(PROJECT_ROLES)) {
|
||||||
|
await runQuery(query, {
|
||||||
|
slug: role.slug,
|
||||||
|
roleType: role.roleType,
|
||||||
|
systemRole: role.systemRole,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to 'project:viewer' for users that do not have a correct role set
|
||||||
|
// This should not happen in a correctly set up system, but we want to ensure
|
||||||
|
// that all users have a role set, before we add the foreign key constraint
|
||||||
|
await runQuery(
|
||||||
|
`UPDATE ${projectRelationTableName} SET ${roleColumn} = '${PROJECT_VIEWER_ROLE.slug}' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleColumn})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await addForeignKey('project_relation', 'role', ['role', 'slug']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down({ schemaBuilder: { dropForeignKey } }: MigrationContext) {
|
||||||
|
await dropForeignKey('project_relation', 'role', ['role', 'slug']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
|
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
|
||||||
|
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
|
||||||
import { InitialMigration1588157391238 } from './1588157391238-InitialMigration';
|
import { InitialMigration1588157391238 } from './1588157391238-InitialMigration';
|
||||||
import { WebhookModel1592447867632 } from './1592447867632-WebhookModel';
|
import { WebhookModel1592447867632 } from './1592447867632-WebhookModel';
|
||||||
import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt';
|
import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt';
|
||||||
@@ -197,4 +198,5 @@ export const mysqlMigrations: Migration[] = [
|
|||||||
CreateDataStoreTables1754475614601,
|
CreateDataStoreTables1754475614601,
|
||||||
RemoveOldRoleColumn1750252139170,
|
RemoveOldRoleColumn1750252139170,
|
||||||
ReplaceDataStoreTablesWithDataTables1754475614602,
|
ReplaceDataStoreTablesWithDataTables1754475614602,
|
||||||
|
LinkRoleToProjectRelationTable1753953244168,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
|
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
|
||||||
import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution';
|
import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution';
|
||||||
|
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
|
||||||
import { InitialMigration1587669153312 } from './1587669153312-InitialMigration';
|
import { InitialMigration1587669153312 } from './1587669153312-InitialMigration';
|
||||||
import { WebhookModel1589476000887 } from './1589476000887-WebhookModel';
|
import { WebhookModel1589476000887 } from './1589476000887-WebhookModel';
|
||||||
import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt';
|
import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt';
|
||||||
@@ -195,4 +196,5 @@ export const postgresMigrations: Migration[] = [
|
|||||||
CreateDataStoreTables1754475614601,
|
CreateDataStoreTables1754475614601,
|
||||||
RemoveOldRoleColumn1750252139170,
|
RemoveOldRoleColumn1750252139170,
|
||||||
ReplaceDataStoreTablesWithDataTables1754475614602,
|
ReplaceDataStoreTablesWithDataTables1754475614602,
|
||||||
|
LinkRoleToProjectRelationTable1753953244168,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752
|
|||||||
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
|
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
|
||||||
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
|
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
|
||||||
import type { Migration } from '../migration-types';
|
import type { Migration } from '../migration-types';
|
||||||
|
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
@@ -189,6 +190,7 @@ const sqliteMigrations: Migration[] = [
|
|||||||
CreateDataStoreTables1754475614601,
|
CreateDataStoreTables1754475614601,
|
||||||
RemoveOldRoleColumn1750252139170,
|
RemoveOldRoleColumn1750252139170,
|
||||||
ReplaceDataStoreTablesWithDataTables1754475614602,
|
ReplaceDataStoreTablesWithDataTables1754475614602,
|
||||||
|
LinkRoleToProjectRelationTable1753953244168,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import type { ProjectRole } from '@n8n/permissions';
|
import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole } from '@n8n/permissions';
|
||||||
import { DataSource, In, Repository } from '@n8n/typeorm';
|
import { DataSource, In, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
import { ProjectRelation } from '../entities';
|
import { ProjectRelation } from '../entities';
|
||||||
@@ -14,7 +14,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
|
|||||||
return await this.find({
|
return await this.find({
|
||||||
where: {
|
where: {
|
||||||
projectId: In(projectIds),
|
projectId: In(projectIds),
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
},
|
},
|
||||||
relations: {
|
relations: {
|
||||||
user: {
|
user: {
|
||||||
@@ -28,7 +28,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
|
|||||||
const projectRelations = await this.find({
|
const projectRelations = await this.find({
|
||||||
where: {
|
where: {
|
||||||
userId: In(userIds),
|
userId: In(userIds),
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
|
|||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
relations: { role: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import type { EntityManager } from '@n8n/typeorm';
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
import { DataSource, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
@@ -14,7 +15,11 @@ export class ProjectRepository extends Repository<Project> {
|
|||||||
const em = entityManager ?? this.manager;
|
const em = entityManager ?? this.manager;
|
||||||
|
|
||||||
return await em.findOne(Project, {
|
return await em.findOne(Project, {
|
||||||
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
|
where: {
|
||||||
|
type: 'personal',
|
||||||
|
projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } },
|
||||||
|
},
|
||||||
|
relations: ['projectRelations.role'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +27,10 @@ export class ProjectRepository extends Repository<Project> {
|
|||||||
const em = entityManager ?? this.manager;
|
const em = entityManager ?? this.manager;
|
||||||
|
|
||||||
return await em.findOneOrFail(Project, {
|
return await em.findOneOrFail(Project, {
|
||||||
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
|
where: {
|
||||||
|
type: 'personal',
|
||||||
|
projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
|||||||
project: {
|
project: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
userId: In(userIds),
|
userId: In(userIds),
|
||||||
role: In(projectRoles),
|
role: { slug: In(projectRoles) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import type { WorkflowSharingRole } from '@n8n/permissions';
|
import { PROJECT_OWNER_ROLE_SLUG, type WorkflowSharingRole } from '@n8n/permissions';
|
||||||
import { DataSource, Repository, In, Not } from '@n8n/typeorm';
|
import { DataSource, Repository, In, Not } from '@n8n/typeorm';
|
||||||
import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
|
import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
|
|||||||
role: 'workflow:owner',
|
role: 'workflow:owner',
|
||||||
workflowId: In(workflowIds),
|
workflowId: In(workflowIds),
|
||||||
},
|
},
|
||||||
relations: { project: { projectRelations: { user: true } } },
|
relations: { project: { projectRelations: { user: true, role: true } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
workflowId,
|
workflowId,
|
||||||
project: { projectRelations: { role: 'project:personalOwner', userId } },
|
project: { projectRelations: { role: { slug: PROJECT_OWNER_ROLE_SLUG }, userId } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { UsersListFilterDto } from '@n8n/api-types';
|
import type { UsersListFilterDto } from '@n8n/api-types';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
|
import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
|
import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
@@ -106,8 +107,8 @@ export class UserRepository extends Repository<User> {
|
|||||||
await entityManager.save<ProjectRelation>(
|
await entityManager.save<ProjectRelation>(
|
||||||
entityManager.create(ProjectRelation, {
|
entityManager.create(ProjectRelation, {
|
||||||
projectId: savedProject.id,
|
projectId: savedProject.id,
|
||||||
userId: userWithRole.id,
|
userId: savedUser.id,
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return { user: userWithRole, project: savedProject };
|
return { user: userWithRole, project: savedProject };
|
||||||
@@ -129,7 +130,7 @@ export class UserRepository extends Repository<User> {
|
|||||||
return await this.findOne({
|
return await this.findOne({
|
||||||
where: {
|
where: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } },
|
project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -146,7 +147,7 @@ export class UserRepository extends Repository<User> {
|
|||||||
return await this.findOne({
|
return await this.findOne({
|
||||||
where: {
|
where: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
projectId,
|
projectId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -232,15 +233,15 @@ export class UserRepository extends Repository<User> {
|
|||||||
expand: UsersListFilterDto['expand'],
|
expand: UsersListFilterDto['expand'],
|
||||||
): SelectQueryBuilder<User> {
|
): SelectQueryBuilder<User> {
|
||||||
if (expand?.includes('projectRelations')) {
|
if (expand?.includes('projectRelations')) {
|
||||||
queryBuilder.leftJoinAndSelect(
|
queryBuilder
|
||||||
'user.projectRelations',
|
.leftJoinAndSelect(
|
||||||
'projectRelations',
|
'user.projectRelations',
|
||||||
'projectRelations.role <> :projectRole',
|
'projectRelations',
|
||||||
{
|
'projectRelations.role <> :projectRole',
|
||||||
projectRole: 'project:personalOwner', // Exclude personal project relations
|
{ projectRole: PROJECT_OWNER_ROLE_SLUG },
|
||||||
},
|
)
|
||||||
);
|
.leftJoinAndSelect('projectRelations.project', 'project')
|
||||||
queryBuilder.leftJoinAndSelect('projectRelations.project', 'project');
|
.leftJoinAndSelect('projectRelations.role', 'projectRole');
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryBuilder;
|
return queryBuilder;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
|
import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
import { WorkflowStatistics } from '../entities';
|
import { WorkflowStatistics } from '../entities';
|
||||||
@@ -122,7 +123,7 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
|
|||||||
workflow: {
|
workflow: {
|
||||||
shared: {
|
shared: {
|
||||||
role: 'workflow:owner',
|
role: 'workflow:owner',
|
||||||
project: { projectRelations: { userId, role: 'project:personalOwner' } },
|
project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } },
|
||||||
},
|
},
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export class AuthRolesService {
|
|||||||
}).filter((scope) => scope !== null);
|
}).filter((scope) => scope !== null);
|
||||||
|
|
||||||
if (scopesToUpdate.length > 0) {
|
if (scopesToUpdate.length > 0) {
|
||||||
this.logger.info(`Updating ${scopesToUpdate.length} scopes...`);
|
this.logger.debug(`Updating ${scopesToUpdate.length} scopes...`);
|
||||||
await this.scopeRepository.save(scopesToUpdate);
|
await this.scopeRepository.save(scopesToUpdate);
|
||||||
this.logger.info('Scopes updated successfully.');
|
this.logger.debug('Scopes updated successfully.');
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug('No scopes to update.');
|
this.logger.debug('No scopes to update.');
|
||||||
}
|
}
|
||||||
@@ -118,9 +118,9 @@ export class AuthRolesService {
|
|||||||
})
|
})
|
||||||
.filter((role) => role !== null);
|
.filter((role) => role !== null);
|
||||||
if (rolesToUpdate.length > 0) {
|
if (rolesToUpdate.length > 0) {
|
||||||
this.logger.info(`Updating ${rolesToUpdate.length} ${roleNamespace} roles...`);
|
this.logger.debug(`Updating ${rolesToUpdate.length} ${roleNamespace} roles...`);
|
||||||
await this.roleRepository.save(rolesToUpdate);
|
await this.roleRepository.save(rolesToUpdate);
|
||||||
this.logger.info(`${roleNamespace} roles updated successfully.`);
|
this.logger.debug(`${roleNamespace} roles updated successfully.`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`No ${roleNamespace} roles to update.`);
|
this.logger.debug(`No ${roleNamespace} roles to update.`);
|
||||||
}
|
}
|
||||||
@@ -128,9 +128,9 @@ export class AuthRolesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.logger.info('Initializing AuthRolesService...');
|
this.logger.debug('Initializing AuthRolesService...');
|
||||||
await this.syncScopes();
|
await this.syncScopes();
|
||||||
await this.syncRoles();
|
await this.syncRoles();
|
||||||
this.logger.info('AuthRolesService initialized successfully.');
|
this.logger.debug('AuthRolesService initialized successfully.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
PROJECT_ADMIN_ROLE_SLUG,
|
||||||
|
PROJECT_EDITOR_ROLE_SLUG,
|
||||||
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
|
PROJECT_VIEWER_ROLE_SLUG,
|
||||||
|
} from '@/constants.ee';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
roleNamespaceSchema,
|
roleNamespaceSchema,
|
||||||
globalRoleSchema,
|
globalRoleSchema,
|
||||||
@@ -53,10 +60,26 @@ describe('assignableGlobalRoleSchema', () => {
|
|||||||
|
|
||||||
describe('projectRoleSchema', () => {
|
describe('projectRoleSchema', () => {
|
||||||
test.each([
|
test.each([
|
||||||
{ name: 'valid role: project:personalOwner', value: 'project:personalOwner', expected: true },
|
{
|
||||||
{ name: 'valid role: project:admin', value: 'project:admin', expected: true },
|
name: `valid role: ${PROJECT_OWNER_ROLE_SLUG}`,
|
||||||
{ name: 'valid role: project:editor', value: 'project:editor', expected: true },
|
value: PROJECT_OWNER_ROLE_SLUG,
|
||||||
{ name: 'valid role: project:viewer', value: 'project:viewer', expected: true },
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `valid role: ${PROJECT_ADMIN_ROLE_SLUG}`,
|
||||||
|
value: PROJECT_ADMIN_ROLE_SLUG,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `valid role: ${PROJECT_EDITOR_ROLE_SLUG}`,
|
||||||
|
value: PROJECT_EDITOR_ROLE_SLUG,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `valid role: ${PROJECT_VIEWER_ROLE_SLUG}`,
|
||||||
|
value: PROJECT_VIEWER_ROLE_SLUG,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
{ name: 'invalid role', value: 'invalid-role', expected: false },
|
{ name: 'invalid role', value: 'invalid-role', expected: false },
|
||||||
])('should validate $name', ({ value, expected }) => {
|
])('should validate $name', ({ value, expected }) => {
|
||||||
const result = projectRoleSchema.safeParse(value);
|
const result = projectRoleSchema.safeParse(value);
|
||||||
|
|||||||
@@ -43,3 +43,8 @@ export const API_KEY_RESOURCES = {
|
|||||||
sourceControl: ['pull'] as const,
|
sourceControl: ['pull'] as const,
|
||||||
workflowTags: ['update', 'list'] as const,
|
workflowTags: ['update', 'list'] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const PROJECT_OWNER_ROLE_SLUG = 'project:personalOwner';
|
||||||
|
export const PROJECT_ADMIN_ROLE_SLUG = 'project:admin';
|
||||||
|
export const PROJECT_EDITOR_ROLE_SLUG = 'project:editor';
|
||||||
|
export const PROJECT_VIEWER_ROLE_SLUG = 'project:viewer';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export * from './scope-information';
|
|||||||
export * from './roles/role-maps.ee';
|
export * from './roles/role-maps.ee';
|
||||||
export * from './roles/all-roles';
|
export * from './roles/all-roles';
|
||||||
|
|
||||||
export { projectRoleSchema } from './schemas.ee';
|
export { projectRoleSchema, teamRoleSchema } from './schemas.ee';
|
||||||
|
|
||||||
export { hasScope } from './utilities/has-scope.ee';
|
export { hasScope } from './utilities/has-scope.ee';
|
||||||
export { hasGlobalScope } from './utilities/has-global-scope.ee';
|
export { hasGlobalScope } from './utilities/has-global-scope.ee';
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
PROJECT_ADMIN_ROLE_SLUG,
|
||||||
|
PROJECT_EDITOR_ROLE_SLUG,
|
||||||
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
|
PROJECT_VIEWER_ROLE_SLUG,
|
||||||
|
} from '../constants.ee';
|
||||||
import {
|
import {
|
||||||
CREDENTIALS_SHARING_SCOPE_MAP,
|
CREDENTIALS_SHARING_SCOPE_MAP,
|
||||||
GLOBAL_SCOPE_MAP,
|
GLOBAL_SCOPE_MAP,
|
||||||
@@ -11,10 +17,10 @@ const ROLE_NAMES: Record<AllRoleTypes, string> = {
|
|||||||
'global:owner': 'Owner',
|
'global:owner': 'Owner',
|
||||||
'global:admin': 'Admin',
|
'global:admin': 'Admin',
|
||||||
'global:member': 'Member',
|
'global:member': 'Member',
|
||||||
'project:personalOwner': 'Project Owner',
|
[PROJECT_OWNER_ROLE_SLUG]: 'Project Owner',
|
||||||
'project:admin': 'Project Admin',
|
[PROJECT_ADMIN_ROLE_SLUG]: 'Project Admin',
|
||||||
'project:editor': 'Project Editor',
|
[PROJECT_EDITOR_ROLE_SLUG]: 'Project Editor',
|
||||||
'project:viewer': 'Project Viewer',
|
[PROJECT_VIEWER_ROLE_SLUG]: 'Project Viewer',
|
||||||
'credential:user': 'Credential User',
|
'credential:user': 'Credential User',
|
||||||
'credential:owner': 'Credential Owner',
|
'credential:owner': 'Credential Owner',
|
||||||
'workflow:owner': 'Workflow Owner',
|
'workflow:owner': 'Workflow Owner',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
|
||||||
|
|
||||||
export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']);
|
export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']);
|
||||||
|
|
||||||
export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']);
|
export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']);
|
||||||
@@ -14,7 +16,11 @@ export const personalRoleSchema = z.enum([
|
|||||||
|
|
||||||
export const teamRoleSchema = z.enum(['project:admin', 'project:editor', 'project:viewer']);
|
export const teamRoleSchema = z.enum(['project:admin', 'project:editor', 'project:viewer']);
|
||||||
|
|
||||||
export const projectRoleSchema = z.enum([...personalRoleSchema.options, ...teamRoleSchema.options]);
|
export const customRoleSchema = z.string().refine((val) => val !== PROJECT_OWNER_ROLE_SLUG, {
|
||||||
|
message: `'${PROJECT_OWNER_ROLE_SLUG}' is not assignable`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectRoleSchema = z.union([personalRoleSchema, teamRoleSchema]);
|
||||||
|
|
||||||
export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']);
|
export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']);
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type CredentialSharingRole = z.infer<typeof credentialSharingRoleSchema>;
|
|||||||
export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>;
|
export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>;
|
||||||
export type TeamProjectRole = z.infer<typeof teamRoleSchema>;
|
export type TeamProjectRole = z.infer<typeof teamRoleSchema>;
|
||||||
export type ProjectRole = z.infer<typeof projectRoleSchema>;
|
export type ProjectRole = z.infer<typeof projectRoleSchema>;
|
||||||
|
export type CustomRole = string;
|
||||||
|
|
||||||
/** Union of all possible role types in the system */
|
/** Union of all possible role types in the system */
|
||||||
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;
|
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import type { User, WorkflowEntity } from '@n8n/db';
|
import type { User, WorkflowEntity } from '@n8n/db';
|
||||||
import { WorkflowRepository, DbConnection } from '@n8n/db';
|
import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { type SelectQueryBuilder } from '@n8n/typeorm';
|
import { type SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
@@ -39,6 +39,7 @@ mockInstance(CommunityPackagesService);
|
|||||||
const dbConnection = mockInstance(DbConnection);
|
const dbConnection = mockInstance(DbConnection);
|
||||||
dbConnection.init.mockResolvedValue(undefined);
|
dbConnection.init.mockResolvedValue(undefined);
|
||||||
dbConnection.migrate.mockResolvedValue(undefined);
|
dbConnection.migrate.mockResolvedValue(undefined);
|
||||||
|
mockInstance(AuthRolesService);
|
||||||
|
|
||||||
test('should start a task runner when task runners are enabled', async () => {
|
test('should start a task runner when task runners are enabled', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import type { User, WorkflowEntity } from '@n8n/db';
|
import type { User, WorkflowEntity } from '@n8n/db';
|
||||||
import { WorkflowRepository, DbConnection } from '@n8n/db';
|
import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { IRun } from 'n8n-workflow';
|
import type { IRun } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { Execute } from '../execute';
|
||||||
|
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
import { DeprecationService } from '@/deprecation/deprecation.service';
|
import { DeprecationService } from '@/deprecation/deprecation.service';
|
||||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||||
@@ -19,8 +21,6 @@ import { ShutdownService } from '@/shutdown/shutdown.service';
|
|||||||
import { TaskRunnerModule } from '@/task-runners/task-runner-module';
|
import { TaskRunnerModule } from '@/task-runners/task-runner-module';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
|
||||||
import { Execute } from '../execute';
|
|
||||||
|
|
||||||
const taskRunnerModule = mockInstance(TaskRunnerModule);
|
const taskRunnerModule = mockInstance(TaskRunnerModule);
|
||||||
const workflowRepository = mockInstance(WorkflowRepository);
|
const workflowRepository = mockInstance(WorkflowRepository);
|
||||||
const ownershipService = mockInstance(OwnershipService);
|
const ownershipService = mockInstance(OwnershipService);
|
||||||
@@ -38,6 +38,7 @@ mockInstance(CommunityPackagesService);
|
|||||||
const dbConnection = mockInstance(DbConnection);
|
const dbConnection = mockInstance(DbConnection);
|
||||||
dbConnection.init.mockResolvedValue(undefined);
|
dbConnection.init.mockResolvedValue(undefined);
|
||||||
dbConnection.migrate.mockResolvedValue(undefined);
|
dbConnection.migrate.mockResolvedValue(undefined);
|
||||||
|
mockInstance(AuthRolesService);
|
||||||
|
|
||||||
test('should start a task runner when task runners are enabled', async () => {
|
test('should start a task runner when task runners are enabled', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@n8n/backend-common';
|
} from '@n8n/backend-common';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||||
import { DbConnection } from '@n8n/db';
|
import { AuthRolesService, DbConnection } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import {
|
import {
|
||||||
BinaryDataConfig,
|
BinaryDataConfig,
|
||||||
@@ -121,6 +121,9 @@ export abstract class BaseCommand<F = never> {
|
|||||||
await this.exitWithCrash('There was an error running database migrations', error),
|
await this.exitWithCrash('There was an error running database migrations', error),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize the auth roles service to make sure that roles are correctly setup for the instance
|
||||||
|
await Container.get(AuthRolesService).init();
|
||||||
|
|
||||||
Container.get(DeprecationService).warn();
|
Container.get(DeprecationService).warn();
|
||||||
|
|
||||||
if (process.env.EXECUTIONS_PROCESS === 'own') process.exit(-1);
|
if (process.env.EXECUTIONS_PROCESS === 'own') process.exit(-1);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Command } from '@n8n/decorators';
|
import { Command } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import type { EntityManager } from '@n8n/typeorm';
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
@@ -17,10 +18,10 @@ import type { ICredentialsEncrypted } from 'n8n-workflow';
|
|||||||
import { jsonParse, UserError } from 'n8n-workflow';
|
import { jsonParse, UserError } from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { UM_FIX_INSTRUCTION } from '@/constants';
|
|
||||||
|
|
||||||
import { BaseCommand } from '../base-command';
|
import { BaseCommand } from '../base-command';
|
||||||
|
|
||||||
|
import { UM_FIX_INSTRUCTION } from '@/constants';
|
||||||
|
|
||||||
const flagsSchema = z.object({
|
const flagsSchema = z.object({
|
||||||
input: z
|
input: z
|
||||||
.string()
|
.string()
|
||||||
@@ -239,7 +240,7 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
|
|||||||
if (sharedCredential && sharedCredential.project.type === 'personal') {
|
if (sharedCredential && sharedCredential.project.type === 'personal') {
|
||||||
const user = await this.transactionManager.findOneByOrFail(User, {
|
const user = await this.transactionManager.findOneByOrFail(User, {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
projectId: sharedCredential.projectId,
|
projectId: sharedCredential.projectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,18 +9,19 @@ import {
|
|||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Command } from '@n8n/decorators';
|
import { Command } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
|
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
|
||||||
import { jsonParse, UserError } from 'n8n-workflow';
|
import { jsonParse, UserError } from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { BaseCommand } from '../base-command';
|
||||||
|
|
||||||
import { UM_FIX_INSTRUCTION } from '@/constants';
|
import { UM_FIX_INSTRUCTION } from '@/constants';
|
||||||
import type { IWorkflowToImport } from '@/interfaces';
|
import type { IWorkflowToImport } from '@/interfaces';
|
||||||
import { ImportService } from '@/services/import.service';
|
import { ImportService } from '@/services/import.service';
|
||||||
|
|
||||||
import { BaseCommand } from '../base-command';
|
|
||||||
|
|
||||||
function assertHasWorkflowsToImport(
|
function assertHasWorkflowsToImport(
|
||||||
workflows: unknown[],
|
workflows: unknown[],
|
||||||
): asserts workflows is IWorkflowToImport[] {
|
): asserts workflows is IWorkflowToImport[] {
|
||||||
@@ -167,7 +168,7 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
|
|||||||
if (sharing && sharing.project.type === 'personal') {
|
if (sharing && sharing.project.type === 'personal') {
|
||||||
const user = await Container.get(UserRepository).findOneByOrFail({
|
const user = await Container.get(UserRepository).findOneByOrFail({
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
projectId: sharing.projectId,
|
projectId: sharing.projectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||||
import { AuthRolesService, ExecutionRepository, SettingsRepository } from '@n8n/db';
|
import { ExecutionRepository, SettingsRepository } from '@n8n/db';
|
||||||
import { Command } from '@n8n/decorators';
|
import { Command } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
@@ -13,8 +13,6 @@ import replaceStream from 'replacestream';
|
|||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { BaseCommand } from './base-command';
|
|
||||||
|
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
@@ -34,6 +32,8 @@ import { UrlService } from '@/services/url.service';
|
|||||||
import { WaitTracker } from '@/wait-tracker';
|
import { WaitTracker } from '@/wait-tracker';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
|
||||||
|
import { BaseCommand } from './base-command';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const open = require('open');
|
const open = require('open');
|
||||||
|
|
||||||
@@ -173,8 +173,6 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
|
|||||||
|
|
||||||
await super.init();
|
await super.init();
|
||||||
|
|
||||||
await Container.get(AuthRolesService).init();
|
|
||||||
|
|
||||||
this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
|
this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
|
||||||
|
|
||||||
const isMultiMainEnabled =
|
const isMultiMainEnabled =
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
} from '@n8n/decorators';
|
} from '@n8n/decorators';
|
||||||
import {
|
import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions';
|
||||||
combineScopes,
|
|
||||||
getAuthPrincipalScopes,
|
|
||||||
getRoleScopes,
|
|
||||||
hasGlobalScope,
|
|
||||||
} from '@n8n/permissions';
|
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In, Not } from '@n8n/typeorm';
|
import { In, Not } from '@n8n/typeorm';
|
||||||
@@ -69,13 +64,18 @@ export class ProjectController {
|
|||||||
uiContext: payload.uiContext,
|
uiContext: payload.uiContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const relations = await this.projectsService.getProjectRelations(project.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
role: 'project:admin',
|
role: 'project:admin',
|
||||||
scopes: [
|
scopes: [
|
||||||
...combineScopes({
|
...combineScopes({
|
||||||
global: getAuthPrincipalScopes(req.user),
|
global: getAuthPrincipalScopes(req.user),
|
||||||
project: getRoleScopes('project:admin'),
|
project:
|
||||||
|
relations
|
||||||
|
.find((pr) => pr.userId === req.user.id)
|
||||||
|
?.role.scopes.map((scope) => scope.slug) || [],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -105,14 +105,14 @@ export class ProjectController {
|
|||||||
for (const pr of relations) {
|
for (const pr of relations) {
|
||||||
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
|
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
|
||||||
this.projectRepository.create(pr.project),
|
this.projectRepository.create(pr.project),
|
||||||
{ role: pr.role, scopes: [] },
|
{ role: pr.role.slug, scopes: [] },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.scopes) {
|
if (result.scopes) {
|
||||||
result.scopes.push(
|
result.scopes.push(
|
||||||
...combineScopes({
|
...combineScopes({
|
||||||
global: getAuthPrincipalScopes(req.user),
|
global: getAuthPrincipalScopes(req.user),
|
||||||
project: getRoleScopes(pr.role),
|
project: pr.role.scopes.map((scope) => scope.slug),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,10 +155,15 @@ export class ProjectController {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
throw new NotFoundError('Could not find a personal project for this user');
|
throw new NotFoundError('Could not find a personal project for this user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const relations = await this.projectsService.getProjectRelations(project.id);
|
||||||
const scopes: Scope[] = [
|
const scopes: Scope[] = [
|
||||||
...combineScopes({
|
...combineScopes({
|
||||||
global: getAuthPrincipalScopes(req.user),
|
global: getAuthPrincipalScopes(req.user),
|
||||||
project: getRoleScopes('project:personalOwner'),
|
project:
|
||||||
|
relations
|
||||||
|
.find((pr) => pr.userId === req.user.id)
|
||||||
|
?.role.scopes.map((scope) => scope.slug) ?? [],
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
@@ -191,12 +196,12 @@ export class ProjectController {
|
|||||||
email: r.user.email,
|
email: r.user.email,
|
||||||
firstName: r.user.firstName,
|
firstName: r.user.firstName,
|
||||||
lastName: r.user.lastName,
|
lastName: r.user.lastName,
|
||||||
role: r.role,
|
role: r.role.slug,
|
||||||
})),
|
})),
|
||||||
scopes: [
|
scopes: [
|
||||||
...combineScopes({
|
...combineScopes({
|
||||||
global: getAuthPrincipalScopes(req.user),
|
global: getAuthPrincipalScopes(req.user),
|
||||||
...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}),
|
...(myRelation ? { project: myRelation.role.scopes.map((scope) => scope.slug) } : {}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export class UsersController {
|
|||||||
...user,
|
...user,
|
||||||
projectRelations: u.projectRelations?.map((pr) => ({
|
projectRelations: u.projectRelations?.map((pr) => ({
|
||||||
id: pr.projectId,
|
id: pr.projectId,
|
||||||
role: pr.role, // normalize role for frontend
|
role: pr.role.slug, // normalize role for frontend
|
||||||
name: pr.project.name,
|
name: pr.project.name,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,14 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
import type { CredentialsEntity, ICredentialsDb } from '@n8n/db';
|
import type { CredentialsEntity, ICredentialsDb } from '@n8n/db';
|
||||||
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
import {
|
||||||
|
CredentialsRepository,
|
||||||
|
GLOBAL_ADMIN_ROLE,
|
||||||
|
GLOBAL_OWNER_ROLE,
|
||||||
|
SharedCredentialsRepository,
|
||||||
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { EntityNotFoundError, In } from '@n8n/typeorm';
|
import { EntityNotFoundError, In } from '@n8n/typeorm';
|
||||||
import { Credentials, getAdditionalKeys } from 'n8n-core';
|
import { Credentials, getAdditionalKeys } from 'n8n-core';
|
||||||
@@ -496,9 +502,9 @@ export class CredentialsHelper extends ICredentialsHelper {
|
|||||||
role: 'credential:owner',
|
role: 'credential:owner',
|
||||||
project: {
|
project: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: In(['project:personalOwner', 'project:admin']),
|
role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]) },
|
||||||
user: {
|
user: {
|
||||||
role: In(['global:owner', 'global:admin']),
|
role: { slug: In([GLOBAL_OWNER_ROLE.slug, GLOBAL_ADMIN_ROLE.slug]) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,12 +25,17 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
} from '@n8n/decorators';
|
} from '@n8n/decorators';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In } from '@n8n/typeorm';
|
import { In } from '@n8n/typeorm';
|
||||||
import { deepCopy } from 'n8n-workflow';
|
import { deepCopy } from 'n8n-workflow';
|
||||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { CredentialsFinderService } from './credentials-finder.service';
|
||||||
|
import { CredentialsService } from './credentials.service';
|
||||||
|
import { EnterpriseCredentialsService } from './credentials.service.ee';
|
||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
@@ -42,10 +47,6 @@ import { NamingService } from '@/services/naming.service';
|
|||||||
import { UserManagementMailer } from '@/user-management/email';
|
import { UserManagementMailer } from '@/user-management/email';
|
||||||
import * as utils from '@/utils';
|
import * as utils from '@/utils';
|
||||||
|
|
||||||
import { CredentialsFinderService } from './credentials-finder.service';
|
|
||||||
import { CredentialsService } from './credentials.service';
|
|
||||||
import { EnterpriseCredentialsService } from './credentials.service.ee';
|
|
||||||
|
|
||||||
@RestController('/credentials')
|
@RestController('/credentials')
|
||||||
export class CredentialsController {
|
export class CredentialsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -364,7 +365,7 @@ export class CredentialsController {
|
|||||||
|
|
||||||
const projectsRelations = await this.projectRelationRepository.findBy({
|
const projectsRelations = await this.projectRelationRepository.findBy({
|
||||||
projectId: In(newShareeIds),
|
projectId: In(newShareeIds),
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.userManagementMailer.notifyCredentialsShared({
|
await this.userManagementMailer.notifyCredentialsShared({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
UserRepository,
|
UserRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { hasGlobalScope, type Scope } from '@n8n/permissions';
|
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import {
|
import {
|
||||||
In,
|
In,
|
||||||
@@ -27,6 +27,8 @@ import type {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers, UnexpectedError } from 'n8n-workflow';
|
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers, UnexpectedError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { CredentialsFinderService } from './credentials-finder.service';
|
||||||
|
|
||||||
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
|
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
|
||||||
import { CredentialTypes } from '@/credential-types';
|
import { CredentialTypes } from '@/credential-types';
|
||||||
import { createCredentialsFromCredentialsEntity } from '@/credentials-helper';
|
import { createCredentialsFromCredentialsEntity } from '@/credentials-helper';
|
||||||
@@ -38,12 +40,9 @@ import { userHasScopes } from '@/permissions.ee/check-access';
|
|||||||
import type { CredentialRequest, ListQuery } from '@/requests';
|
import type { CredentialRequest, ListQuery } from '@/requests';
|
||||||
import { CredentialsTester } from '@/services/credentials-tester.service';
|
import { CredentialsTester } from '@/services/credentials-tester.service';
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
// eslint-disable-next-line import-x/no-cycle
|
|
||||||
import { ProjectService } from '@/services/project.service.ee';
|
import { ProjectService } from '@/services/project.service.ee';
|
||||||
import { RoleService } from '@/services/role.service';
|
import { RoleService } from '@/services/role.service';
|
||||||
|
|
||||||
import { CredentialsFinderService } from './credentials-finder.service';
|
|
||||||
|
|
||||||
export type CredentialsGetSharedOptions =
|
export type CredentialsGetSharedOptions =
|
||||||
| { allowGlobalScope: true; globalScope: Scope }
|
| { allowGlobalScope: true; globalScope: Scope }
|
||||||
| { allowGlobalScope: false };
|
| { allowGlobalScope: false };
|
||||||
@@ -300,7 +299,7 @@ export class CredentialsService {
|
|||||||
role: 'credential:owner',
|
role: 'credential:owner',
|
||||||
project: {
|
project: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SourceControlledFile } from '@n8n/api-types';
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { GLOBAL_ADMIN_ROLE, User } from '@n8n/db';
|
import { GLOBAL_ADMIN_ROLE, PROJECT_OWNER_ROLE, User } from '@n8n/db';
|
||||||
import type {
|
import type {
|
||||||
SharedCredentials,
|
SharedCredentials,
|
||||||
SharedWorkflow,
|
SharedWorkflow,
|
||||||
@@ -83,7 +83,7 @@ describe('SourceControlExportService', () => {
|
|||||||
type: 'personal',
|
type: 'personal',
|
||||||
projectRelations: [
|
projectRelations: [
|
||||||
{
|
{
|
||||||
role: 'project:personalOwner',
|
role: PROJECT_OWNER_ROLE,
|
||||||
user: mock({ email: 'user@example.com' }),
|
user: mock({ email: 'user@example.com' }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -268,7 +268,7 @@ describe('SourceControlExportService', () => {
|
|||||||
mock<SharedWorkflow>({
|
mock<SharedWorkflow>({
|
||||||
project: mock({
|
project: mock({
|
||||||
type: 'personal',
|
type: 'personal',
|
||||||
projectRelations: [{ role: 'project:personalOwner', user: mock() }],
|
projectRelations: [{ role: PROJECT_OWNER_ROLE, user: mock() }],
|
||||||
}),
|
}),
|
||||||
workflow: mock(),
|
workflow: mock(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('SourceControlImportService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
|
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
|
||||||
sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValueOnce([]);
|
sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
|
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
|
||||||
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
|
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
|
||||||
@@ -312,7 +312,7 @@ describe('SourceControlImportService', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValue([
|
sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValue([
|
||||||
Object.assign(new Project(), {
|
Object.assign(new Project(), {
|
||||||
id: 'project1',
|
id: 'project1',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In } from '@n8n/typeorm';
|
import { In } from '@n8n/typeorm';
|
||||||
import { rmSync } from 'fs';
|
import { rmSync } from 'fs';
|
||||||
@@ -18,8 +19,6 @@ import { UnexpectedError, type ICredentialDataDecryptedObject } from 'n8n-workfl
|
|||||||
import { writeFile as fsWriteFile, rm as fsRm } from 'node:fs/promises';
|
import { writeFile as fsWriteFile, rm as fsRm } from 'node:fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { formatWorkflow } from '@/workflows/workflow.formatter';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||||
SOURCE_CONTROL_GIT_FOLDER,
|
SOURCE_CONTROL_GIT_FOLDER,
|
||||||
@@ -44,6 +43,8 @@ import type { RemoteResourceOwner } from './types/resource-owner';
|
|||||||
import type { SourceControlContext } from './types/source-control-context';
|
import type { SourceControlContext } from './types/source-control-context';
|
||||||
import { VariablesService } from '../variables/variables.service.ee';
|
import { VariablesService } from '../variables/variables.service.ee';
|
||||||
|
|
||||||
|
import { formatWorkflow } from '@/workflows/workflow.formatter';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SourceControlExportService {
|
export class SourceControlExportService {
|
||||||
private gitFolder: string;
|
private gitFolder: string;
|
||||||
@@ -145,7 +146,7 @@ export class SourceControlExportService {
|
|||||||
|
|
||||||
if (project.type === 'personal') {
|
if (project.type === 'personal') {
|
||||||
const ownerRelation = project.projectRelations.find(
|
const ownerRelation = project.projectRelations.find(
|
||||||
(pr) => pr.role === 'project:personalOwner',
|
(pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
|
||||||
);
|
);
|
||||||
if (!ownerRelation) {
|
if (!ownerRelation) {
|
||||||
throw new UnexpectedError(
|
throw new UnexpectedError(
|
||||||
@@ -251,7 +252,7 @@ export class SourceControlExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allowedProjects =
|
const allowedProjects =
|
||||||
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
|
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
|
||||||
|
|
||||||
const fileName = getFoldersPath(this.gitFolder);
|
const fileName = getFoldersPath(this.gitFolder);
|
||||||
|
|
||||||
@@ -409,7 +410,7 @@ export class SourceControlExportService {
|
|||||||
let owner: RemoteResourceOwner | null = null;
|
let owner: RemoteResourceOwner | null = null;
|
||||||
if (sharing.project.type === 'personal') {
|
if (sharing.project.type === 'personal') {
|
||||||
const ownerRelation = sharing.project.projectRelations.find(
|
const ownerRelation = sharing.project.projectRelations.find(
|
||||||
(pr) => pr.role === 'project:personalOwner',
|
(pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
|
||||||
);
|
);
|
||||||
if (ownerRelation) {
|
if (ownerRelation) {
|
||||||
owner = {
|
owner = {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
UserRepository,
|
UserRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In } from '@n8n/typeorm';
|
import { In } from '@n8n/typeorm';
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
@@ -23,14 +24,6 @@ import { jsonParse, ensureError, UserError, UnexpectedError } from 'n8n-workflow
|
|||||||
import { readFile as fsReadFile } from 'node:fs/promises';
|
import { readFile as fsReadFile } from 'node:fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
|
||||||
import { CredentialsService } from '@/credentials/credentials.service';
|
|
||||||
import type { IWorkflowToImport } from '@/interfaces';
|
|
||||||
import { isUniqueConstraintError } from '@/response-helper';
|
|
||||||
import { TagService } from '@/services/tag.service';
|
|
||||||
import { assertNever } from '@/utils';
|
|
||||||
import { WorkflowService } from '@/workflows/workflow.service';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||||
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
|
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
|
||||||
@@ -52,6 +45,14 @@ import type { SourceControlContext } from './types/source-control-context';
|
|||||||
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
||||||
import { VariablesService } from '../variables/variables.service.ee';
|
import { VariablesService } from '../variables/variables.service.ee';
|
||||||
|
|
||||||
|
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
|
import { CredentialsService } from '@/credentials/credentials.service';
|
||||||
|
import type { IWorkflowToImport } from '@/interfaces';
|
||||||
|
import { isUniqueConstraintError } from '@/response-helper';
|
||||||
|
import { TagService } from '@/services/tag.service';
|
||||||
|
import { assertNever } from '@/utils';
|
||||||
|
import { WorkflowService } from '@/workflows/workflow.service';
|
||||||
|
|
||||||
const findOwnerProject = (
|
const findOwnerProject = (
|
||||||
owner: RemoteResourceOwner,
|
owner: RemoteResourceOwner,
|
||||||
accessibleProjects: Project[],
|
accessibleProjects: Project[],
|
||||||
@@ -59,7 +60,7 @@ const findOwnerProject = (
|
|||||||
if (typeof owner === 'string') {
|
if (typeof owner === 'string') {
|
||||||
return accessibleProjects.find((project) =>
|
return accessibleProjects.find((project) =>
|
||||||
project.projectRelations.some(
|
project.projectRelations.some(
|
||||||
(r) => r.role === 'project:personalOwner' && r.user.email === owner,
|
(r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG && r.user.email === owner,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ const findOwnerProject = (
|
|||||||
(project) =>
|
(project) =>
|
||||||
project.type === 'personal' &&
|
project.type === 'personal' &&
|
||||||
project.projectRelations.some(
|
project.projectRelations.some(
|
||||||
(r) => r.role === 'project:personalOwner' && r.user.email === owner.personalEmail,
|
(r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG && r.user.email === owner.personalEmail,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ const getOwnerFromProject = (remoteOwnerProject: Project): StatusResourceOwner |
|
|||||||
|
|
||||||
if (remoteOwnerProject?.type === 'personal') {
|
if (remoteOwnerProject?.type === 'personal') {
|
||||||
const personalEmail = remoteOwnerProject.projectRelations?.find(
|
const personalEmail = remoteOwnerProject.projectRelations?.find(
|
||||||
(r) => r.role === 'project:personalOwner',
|
(r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG,
|
||||||
)?.user?.email;
|
)?.user?.email;
|
||||||
|
|
||||||
if (personalEmail) {
|
if (personalEmail) {
|
||||||
@@ -148,7 +149,7 @@ export class SourceControlImportService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const accessibleProjects =
|
const accessibleProjects =
|
||||||
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
|
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
|
||||||
|
|
||||||
const remoteWorkflowsRead = await Promise.all(
|
const remoteWorkflowsRead = await Promise.all(
|
||||||
remoteWorkflowFiles.map(async (file) => {
|
remoteWorkflowFiles.map(async (file) => {
|
||||||
@@ -251,7 +252,9 @@ export class SourceControlImportService {
|
|||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: true,
|
role: {
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
email: true,
|
email: true,
|
||||||
},
|
},
|
||||||
@@ -300,7 +303,7 @@ export class SourceControlImportService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const accessibleProjects =
|
const accessibleProjects =
|
||||||
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
|
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
|
||||||
|
|
||||||
const remoteCredentialFilesRead = await Promise.all(
|
const remoteCredentialFilesRead = await Promise.all(
|
||||||
remoteCredentialFiles.map(async (file) => {
|
remoteCredentialFiles.map(async (file) => {
|
||||||
@@ -368,7 +371,9 @@ export class SourceControlImportService {
|
|||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: true,
|
role: {
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
email: true,
|
email: true,
|
||||||
},
|
},
|
||||||
@@ -426,7 +431,7 @@ export class SourceControlImportService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const accessibleProjects =
|
const accessibleProjects =
|
||||||
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
|
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
|
||||||
|
|
||||||
mappedFolders.folders = mappedFolders.folders.filter(
|
mappedFolders.folders = mappedFolders.folders.filter(
|
||||||
(folder) =>
|
(folder) =>
|
||||||
|
|||||||
@@ -29,20 +29,21 @@ export class SourceControlScopedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ctx = new SourceControlContext(req.user);
|
const ctx = new SourceControlContext(req.user);
|
||||||
const projectsWithAdminAccess = await this.getAdminProjectsFromContext(ctx);
|
const projectsWithAdminAccess = await this.getAuthorizedProjectsFromContext(ctx);
|
||||||
|
|
||||||
if (projectsWithAdminAccess?.length === 0) {
|
if (projectsWithAdminAccess?.length === 0) {
|
||||||
throw new ForbiddenError('You are not allowed to push changes');
|
throw new ForbiddenError('You are not allowed to push changes');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAdminProjectsFromContext(context: SourceControlContext): Promise<Project[]> {
|
async getAuthorizedProjectsFromContext(context: SourceControlContext): Promise<Project[]> {
|
||||||
if (context.hasAccessToAllProjects()) {
|
if (context.hasAccessToAllProjects()) {
|
||||||
// In case the user is a global admin or owner, we don't need a filter
|
// In case the user is a global admin or owner, we don't need a filter
|
||||||
return await this.projectRepository.find({
|
return await this.projectRepository.find({
|
||||||
relations: {
|
relations: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
user: true,
|
user: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -52,6 +53,7 @@ export class SourceControlScopedService {
|
|||||||
relations: {
|
relations: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
user: true,
|
user: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -59,7 +61,7 @@ export class SourceControlScopedService {
|
|||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
},
|
},
|
||||||
where: this.getAdminProjectsByContextFilter(context),
|
where: this.getProjectsWithPushScopeByContextFilter(context),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ export class SourceControlScopedService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getAdminProjectsByContextFilter(
|
getProjectsWithPushScopeByContextFilter(
|
||||||
context: SourceControlContext,
|
context: SourceControlContext,
|
||||||
): FindOptionsWhere<Project> | undefined {
|
): FindOptionsWhere<Project> | undefined {
|
||||||
if (context.hasAccessToAllProjects()) {
|
if (context.hasAccessToAllProjects()) {
|
||||||
@@ -96,7 +98,11 @@ export class SourceControlScopedService {
|
|||||||
return {
|
return {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: 'project:admin',
|
role: {
|
||||||
|
scopes: {
|
||||||
|
slug: 'sourceControl:push',
|
||||||
|
},
|
||||||
|
},
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -113,7 +119,7 @@ export class SourceControlScopedService {
|
|||||||
// We build a filter to only select folder, that belong to a team project
|
// We build a filter to only select folder, that belong to a team project
|
||||||
// that the user is an admin off
|
// that the user is an admin off
|
||||||
return {
|
return {
|
||||||
homeProject: this.getAdminProjectsByContextFilter(context),
|
homeProject: this.getProjectsWithPushScopeByContextFilter(context),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +136,7 @@ export class SourceControlScopedService {
|
|||||||
return {
|
return {
|
||||||
shared: {
|
shared: {
|
||||||
role: 'workflow:owner',
|
role: 'workflow:owner',
|
||||||
project: this.getAdminProjectsByContextFilter(context),
|
project: this.getProjectsWithPushScopeByContextFilter(context),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -148,7 +154,7 @@ export class SourceControlScopedService {
|
|||||||
return {
|
return {
|
||||||
shared: {
|
shared: {
|
||||||
role: 'credential:owner',
|
role: 'credential:owner',
|
||||||
project: this.getAdminProjectsByContextFilter(context),
|
project: this.getProjectsWithPushScopeByContextFilter(context),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import { snakeCase } from 'change-case';
|
import { snakeCase } from 'change-case';
|
||||||
import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
|
import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
|
||||||
import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow';
|
import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow';
|
||||||
@@ -13,6 +14,9 @@ import { TelemetryHelpers } from 'n8n-workflow';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { get as pslGet } from 'psl';
|
import { get as pslGet } from 'psl';
|
||||||
|
|
||||||
|
import { EventRelay } from './event-relay';
|
||||||
|
import { Telemetry } from '../../telemetry';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { N8N_VERSION } from '@/constants';
|
import { N8N_VERSION } from '@/constants';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
@@ -22,9 +26,6 @@ import type { IExecutionTrackProperties } from '@/interfaces';
|
|||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
|
|
||||||
import { EventRelay } from './event-relay';
|
|
||||||
import { Telemetry } from '../../telemetry';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class TelemetryEventRelay extends EventRelay {
|
export class TelemetryEventRelay extends EventRelay {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -604,7 +605,7 @@ export class TelemetryEventRelay extends EventRelay {
|
|||||||
projectId: workflowOwner.id,
|
projectId: workflowOwner.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (projectRole && projectRole !== 'project:personalOwner') {
|
if (projectRole && projectRole?.slug !== PROJECT_OWNER_ROLE_SLUG) {
|
||||||
userRole = 'member';
|
userRole = 'member';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { User, ExecutionSummaries } from '@n8n/db';
|
import type { User, ExecutionSummaries } from '@n8n/db';
|
||||||
import { Get, Patch, Post, RestController } from '@n8n/decorators';
|
import { Get, Patch, Post, RestController } from '@n8n/decorators';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import { PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
|
||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
|
||||||
import { License } from '@/license';
|
|
||||||
import { isPositiveInteger } from '@/utils';
|
|
||||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
|
||||||
|
|
||||||
import { ExecutionService } from './execution.service';
|
import { ExecutionService } from './execution.service';
|
||||||
import { EnterpriseExecutionsService } from './execution.service.ee';
|
import { EnterpriseExecutionsService } from './execution.service.ee';
|
||||||
@@ -14,6 +8,12 @@ import { ExecutionRequest } from './execution.types';
|
|||||||
import { parseRangeQuery } from './parse-range-query.middleware';
|
import { parseRangeQuery } from './parse-range-query.middleware';
|
||||||
import { validateExecutionUpdatePayload } from './validation';
|
import { validateExecutionUpdatePayload } from './validation';
|
||||||
|
|
||||||
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
import { License } from '@/license';
|
||||||
|
import { isPositiveInteger } from '@/utils';
|
||||||
|
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||||
|
|
||||||
@RestController('/executions')
|
@RestController('/executions')
|
||||||
export class ExecutionsController {
|
export class ExecutionsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -29,7 +29,7 @@ export class ExecutionsController {
|
|||||||
} else {
|
} else {
|
||||||
return await this.workflowSharingService.getSharedWorkflowIds(user, {
|
return await this.workflowSharingService.getSharedWorkflowIds(user, {
|
||||||
workflowRoles: ['workflow:owner'],
|
workflowRoles: ['workflow:owner'],
|
||||||
projectRoles: ['project:personalOwner'],
|
projectRoles: [PROJECT_OWNER_ROLE_SLUG],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
|
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
|
||||||
import {
|
import {
|
||||||
|
type Role,
|
||||||
GLOBAL_MEMBER_ROLE,
|
GLOBAL_MEMBER_ROLE,
|
||||||
GLOBAL_OWNER_ROLE,
|
GLOBAL_OWNER_ROLE,
|
||||||
ProjectRelationRepository,
|
ProjectRelationRepository,
|
||||||
type Project,
|
type Project,
|
||||||
type User,
|
type User,
|
||||||
|
PROJECT_ADMIN_ROLE,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { EntityManager } from '@n8n/typeorm';
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
@@ -71,7 +73,7 @@ describe('dataStoreAggregate', () => {
|
|||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
projectId: project1.id,
|
projectId: project1.id,
|
||||||
role: 'project:admin',
|
role: PROJECT_ADMIN_ROLE,
|
||||||
user,
|
user,
|
||||||
project: project1,
|
project: project1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -81,7 +83,7 @@ describe('dataStoreAggregate', () => {
|
|||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
projectId: project2.id,
|
projectId: project2.id,
|
||||||
role: 'project:viewer',
|
role: { slug: 'project:viewer' } as Role,
|
||||||
user,
|
user,
|
||||||
project: project2,
|
project: project2,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -147,7 +149,7 @@ describe('dataStoreAggregate', () => {
|
|||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
projectId: project1.id,
|
projectId: project1.id,
|
||||||
role: 'project:admin',
|
role: PROJECT_ADMIN_ROLE,
|
||||||
user,
|
user,
|
||||||
project: project1,
|
project: project1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -157,7 +159,7 @@ describe('dataStoreAggregate', () => {
|
|||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
projectId: project2.id,
|
projectId: project2.id,
|
||||||
role: 'project:viewer',
|
role: { slug: 'project:viewer' } as Role,
|
||||||
user,
|
user,
|
||||||
project: project2,
|
project: project2,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -196,7 +198,7 @@ describe('dataStoreAggregate', () => {
|
|||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
projectId: project1.id,
|
projectId: project1.id,
|
||||||
role: 'project:admin',
|
role: PROJECT_ADMIN_ROLE,
|
||||||
user,
|
user,
|
||||||
project: project1,
|
project: project1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -32,15 +32,20 @@ describe('userHasScopes', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
innerJoin: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
groupBy: jest.fn().mockReturnThis(),
|
||||||
|
having: jest.fn().mockReturnThis(),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
getRawMany: jest.fn().mockResolvedValue([{ id: 'projectId' }]),
|
||||||
|
};
|
||||||
|
|
||||||
Container.set(
|
Container.set(
|
||||||
ProjectRepository,
|
ProjectRepository,
|
||||||
mock<ProjectRepository>({
|
mock<ProjectRepository>({
|
||||||
find: jest.fn().mockResolvedValue([
|
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||||
{
|
|
||||||
id: 'projectId',
|
|
||||||
projectRelations: [{ userId: 'userId', role: 'project:admin' }],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type { User } from '@n8n/db';
|
|||||||
import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
|
import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions';
|
import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
|
||||||
import { In } from '@n8n/typeorm';
|
|
||||||
import { UnexpectedError } from 'n8n-workflow';
|
import { UnexpectedError } from 'n8n-workflow';
|
||||||
|
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
@@ -30,21 +28,21 @@ export async function userHasScopes(
|
|||||||
|
|
||||||
if (globalOnly) return false;
|
if (globalOnly) return false;
|
||||||
|
|
||||||
// Find which project roles are defined to contain the required scopes.
|
// Find which projects the user has access to with the required scopes.
|
||||||
// Then find projects having this user and having those project roles.
|
// This is done by finding the projects where the user has a role with at least the required scopes
|
||||||
|
|
||||||
const projectRoles = rolesWithScope('project', scopes);
|
|
||||||
const userProjectIds = (
|
const userProjectIds = (
|
||||||
await Container.get(ProjectRepository).find({
|
await Container.get(ProjectRepository)
|
||||||
where: {
|
.createQueryBuilder('project')
|
||||||
projectRelations: {
|
.innerJoin('project.projectRelations', 'relation')
|
||||||
userId: user.id,
|
.innerJoin('relation.role', 'role')
|
||||||
role: In(projectRoles),
|
.innerJoin('role.scopes', 'scope')
|
||||||
},
|
.where('relation.userId = :userId', { userId: user.id })
|
||||||
},
|
.andWhere('scope.slug IN (:...scopes)', { scopes })
|
||||||
select: ['id'],
|
.groupBy('project.id')
|
||||||
})
|
.having('COUNT(DISTINCT scope.slug) = :scopeCount', { scopeCount: scopes.length })
|
||||||
).map((p) => p.id);
|
.select(['project.id AS id'])
|
||||||
|
.getRawMany()
|
||||||
|
).map((row: { id: string }) => row.id);
|
||||||
|
|
||||||
// Find which resource roles are defined to contain the required scopes.
|
// Find which resource roles are defined to contain the required scopes.
|
||||||
// Then find at least one of the above qualifying projects having one of
|
// Then find at least one of the above qualifying projects having one of
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { Scope, WorkflowSharingRole } from '@n8n/permissions';
|
import { PROJECT_OWNER_ROLE_SLUG, type Scope, type WorkflowSharingRole } from '@n8n/permissions';
|
||||||
import type { WorkflowId } from 'n8n-workflow';
|
import type { WorkflowId } from 'n8n-workflow';
|
||||||
|
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
@@ -32,7 +32,7 @@ export async function getSharedWorkflowIds(
|
|||||||
} else {
|
} else {
|
||||||
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
|
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
|
||||||
workflowRoles: ['workflow:owner'],
|
workflowRoles: ['workflow:owner'],
|
||||||
projectRoles: ['project:personalOwner'],
|
projectRoles: [PROJECT_OWNER_ROLE_SLUG],
|
||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import type {
|
|||||||
ListQueryDb,
|
ListQueryDb,
|
||||||
WorkflowHistory,
|
WorkflowHistory,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import type { AssignableGlobalRole, ProjectRole, Scope } from '@n8n/permissions';
|
import type {
|
||||||
|
AssignableGlobalRole,
|
||||||
|
CustomRole,
|
||||||
|
GlobalRole,
|
||||||
|
ProjectRole,
|
||||||
|
Scope,
|
||||||
|
} from '@n8n/permissions';
|
||||||
import type {
|
import type {
|
||||||
ICredentialDataDecryptedObject,
|
ICredentialDataDecryptedObject,
|
||||||
INodeCredentialTestRequest,
|
INodeCredentialTestRequest,
|
||||||
@@ -268,14 +274,16 @@ export declare namespace ActiveWorkflowRequest {
|
|||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
export declare namespace ProjectRequest {
|
export declare namespace ProjectRequest {
|
||||||
type GetMyProjectsResponse = Array<Project & { role: string; scopes?: Scope[] }>;
|
type GetMyProjectsResponse = Array<
|
||||||
|
Project & { role: ProjectRole | GlobalRole | CustomRole; scopes?: Scope[] }
|
||||||
|
>;
|
||||||
|
|
||||||
type ProjectRelationResponse = {
|
type ProjectRelationResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: ProjectRole;
|
role: ProjectRole | CustomRole;
|
||||||
};
|
};
|
||||||
type ProjectWithRelations = {
|
type ProjectWithRelations = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db';
|
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db';
|
||||||
import type { CredentialsEntity, User } from '@n8n/db';
|
import type { CredentialsEntity, User } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import {
|
||||||
|
PROJECT_ADMIN_ROLE_SLUG,
|
||||||
|
PROJECT_EDITOR_ROLE_SLUG,
|
||||||
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
|
PROJECT_VIEWER_ROLE_SLUG,
|
||||||
|
} from '@n8n/permissions';
|
||||||
import { In } from '@n8n/typeorm';
|
import { In } from '@n8n/typeorm';
|
||||||
|
import { mockEntityManager } from '@test/mocking';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||||
import { mockEntityManager } from '@test/mocking';
|
|
||||||
|
|
||||||
describe('CredentialsFinderService', () => {
|
describe('CredentialsFinderService', () => {
|
||||||
const entityManager = mockEntityManager(SharedCredentials);
|
const entityManager = mockEntityManager(SharedCredentials);
|
||||||
@@ -56,10 +62,10 @@ describe('CredentialsFinderService', () => {
|
|||||||
project: {
|
project: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: In([
|
role: In([
|
||||||
'project:admin',
|
PROJECT_ADMIN_ROLE_SLUG,
|
||||||
'project:personalOwner',
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
'project:editor',
|
PROJECT_EDITOR_ROLE_SLUG,
|
||||||
'project:viewer',
|
PROJECT_VIEWER_ROLE_SLUG,
|
||||||
]),
|
]),
|
||||||
userId: member.id,
|
userId: member.id,
|
||||||
},
|
},
|
||||||
@@ -84,10 +90,10 @@ describe('CredentialsFinderService', () => {
|
|||||||
project: {
|
project: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
role: In([
|
role: In([
|
||||||
'project:admin',
|
PROJECT_ADMIN_ROLE_SLUG,
|
||||||
'project:personalOwner',
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
'project:editor',
|
PROJECT_EDITOR_ROLE_SLUG,
|
||||||
'project:viewer',
|
PROJECT_VIEWER_ROLE_SLUG,
|
||||||
]),
|
]),
|
||||||
userId: member.id,
|
userId: member.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
SharedWorkflowRepository,
|
SharedWorkflowRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
GLOBAL_OWNER_ROLE,
|
GLOBAL_OWNER_ROLE,
|
||||||
|
PROJECT_OWNER_ROLE,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
@@ -64,7 +66,7 @@ describe('OwnershipService', () => {
|
|||||||
const owner = new User();
|
const owner = new User();
|
||||||
owner.role = GLOBAL_OWNER_ROLE;
|
owner.role = GLOBAL_OWNER_ROLE;
|
||||||
const projectRelation = new ProjectRelation();
|
const projectRelation = new ProjectRelation();
|
||||||
projectRelation.role = 'project:personalOwner';
|
projectRelation.role = PROJECT_OWNER_ROLE;
|
||||||
(projectRelation.project = project), (projectRelation.user = owner);
|
(projectRelation.project = project), (projectRelation.user = owner);
|
||||||
|
|
||||||
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
|
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
|
||||||
@@ -92,7 +94,7 @@ describe('OwnershipService', () => {
|
|||||||
owner.id = uuid();
|
owner.id = uuid();
|
||||||
owner.role = GLOBAL_OWNER_ROLE;
|
owner.role = GLOBAL_OWNER_ROLE;
|
||||||
const projectRelation = new ProjectRelation();
|
const projectRelation = new ProjectRelation();
|
||||||
projectRelation.role = 'project:personalOwner';
|
projectRelation.role = { slug: PROJECT_OWNER_ROLE_SLUG } as any;
|
||||||
(projectRelation.project = project), (projectRelation.user = owner);
|
(projectRelation.project = project), (projectRelation.user = owner);
|
||||||
|
|
||||||
cacheService.getHashValue.mockResolvedValueOnce(owner);
|
cacheService.getHashValue.mockResolvedValueOnce(owner);
|
||||||
@@ -116,7 +118,7 @@ describe('OwnershipService', () => {
|
|||||||
mockOwner.role = GLOBAL_OWNER_ROLE;
|
mockOwner.role = GLOBAL_OWNER_ROLE;
|
||||||
|
|
||||||
const projectRelation = Object.assign(new ProjectRelation(), {
|
const projectRelation = Object.assign(new ProjectRelation(), {
|
||||||
role: 'project:personalOwner',
|
role: PROJECT_OWNER_ROLE_SLUG,
|
||||||
project: mockProject,
|
project: mockProject,
|
||||||
user: mockOwner,
|
user: mockOwner,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { ProjectRelation } from '@n8n/api-types';
|
import type { ProjectRelation } from '@n8n/api-types';
|
||||||
import type { DatabaseConfig } from '@n8n/config';
|
import type { DatabaseConfig } from '@n8n/config';
|
||||||
import type {
|
import {
|
||||||
Project,
|
type Project,
|
||||||
ProjectRepository,
|
type ProjectRepository,
|
||||||
SharedCredentialsRepository,
|
type SharedCredentialsRepository,
|
||||||
ProjectRelationRepository,
|
type ProjectRelationRepository,
|
||||||
SharedCredentials,
|
type SharedCredentials,
|
||||||
|
PROJECT_ADMIN_ROLE,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import type { EntityManager } from '@n8n/typeorm';
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ describe('ProjectService', () => {
|
|||||||
// ACT & ASSERT
|
// ACT & ASSERT
|
||||||
await expect(
|
await expect(
|
||||||
projectService.addUsersToProject(projectId, [
|
projectService.addUsersToProject(projectId, [
|
||||||
{ userId: '1234', role: 'project:personalOwner' },
|
{ userId: '1234', role: PROJECT_OWNER_ROLE_SLUG },
|
||||||
]),
|
]),
|
||||||
).rejects.toThrowError("Can't add a personalOwner to a team project.");
|
).rejects.toThrowError("Can't add a personalOwner to a team project.");
|
||||||
});
|
});
|
||||||
@@ -100,7 +102,7 @@ describe('ProjectService', () => {
|
|||||||
|
|
||||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||||
where: { id: projectId, type: 'team' },
|
where: { id: projectId, type: 'team' },
|
||||||
relations: { projectRelations: true },
|
relations: { projectRelations: { role: true } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(manager.delete).toHaveBeenCalled();
|
expect(manager.delete).toHaveBeenCalled();
|
||||||
@@ -139,7 +141,7 @@ describe('ProjectService', () => {
|
|||||||
mock<Project>({
|
mock<Project>({
|
||||||
id: projectId,
|
id: projectId,
|
||||||
type: 'team',
|
type: 'team',
|
||||||
projectRelations: [{ userId: 'user1', role: 'project:admin' }],
|
projectRelations: [{ userId: 'user1', role: PROJECT_ADMIN_ROLE }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
roleService.isRoleLicensed.mockReturnValue(false);
|
roleService.isRoleLicensed.mockReturnValue(false);
|
||||||
@@ -156,9 +158,9 @@ describe('ProjectService', () => {
|
|||||||
|
|
||||||
describe('changeUserRoleInProject', () => {
|
describe('changeUserRoleInProject', () => {
|
||||||
const projectId = '12345';
|
const projectId = '12345';
|
||||||
const mockRelations: ProjectRelation[] = [
|
const mockRelations = [
|
||||||
{ userId: 'user1', role: 'project:admin' },
|
{ userId: 'user1', role: { slug: 'project:admin' } },
|
||||||
{ userId: 'user2', role: 'project:viewer' },
|
{ userId: 'user2', role: { slug: 'project:viewer' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -185,12 +187,12 @@ describe('ProjectService', () => {
|
|||||||
|
|
||||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||||
where: { id: projectId, type: 'team' },
|
where: { id: projectId, type: 'team' },
|
||||||
relations: { projectRelations: true },
|
relations: { projectRelations: { role: true } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(projectRelationRepository.update).toHaveBeenCalledWith(
|
expect(projectRelationRepository.update).toHaveBeenCalledWith(
|
||||||
{ projectId, userId: 'user2' },
|
{ projectId, userId: 'user2' },
|
||||||
{ role: 'project:admin' },
|
{ role: { slug: 'project:admin' } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,13 +212,13 @@ describe('ProjectService', () => {
|
|||||||
|
|
||||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||||
where: { id: projectId, type: 'team' },
|
where: { id: projectId, type: 'team' },
|
||||||
relations: { projectRelations: true },
|
relations: { projectRelations: { role: true } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if the role to be set is `project:personalOwner`', async () => {
|
it('should throw if the role to be set is `project:personalOwner`', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
projectService.changeUserRoleInProject(projectId, 'user2', 'project:personalOwner'),
|
projectService.changeUserRoleInProject(projectId, 'user2', PROJECT_OWNER_ROLE_SLUG),
|
||||||
).rejects.toThrow('Personal owner cannot be added to a team project.');
|
).rejects.toThrow('Personal owner cannot be added to a team project.');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,7 +232,7 @@ describe('ProjectService', () => {
|
|||||||
|
|
||||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||||
where: { id: projectId, type: 'team' },
|
where: { id: projectId, type: 'team' },
|
||||||
relations: { projectRelations: true },
|
relations: { projectRelations: { role: true } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import {
|
|||||||
SharedWorkflowRepository,
|
SharedWorkflowRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container, Service } from '@n8n/di';
|
import { Container, Service } from '@n8n/di';
|
||||||
import { hasGlobalScope, rolesWithScope, type Scope, type ProjectRole } from '@n8n/permissions';
|
import {
|
||||||
|
hasGlobalScope,
|
||||||
|
rolesWithScope,
|
||||||
|
type Scope,
|
||||||
|
type ProjectRole,
|
||||||
|
CustomRole,
|
||||||
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
|
PROJECT_ADMIN_ROLE_SLUG,
|
||||||
|
} from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
|
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
@@ -26,8 +34,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
|||||||
import { CacheService } from './cache/cache.service';
|
import { CacheService } from './cache/cache.service';
|
||||||
import { RoleService } from './role.service';
|
import { RoleService } from './role.service';
|
||||||
|
|
||||||
type Relation = Pick<ProjectRelation, 'userId' | 'role'>;
|
|
||||||
|
|
||||||
export class TeamProjectOverQuotaError extends UserError {
|
export class TeamProjectOverQuotaError extends UserError {
|
||||||
constructor(limit: number) {
|
constructor(limit: number) {
|
||||||
super(
|
super(
|
||||||
@@ -37,7 +43,7 @@ export class TeamProjectOverQuotaError extends UserError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UnlicensedProjectRoleError extends UserError {
|
export class UnlicensedProjectRoleError extends UserError {
|
||||||
constructor(role: ProjectRole) {
|
constructor(role: ProjectRole | CustomRole) {
|
||||||
super(`Your instance is not licensed to use role "${role}".`);
|
super(`Your instance is not licensed to use role "${role}".`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,14 +77,12 @@ export class ProjectService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get workflowService() {
|
private get workflowService() {
|
||||||
// eslint-disable-next-line import-x/no-cycle
|
|
||||||
return import('@/workflows/workflow.service').then(({ WorkflowService }) =>
|
return import('@/workflows/workflow.service').then(({ WorkflowService }) =>
|
||||||
Container.get(WorkflowService),
|
Container.get(WorkflowService),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get credentialsService() {
|
private get credentialsService() {
|
||||||
// eslint-disable-next-line import-x/no-cycle
|
|
||||||
return import('@/credentials/credentials.service').then(({ CredentialsService }) =>
|
return import('@/credentials/credentials.service').then(({ CredentialsService }) =>
|
||||||
Container.get(CredentialsService),
|
Container.get(CredentialsService),
|
||||||
);
|
);
|
||||||
@@ -262,7 +266,7 @@ export class ProjectService {
|
|||||||
async getProjectRelationsForUser(user: User): Promise<ProjectRelation[]> {
|
async getProjectRelationsForUser(user: User): Promise<ProjectRelation[]> {
|
||||||
return await this.projectRelationRepository.find({
|
return await this.projectRelationRepository.find({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
relations: ['project'],
|
relations: ['project', 'role'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +296,10 @@ export class ProjectService {
|
|||||||
* Throws if you the project is a personal project.
|
* Throws if you the project is a personal project.
|
||||||
* Throws if the relations contain `project:personalOwner`.
|
* Throws if the relations contain `project:personalOwner`.
|
||||||
*/
|
*/
|
||||||
async addUsersToProject(projectId: string, relations: Relation[]) {
|
async addUsersToProject(
|
||||||
|
projectId: string,
|
||||||
|
relations: Array<{ userId: string; role: ProjectRole | CustomRole }>,
|
||||||
|
) {
|
||||||
const project = await this.getTeamProjectWithRelations(projectId);
|
const project = await this.getTeamProjectWithRelations(projectId);
|
||||||
this.checkRolesLicensed(project, relations);
|
this.checkRolesLicensed(project, relations);
|
||||||
|
|
||||||
@@ -300,31 +307,38 @@ export class ProjectService {
|
|||||||
throw new ForbiddenError("Can't add users to personal projects.");
|
throw new ForbiddenError("Can't add users to personal projects.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relations.some((r) => r.role === 'project:personalOwner')) {
|
if (relations.some((r) => r.role === PROJECT_OWNER_ROLE_SLUG)) {
|
||||||
throw new ForbiddenError("Can't add a personalOwner to a team project.");
|
throw new ForbiddenError("Can't add a personalOwner to a team project.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.projectRelationRepository.save(
|
await this.projectRelationRepository.save(
|
||||||
relations.map((relation) => ({ projectId, ...relation })),
|
relations.map((relation) => ({
|
||||||
|
projectId,
|
||||||
|
userId: relation.userId,
|
||||||
|
role: { slug: relation.role },
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTeamProjectWithRelations(projectId: string) {
|
private async getTeamProjectWithRelations(projectId: string) {
|
||||||
const project = await this.projectRepository.findOne({
|
const project = await this.projectRepository.findOne({
|
||||||
where: { id: projectId, type: 'team' },
|
where: { id: projectId, type: 'team' },
|
||||||
relations: { projectRelations: true },
|
relations: { projectRelations: { role: true } },
|
||||||
});
|
});
|
||||||
ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
|
ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check to see if the instance is licensed to use all roles provided */
|
/** Check to see if the instance is licensed to use all roles provided */
|
||||||
private checkRolesLicensed(project: Project, relations: Relation[]) {
|
private checkRolesLicensed(
|
||||||
|
project: Project,
|
||||||
|
relations: Array<{ role: ProjectRole | CustomRole; userId: string }>,
|
||||||
|
) {
|
||||||
for (const { role, userId } of relations) {
|
for (const { role, userId } of relations) {
|
||||||
const existing = project.projectRelations.find((pr) => pr.userId === userId);
|
const existing = project.projectRelations.find((pr) => pr.userId === userId);
|
||||||
// We don't throw an error if the user already exists with that role so
|
// We don't throw an error if the user already exists with that role so
|
||||||
// existing projects continue working as is.
|
// existing projects continue working as is.
|
||||||
if (existing?.role !== role && !this.roleService.isRoleLicensed(role)) {
|
if (existing?.role?.slug !== role && !this.roleService.isRoleLicensed(role)) {
|
||||||
throw new UnlicensedProjectRoleError(role);
|
throw new UnlicensedProjectRoleError(role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,7 +346,7 @@ export class ProjectService {
|
|||||||
|
|
||||||
private isUserProjectOwner(project: Project, userId: string) {
|
private isUserProjectOwner(project: Project, userId: string) {
|
||||||
return project.projectRelations.some(
|
return project.projectRelations.some(
|
||||||
(pr) => pr.userId === userId && pr.role === 'project:personalOwner',
|
(pr) => pr.userId === userId && pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +362,7 @@ export class ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async changeUserRoleInProject(projectId: string, userId: string, role: ProjectRole) {
|
async changeUserRoleInProject(projectId: string, userId: string, role: ProjectRole) {
|
||||||
if (role === 'project:personalOwner') {
|
if (role === PROJECT_OWNER_ROLE_SLUG) {
|
||||||
throw new ForbiddenError('Personal owner cannot be added to a team project.');
|
throw new ForbiddenError('Personal owner cannot be added to a team project.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +374,7 @@ export class ProjectService {
|
|||||||
throw new ProjectNotFoundError(projectId);
|
throw new ProjectNotFoundError(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.projectRelationRepository.update({ projectId, userId }, { role });
|
await this.projectRelationRepository.update({ projectId, userId }, { role: { slug: role } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearCredentialCanUseExternalSecretsCache(projectId: string) {
|
async clearCredentialCanUseExternalSecretsCache(projectId: string) {
|
||||||
@@ -385,7 +399,7 @@ export class ProjectService {
|
|||||||
async addManyRelations(
|
async addManyRelations(
|
||||||
em: EntityManager,
|
em: EntityManager,
|
||||||
project: Project,
|
project: Project,
|
||||||
relations: Array<{ userId: string; role: ProjectRole }>,
|
relations: Array<{ userId: string; role: ProjectRole | CustomRole }>,
|
||||||
) {
|
) {
|
||||||
await em.insert(
|
await em.insert(
|
||||||
ProjectRelation,
|
ProjectRelation,
|
||||||
@@ -394,7 +408,7 @@ export class ProjectService {
|
|||||||
this.projectRelationRepository.create({
|
this.projectRelationRepository.create({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
userId: v.userId,
|
userId: v.userId,
|
||||||
role: v.role,
|
role: { slug: v.role },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -434,12 +448,16 @@ export class ProjectService {
|
|||||||
* Throws if you the project is a personal project.
|
* Throws if you the project is a personal project.
|
||||||
* Throws if the relations contain `project:personalOwner`.
|
* Throws if the relations contain `project:personalOwner`.
|
||||||
*/
|
*/
|
||||||
async addUser(projectId: string, { userId, role }: Relation, trx?: EntityManager) {
|
async addUser(
|
||||||
|
projectId: string,
|
||||||
|
{ userId, role }: { userId: string; role: ProjectRole | CustomRole },
|
||||||
|
trx?: EntityManager,
|
||||||
|
) {
|
||||||
trx = trx ?? this.projectRelationRepository.manager;
|
trx = trx ?? this.projectRelationRepository.manager;
|
||||||
return await trx.save(ProjectRelation, {
|
return await trx.save(ProjectRelation, {
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
role,
|
role: { slug: role },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +472,7 @@ export class ProjectService {
|
|||||||
async getProjectRelations(projectId: string): Promise<ProjectRelation[]> {
|
async getProjectRelations(projectId: string): Promise<ProjectRelation[]> {
|
||||||
return await this.projectRelationRepository.find({
|
return await this.projectRelationRepository.find({
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
relations: { user: true },
|
relations: { user: true, role: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +481,7 @@ export class ProjectService {
|
|||||||
where: {
|
where: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
userId,
|
userId,
|
||||||
role: In(['project:personalOwner', 'project:admin']),
|
role: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
ProjectRelation,
|
ProjectRelation,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import type { AllRoleTypes, Scope } from '@n8n/permissions';
|
import type { CustomRole, ProjectRole, Scope } from '@n8n/permissions';
|
||||||
import { ALL_ROLES, combineScopes, getAuthPrincipalScopes, getRoleScopes } from '@n8n/permissions';
|
import { ALL_ROLES, combineScopes, getAuthPrincipalScopes, getRoleScopes } from '@n8n/permissions';
|
||||||
import { UnexpectedError } from 'n8n-workflow';
|
import { UnexpectedError } from 'n8n-workflow';
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ export class RoleService {
|
|||||||
);
|
);
|
||||||
let projectScopes: Scope[] = [];
|
let projectScopes: Scope[] = [];
|
||||||
if (pr) {
|
if (pr) {
|
||||||
projectScopes = getRoleScopes(pr.role);
|
projectScopes = pr.role.scopes.map((s) => s.slug);
|
||||||
}
|
}
|
||||||
const resourceMask = getRoleScopes(sharedEntity.role);
|
const resourceMask = getRoleScopes(sharedEntity.role);
|
||||||
const mergedScopes = combineScopes(
|
const mergedScopes = combineScopes(
|
||||||
@@ -112,7 +112,7 @@ export class RoleService {
|
|||||||
return [...scopesSet].sort();
|
return [...scopesSet].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
isRoleLicensed(role: AllRoleTypes) {
|
isRoleLicensed(role: ProjectRole | CustomRole) {
|
||||||
// TODO: move this info into FrontendSettings
|
// TODO: move this info into FrontendSettings
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'project:admin':
|
case 'project:admin':
|
||||||
@@ -124,6 +124,7 @@ export class RoleService {
|
|||||||
case 'global:admin':
|
case 'global:admin':
|
||||||
return this.license.isAdvancedPermissionsLicensed();
|
return this.license.isAdvancedPermissionsLicensed();
|
||||||
default:
|
default:
|
||||||
|
// TODO: handle custom roles licensing
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ProjectRole,
|
type ProjectRole,
|
||||||
type WorkflowSharingRole,
|
type WorkflowSharingRole,
|
||||||
type Scope,
|
type Scope,
|
||||||
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
} from '@n8n/permissions';
|
} from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In } from '@n8n/typeorm';
|
import { In } from '@n8n/typeorm';
|
||||||
@@ -74,7 +75,7 @@ export class WorkflowSharingService {
|
|||||||
project: {
|
project: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -115,7 +116,7 @@ export class WorkflowSharingService {
|
|||||||
project: {
|
project: {
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import omit from 'lodash/omit';
|
|||||||
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
|
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
|
||||||
import { NodeOperationError, PROJECT_ROOT, UserError, WorkflowActivationError } from 'n8n-workflow';
|
import { NodeOperationError, PROJECT_ROOT, UserError, WorkflowActivationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { WorkflowFinderService } from './workflow-finder.service';
|
||||||
|
|
||||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||||
import { CredentialsService } from '@/credentials/credentials.service';
|
import { CredentialsService } from '@/credentials/credentials.service';
|
||||||
@@ -34,8 +36,6 @@ import { FolderService } from '@/services/folder.service';
|
|||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { ProjectService } from '@/services/project.service.ee';
|
import { ProjectService } from '@/services/project.service.ee';
|
||||||
|
|
||||||
import { WorkflowFinderService } from './workflow-finder.service';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class EnterpriseWorkflowService {
|
export class EnterpriseWorkflowService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -74,7 +74,7 @@ export class EnterpriseWorkflowService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const newSharedWorkflows = projects
|
const newSharedWorkflows = projects
|
||||||
// We filter by role === 'project:personalOwner' above and there should
|
// We filter by role === PROJECT_OWNER_ROLE_SLUG above and there should
|
||||||
// always only be one owner.
|
// always only be one owner.
|
||||||
.map((project) =>
|
.map((project) =>
|
||||||
this.sharedWorkflowRepository.create({
|
this.sharedWorkflowRepository.create({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
RestController,
|
RestController,
|
||||||
} from '@n8n/decorators';
|
} from '@n8n/decorators';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In, type FindOptionsRelations } from '@n8n/typeorm';
|
import { In, type FindOptionsRelations } from '@n8n/typeorm';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -37,6 +38,14 @@ import express from 'express';
|
|||||||
import { UnexpectedError } from 'n8n-workflow';
|
import { UnexpectedError } from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
import { WorkflowExecutionService } from './workflow-execution.service';
|
||||||
|
import { WorkflowFinderService } from './workflow-finder.service';
|
||||||
|
import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee';
|
||||||
|
import { WorkflowRequest } from './workflow.request';
|
||||||
|
import { WorkflowService } from './workflow.service';
|
||||||
|
import { EnterpriseWorkflowService } from './workflow.service.ee';
|
||||||
|
import { CredentialsService } from '../credentials/credentials.service';
|
||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
@@ -56,14 +65,6 @@ import { UserManagementMailer } from '@/user-management/email';
|
|||||||
import * as utils from '@/utils';
|
import * as utils from '@/utils';
|
||||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
|
|
||||||
import { WorkflowExecutionService } from './workflow-execution.service';
|
|
||||||
import { WorkflowFinderService } from './workflow-finder.service';
|
|
||||||
import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee';
|
|
||||||
import { WorkflowRequest } from './workflow.request';
|
|
||||||
import { WorkflowService } from './workflow.service';
|
|
||||||
import { EnterpriseWorkflowService } from './workflow.service.ee';
|
|
||||||
import { CredentialsService } from '../credentials/credentials.service';
|
|
||||||
|
|
||||||
@RestController('/workflows')
|
@RestController('/workflows')
|
||||||
export class WorkflowsController {
|
export class WorkflowsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -535,7 +536,7 @@ export class WorkflowsController {
|
|||||||
|
|
||||||
const projectsRelations = await this.projectRelationRepository.findBy({
|
const projectsRelations = await this.projectRelationRepository.findBy({
|
||||||
projectId: In(newShareeIds),
|
projectId: In(newShareeIds),
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.mailer.notifyWorkflowShared({
|
await this.mailer.notifyWorkflowShared({
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ describe('Member', () => {
|
|||||||
.post('/api-keys')
|
.post('/api-keys')
|
||||||
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
|
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
|
||||||
|
|
||||||
console.log(newApiKeyResponse.body);
|
|
||||||
expect(newApiKeyResponse.statusCode).toBe(200);
|
expect(newApiKeyResponse.statusCode).toBe(200);
|
||||||
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
|
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
|
||||||
expect(newApiKeyResponse.body.data.apiKey).not.toBeNull();
|
expect(newApiKeyResponse.body.data.apiKey).not.toBeNull();
|
||||||
|
|||||||
@@ -13,13 +13,9 @@ import {
|
|||||||
UserRepository,
|
UserRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import { Not } from '@n8n/typeorm';
|
import { Not } from '@n8n/typeorm';
|
||||||
|
|
||||||
import { EventService } from '@/events/event.service';
|
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
|
||||||
import { PasswordUtility } from '@/services/password.utility';
|
|
||||||
import { UserManagementMailer } from '@/user-management/email';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertReturnedUserProps,
|
assertReturnedUserProps,
|
||||||
assertStoredUserProps,
|
assertStoredUserProps,
|
||||||
@@ -29,6 +25,11 @@ import { createMember, createOwner, createUserShell } from '../../shared/db/user
|
|||||||
import * as utils from '../../shared/utils';
|
import * as utils from '../../shared/utils';
|
||||||
import type { UserInvitationResult } from '../../shared/utils/users';
|
import type { UserInvitationResult } from '../../shared/utils/users';
|
||||||
|
|
||||||
|
import { EventService } from '@/events/event.service';
|
||||||
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
|
import { PasswordUtility } from '@/services/password.utility';
|
||||||
|
import { UserManagementMailer } from '@/user-management/email';
|
||||||
|
|
||||||
describe('InvitationController', () => {
|
describe('InvitationController', () => {
|
||||||
const mailer = mockInstance(UserManagementMailer);
|
const mailer = mockInstance(UserManagementMailer);
|
||||||
const externalHooks = mockInstance(ExternalHooks);
|
const externalHooks = mockInstance(ExternalHooks);
|
||||||
@@ -296,7 +297,7 @@ describe('InvitationController', () => {
|
|||||||
const projectRelation = await projectRelationRepository.findOneOrFail({
|
const projectRelation = await projectRelationRepository.findOneOrFail({
|
||||||
where: {
|
where: {
|
||||||
userId: storedUser.id,
|
userId: storedUser.id,
|
||||||
role: 'project:personalOwner',
|
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||||
project: {
|
project: {
|
||||||
type: 'personal',
|
type: 'personal',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import {
|
|||||||
SharedWorkflowRepository,
|
SharedWorkflowRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { getRoleScopes, type GlobalRole, type ProjectRole, type Scope } from '@n8n/permissions';
|
import {
|
||||||
|
getRoleScopes,
|
||||||
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
|
type GlobalRole,
|
||||||
|
type ProjectRole,
|
||||||
|
type Scope,
|
||||||
|
} from '@n8n/permissions';
|
||||||
import { EntityNotFoundError } from '@n8n/typeorm';
|
import { EntityNotFoundError } from '@n8n/typeorm';
|
||||||
|
|
||||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
|
||||||
import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service';
|
|
||||||
import { CacheService } from '@/services/cache/cache.service';
|
|
||||||
import { createFolder } from '@test-integration/db/folders';
|
import { createFolder } from '@test-integration/db/folders';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +38,10 @@ import {
|
|||||||
import { createMember, createOwner, createUser } from './shared/db/users';
|
import { createMember, createOwner, createUser } from './shared/db/users';
|
||||||
import * as utils from './shared/utils/';
|
import * as utils from './shared/utils/';
|
||||||
|
|
||||||
|
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
|
import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service';
|
||||||
|
import { CacheService } from '@/services/cache/cache.service';
|
||||||
|
|
||||||
const testServer = utils.setupTestServer({
|
const testServer = utils.setupTestServer({
|
||||||
endpointGroups: ['project'],
|
endpointGroups: ['project'],
|
||||||
enabledFeatures: [
|
enabledFeatures: [
|
||||||
@@ -193,7 +199,7 @@ describe('GET /projects/my-projects', () => {
|
|||||||
[
|
[
|
||||||
personalProject1,
|
personalProject1,
|
||||||
{
|
{
|
||||||
role: 'project:personalOwner',
|
role: PROJECT_OWNER_ROLE_SLUG,
|
||||||
scopes: ['project:list', 'project:read', 'credential:create'],
|
scopes: ['project:list', 'project:read', 'credential:create'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -270,7 +276,7 @@ describe('GET /projects/my-projects', () => {
|
|||||||
[
|
[
|
||||||
ownerProject,
|
ownerProject,
|
||||||
{
|
{
|
||||||
role: 'project:personalOwner',
|
role: PROJECT_OWNER_ROLE_SLUG,
|
||||||
scopes: [
|
scopes: [
|
||||||
'project:list',
|
'project:list',
|
||||||
'project:create',
|
'project:create',
|
||||||
@@ -596,15 +602,15 @@ describe('PATCH /projects/:projectId', () => {
|
|||||||
|
|
||||||
expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
|
expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
|
||||||
expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined();
|
expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined();
|
||||||
expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin');
|
expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role.slug).toBe('project:admin');
|
||||||
expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor');
|
expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role.slug).toBe('project:editor');
|
||||||
expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer');
|
expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:viewer');
|
||||||
|
|
||||||
// Check we haven't modified the other team project
|
// Check we haven't modified the other team project
|
||||||
expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
|
expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
|
||||||
expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined();
|
expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined();
|
||||||
expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor');
|
expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role.slug).toBe('project:editor');
|
||||||
expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor');
|
expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:editor');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([['project:viewer'], ['project:editor']] as const)(
|
test.each([['project:viewer'], ['project:editor']] as const)(
|
||||||
@@ -656,8 +662,14 @@ describe('PATCH /projects/:projectId', () => {
|
|||||||
expect(tp1Relations.length).toBe(2);
|
expect(tp1Relations.length).toBe(2);
|
||||||
expect(tp1Relations).toMatchObject(
|
expect(tp1Relations).toMatchObject(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ userId: actor.id, role }),
|
expect.objectContaining({
|
||||||
expect.objectContaining({ userId: projectEditor.id, role: 'project:editor' }),
|
userId: actor.id,
|
||||||
|
role: expect.objectContaining({ slug: role }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: projectEditor.id,
|
||||||
|
role: expect.objectContaining({ slug: 'project:editor' }),
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -692,7 +704,10 @@ describe('PATCH /projects/:projectId', () => {
|
|||||||
expect(tpRelations.length).toBe(1);
|
expect(tpRelations.length).toBe(1);
|
||||||
expect(tpRelations).toMatchObject(
|
expect(tpRelations).toMatchObject(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ userId: projectAdmin.id, role: 'project:admin' }),
|
expect.objectContaining({
|
||||||
|
userId: projectAdmin.id,
|
||||||
|
role: expect.objectContaining({ slug: 'project:admin' }),
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -730,9 +745,9 @@ describe('PATCH /projects/:projectId', () => {
|
|||||||
|
|
||||||
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
|
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
|
||||||
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
|
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
|
||||||
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin');
|
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:admin');
|
||||||
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin');
|
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).toBe('project:admin');
|
||||||
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin');
|
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role?.slug).toBe('project:admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => {
|
test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => {
|
||||||
@@ -768,9 +783,9 @@ describe('PATCH /projects/:projectId', () => {
|
|||||||
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
|
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
|
||||||
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
|
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
|
||||||
expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined();
|
expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined();
|
||||||
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer');
|
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:viewer');
|
||||||
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin');
|
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).toBe('project:admin');
|
||||||
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin');
|
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role?.slug).toBe('project:admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not add or remove users from a personal project', async () => {
|
test('should not add or remove users from a personal project', async () => {
|
||||||
@@ -782,7 +797,7 @@ describe('PATCH /projects/:projectId', () => {
|
|||||||
|
|
||||||
const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({
|
const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({
|
||||||
relations: [
|
relations: [
|
||||||
{ userId: testUser1.id, role: 'project:personalOwner' },
|
{ userId: testUser1.id, role: PROJECT_OWNER_ROLE_SLUG },
|
||||||
{ userId: testUser2.id, role: 'project:admin' },
|
{ userId: testUser2.id, role: 'project:admin' },
|
||||||
] as Array<{
|
] as Array<{
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('ProjectService', () => {
|
|||||||
expect(relations[0]).toMatchObject({
|
expect(relations[0]).toMatchObject({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: 'project:admin',
|
role: { slug: 'project:admin' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ describe('ProjectService', () => {
|
|||||||
expect(relations[0]).toMatchObject({
|
expect(relations[0]).toMatchObject({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: 'project:editor',
|
role: { slug: 'project:editor' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -103,7 +103,7 @@ describe('ProjectService', () => {
|
|||||||
expect(relations[0]).toMatchObject({
|
expect(relations[0]).toMatchObject({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: 'project:admin',
|
role: { slug: 'project:admin' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -564,23 +564,20 @@ describe('Projects in Public API', () => {
|
|||||||
|
|
||||||
expect(projectAfter.length).toEqual(3);
|
expect(projectAfter.length).toEqual(3);
|
||||||
const adminRelation = projectAfter.find(
|
const adminRelation = projectAfter.find(
|
||||||
(relation) => relation.userId === owner.id && relation.role === 'project:admin',
|
(relation) => relation.userId === owner.id && relation.role.slug === 'project:admin',
|
||||||
);
|
|
||||||
expect(adminRelation).toEqual(
|
|
||||||
expect.objectContaining({ userId: owner.id, role: 'project:admin' }),
|
|
||||||
);
|
);
|
||||||
|
expect(adminRelation!.userId).toBe(owner.id);
|
||||||
|
expect(adminRelation!.role.slug).toBe('project:admin');
|
||||||
const viewerRelation = projectAfter.find(
|
const viewerRelation = projectAfter.find(
|
||||||
(relation) => relation.userId === member.id && relation.role === 'project:viewer',
|
(relation) => relation.userId === member.id && relation.role.slug === 'project:viewer',
|
||||||
);
|
|
||||||
expect(viewerRelation).toEqual(
|
|
||||||
expect.objectContaining({ userId: member.id, role: 'project:viewer' }),
|
|
||||||
);
|
);
|
||||||
|
expect(viewerRelation!.userId).toBe(member.id);
|
||||||
|
expect(viewerRelation!.role.slug).toBe('project:viewer');
|
||||||
const editorRelation = projectAfter.find(
|
const editorRelation = projectAfter.find(
|
||||||
(relation) => relation.userId === member2.id && relation.role === 'project:editor',
|
(relation) => relation.userId === member2.id && relation.role.slug === 'project:editor',
|
||||||
);
|
|
||||||
expect(editorRelation).toEqual(
|
|
||||||
expect.objectContaining({ userId: member2.id, role: 'project:editor' }),
|
|
||||||
);
|
);
|
||||||
|
expect(editorRelation!.userId).toBe(member2.id);
|
||||||
|
expect(editorRelation!.role.slug).toBe('project:editor');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject with 400 if license does not include user role', async () => {
|
it('should reject with 400 if license does not include user role', async () => {
|
||||||
@@ -797,8 +794,12 @@ describe('Projects in Public API', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(projectBefore.length).toEqual(2);
|
expect(projectBefore.length).toEqual(2);
|
||||||
expect(projectBefore.find((p) => p.role === 'project:admin')?.userId).toEqual(owner.id);
|
expect(projectBefore.find((p) => p.role.slug === 'project:admin')?.userId).toEqual(
|
||||||
expect(projectBefore.find((p) => p.role === 'project:viewer')?.userId).toEqual(member.id);
|
owner.id,
|
||||||
|
);
|
||||||
|
expect(projectBefore.find((p) => p.role.slug === 'project:viewer')?.userId).toEqual(
|
||||||
|
member.id,
|
||||||
|
);
|
||||||
|
|
||||||
expect(projectAfter.length).toEqual(1);
|
expect(projectAfter.length).toEqual(1);
|
||||||
expect(projectAfter[0].userId).toEqual(owner.id);
|
expect(projectAfter[0].userId).toEqual(owner.id);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getRoleScopes } from '@n8n/permissions';
|
import { getRoleScopes, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||||
import type {
|
import type {
|
||||||
GlobalRole,
|
GlobalRole,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
@@ -76,8 +76,8 @@ beforeAll(async () => {
|
|||||||
expectedProjectRoles = [
|
expectedProjectRoles = [
|
||||||
{
|
{
|
||||||
name: 'Project Owner',
|
name: 'Project Owner',
|
||||||
role: 'project:personalOwner',
|
role: PROJECT_OWNER_ROLE_SLUG,
|
||||||
scopes: getRoleScopes('project:personalOwner'),
|
scopes: getRoleScopes(PROJECT_OWNER_ROLE_SLUG),
|
||||||
licensed: true,
|
licensed: true,
|
||||||
description: 'Project Owner',
|
description: 'Project Owner',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { testDb } from '@n8n/backend-test-utils';
|
import { testDb } from '@n8n/backend-test-utils';
|
||||||
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { ProjectRole, Scope } from '@n8n/permissions';
|
import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions';
|
||||||
|
|
||||||
import { ProjectService } from '@/services/project.service.ee';
|
|
||||||
|
|
||||||
import { createMember } from '../shared/db/users';
|
import { createMember } from '../shared/db/users';
|
||||||
|
|
||||||
|
import { ProjectService } from '@/services/project.service.ee';
|
||||||
|
|
||||||
let projectRepository: ProjectRepository;
|
let projectRepository: ProjectRepository;
|
||||||
let projectService: ProjectService;
|
let projectService: ProjectService;
|
||||||
let projectRelationRepository: ProjectRelationRepository;
|
let projectRelationRepository: ProjectRelationRepository;
|
||||||
@@ -33,7 +33,7 @@ describe('ProjectService', () => {
|
|||||||
'project:viewer',
|
'project:viewer',
|
||||||
'project:admin',
|
'project:admin',
|
||||||
'project:editor',
|
'project:editor',
|
||||||
'project:personalOwner',
|
PROJECT_OWNER_ROLE_SLUG,
|
||||||
] as ProjectRole[])(
|
] as ProjectRole[])(
|
||||||
'creates a relation between the user and the project using the role %s',
|
'creates a relation between the user and the project using the role %s',
|
||||||
async (role) => {
|
async (role) => {
|
||||||
@@ -57,7 +57,7 @@ describe('ProjectService', () => {
|
|||||||
// ASSERT
|
// ASSERT
|
||||||
//
|
//
|
||||||
await projectRelationRepository.findOneOrFail({
|
await projectRelationRepository.findOneOrFail({
|
||||||
where: { userId: member.id, projectId: project.id, role },
|
where: { userId: member.id, projectId: project.id, role: { slug: role } },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -76,7 +76,7 @@ describe('ProjectService', () => {
|
|||||||
await projectService.addUser(project.id, { userId: member.id, role: 'project:viewer' });
|
await projectService.addUser(project.id, { userId: member.id, role: 'project:viewer' });
|
||||||
|
|
||||||
await projectRelationRepository.findOneOrFail({
|
await projectRelationRepository.findOneOrFail({
|
||||||
where: { userId: member.id, projectId: project.id, role: 'project:viewer' },
|
where: { userId: member.id, projectId: project.id, role: { slug: 'project:viewer' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -89,10 +89,11 @@ describe('ProjectService', () => {
|
|||||||
//
|
//
|
||||||
const relationships = await projectRelationRepository.find({
|
const relationships = await projectRelationRepository.find({
|
||||||
where: { userId: member.id, projectId: project.id },
|
where: { userId: member.id, projectId: project.id },
|
||||||
|
relations: { role: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(relationships).toHaveLength(1);
|
expect(relationships).toHaveLength(1);
|
||||||
expect(relationships[0]).toHaveProperty('role', 'project:admin');
|
expect(relationships[0]).toHaveProperty('role.slug', 'project:admin');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ describe('ProjectService', () => {
|
|||||||
|
|
||||||
describe('deleteUserFromProject', () => {
|
describe('deleteUserFromProject', () => {
|
||||||
it('should not allow project owner to be removed from the project', async () => {
|
it('should not allow project owner to be removed from the project', async () => {
|
||||||
const role = 'project:personalOwner';
|
const role = PROJECT_OWNER_ROLE_SLUG;
|
||||||
|
|
||||||
const user = await createMember();
|
const user = await createMember();
|
||||||
const project = await projectRepository.save(
|
const project = await projectRepository.save(
|
||||||
@@ -233,7 +234,7 @@ describe('ProjectService', () => {
|
|||||||
await projectService.deleteUserFromProject(project.id, user.id);
|
await projectService.deleteUserFromProject(project.id, user.id);
|
||||||
|
|
||||||
const relations = await projectRelationRepository.findOne({
|
const relations = await projectRelationRepository.findOne({
|
||||||
where: { userId: user.id, projectId: project.id, role },
|
where: { userId: user.id, projectId: project.id, role: { slug: role } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(relations).toBeNull();
|
expect(relations).toBeNull();
|
||||||
|
|||||||
@@ -1092,7 +1092,7 @@ describe('DELETE /users/:id', () => {
|
|||||||
id: teamProject.id,
|
id: teamProject.id,
|
||||||
projectRelations: {
|
projectRelations: {
|
||||||
userId: transferee.id,
|
userId: transferee.id,
|
||||||
role: 'project:editor',
|
role: { slug: 'project:editor' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).resolves.not.toBeNull(),
|
).resolves.not.toBeNull(),
|
||||||
|
|||||||
Reference in New Issue
Block a user