feat(core): Rebuild project roles to load from the database (#17909)

This commit is contained in:
Guillaume Jacquart
2025-08-28 11:00:31 +02:00
committed by GitHub
parent ab7998b441
commit f757790394
63 changed files with 546 additions and 305 deletions

View File

@@ -1,3 +1,5 @@
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { ChangeUserRoleInProject } from '../change-user-role-in-project.dto';
describe('ChangeUserRoleInProject', () => {
@@ -38,7 +40,7 @@ describe('ChangeUserRoleInProject', () => {
},
{
name: 'personal owner role',
request: { role: 'project:personalOwner' },
request: { role: PROJECT_OWNER_ROLE_SLUG },
expectedErrorPath: ['role'],
},
])('should reject $name', ({ request, expectedErrorPath }) => {

View File

@@ -1,6 +1,6 @@
import { projectRoleSchema } from '@n8n/permissions';
import { teamRoleSchema } from '@n8n/permissions';
import { Z } from 'zod-class';
export class ChangeUserRoleInProject extends Z.class({
role: projectRoleSchema.exclude(['project:personalOwner']),
role: teamRoleSchema,
}) {}

View File

@@ -1,4 +1,4 @@
import { projectRoleSchema } from '@n8n/permissions';
import { teamRoleSchema } from '@n8n/permissions';
import { z } from 'zod';
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({
userId: z.string().min(1),
role: projectRoleSchema.exclude(['project:personalOwner']),
role: teamRoleSchema,
});
export type ProjectRelation = z.infer<typeof projectRelationSchema>;

View File

@@ -1,17 +1,17 @@
import type { Project, User, ProjectRelation } from '@n8n/db';
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
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';
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);
await projectRelationRepository.save(
projectRelationRepository.create({
projectId: project.id,
userId: user.id,
role,
role: { slug: role },
}),
);
};
@@ -41,7 +41,7 @@ export const getPersonalProject = async (user: User): Promise<Project> => {
where: {
projectRelations: {
userId: user.id,
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
type: 'personal',
},
@@ -61,19 +61,20 @@ export const getProjectRelations = async ({
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
return await Container.get(ProjectRelationRepository).find({
where: { projectId, userId, role },
relations: { role: true },
});
};
export const getProjectRoleForUser = async (
projectId: string,
userId: string,
): Promise<ProjectRole | undefined> => {
): Promise<CustomRole | undefined> => {
return (
await Container.get(ProjectRelationRepository).findOne({
select: ['role'],
where: { projectId, userId },
relations: { role: true },
})
)?.role;
)?.role?.slug;
};
export const getAllProjectRelations = async ({
@@ -81,5 +82,6 @@ export const getAllProjectRelations = async ({
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
return await Container.get(ProjectRelationRepository).find({
where: { projectId },
relations: { role: true },
});
};

View File

@@ -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';
@@ -16,15 +25,44 @@ export function buildInRoleToRoleObject(role: GlobalRole): Role {
systemRole: true,
roleType: 'global',
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_ADMIN_ROLE = buildInRoleToRoleObject('global:admin');
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> = {
'global:owner': GLOBAL_OWNER_ROLE,
'global:admin': GLOBAL_ADMIN_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,
};

View File

@@ -1,14 +1,15 @@
import { ProjectRole } from '@n8n/permissions';
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import { Project } from './project';
import { Role } from './role';
import { User } from './user';
@Entity()
export class ProjectRelation extends WithTimestamps {
@Column({ type: 'varchar' })
role: ProjectRole;
@ManyToOne('Role', 'projectRelations')
@JoinColumn({ name: 'role', referencedColumnName: 'slug' })
role: Role;
@ManyToOne('User', 'projectRelations')
user: User;

View File

@@ -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';
@Entity({
@@ -45,6 +46,9 @@ export class Role {
*/
roleType: 'global' | 'project' | 'workflow' | 'credential';
@OneToMany('ProjectRelation', 'role')
projectRelations: ProjectRelation[];
@ManyToMany(() => Scope, {
eager: true,
})

View File

@@ -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']);
}
}

View File

@@ -1,4 +1,5 @@
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
import { InitialMigration1588157391238 } from './1588157391238-InitialMigration';
import { WebhookModel1592447867632 } from './1592447867632-WebhookModel';
import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt';
@@ -197,4 +198,5 @@ export const mysqlMigrations: Migration[] = [
CreateDataStoreTables1754475614601,
RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
];

View File

@@ -1,5 +1,6 @@
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
import { InitialMigration1587669153312 } from './1587669153312-InitialMigration';
import { WebhookModel1589476000887 } from './1589476000887-WebhookModel';
import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt';
@@ -195,4 +196,5 @@ export const postgresMigrations: Migration[] = [
CreateDataStoreTables1754475614601,
RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
];

View File

@@ -93,6 +93,7 @@ import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import type { Migration } from '../migration-types';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@@ -189,6 +190,7 @@ const sqliteMigrations: Migration[] = [
CreateDataStoreTables1754475614601,
RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
];
export { sqliteMigrations };

View File

@@ -1,5 +1,5 @@
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 { ProjectRelation } from '../entities';
@@ -14,7 +14,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
return await this.find({
where: {
projectId: In(projectIds),
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
relations: {
user: {
@@ -28,7 +28,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
const projectRelations = await this.find({
where: {
userId: In(userIds),
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
});
@@ -73,6 +73,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
where: {
userId,
},
relations: { role: true },
});
}
}

View File

@@ -1,4 +1,5 @@
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { EntityManager } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm';
@@ -14,7 +15,11 @@ export class ProjectRepository extends Repository<Project> {
const em = entityManager ?? this.manager;
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;
return await em.findOneOrFail(Project, {
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
where: {
type: 'personal',
projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } },
},
});
}

View File

@@ -120,7 +120,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
project: {
projectRelations: {
userId: In(userIds),
role: In(projectRoles),
role: { slug: In(projectRoles) },
},
},
},

View File

@@ -1,5 +1,5 @@
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 type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
@@ -28,7 +28,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
role: 'workflow:owner',
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: {
workflowId,
project: { projectRelations: { role: 'project:personalOwner', userId } },
project: { projectRelations: { role: { slug: PROJECT_OWNER_ROLE_SLUG }, userId } },
},
});

View File

@@ -1,5 +1,6 @@
import type { UsersListFilterDto } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { DeepPartial, EntityManager, SelectQueryBuilder } 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>(
entityManager.create(ProjectRelation, {
projectId: savedProject.id,
userId: userWithRole.id,
role: 'project:personalOwner',
userId: savedUser.id,
role: { slug: PROJECT_OWNER_ROLE_SLUG },
}),
);
return { user: userWithRole, project: savedProject };
@@ -129,7 +130,7 @@ export class UserRepository extends Repository<User> {
return await this.findOne({
where: {
projectRelations: {
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } },
},
},
@@ -146,7 +147,7 @@ export class UserRepository extends Repository<User> {
return await this.findOne({
where: {
projectRelations: {
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
projectId,
},
},
@@ -232,15 +233,15 @@ export class UserRepository extends Repository<User> {
expand: UsersListFilterDto['expand'],
): SelectQueryBuilder<User> {
if (expand?.includes('projectRelations')) {
queryBuilder.leftJoinAndSelect(
'user.projectRelations',
'projectRelations',
'projectRelations.role <> :projectRole',
{
projectRole: 'project:personalOwner', // Exclude personal project relations
},
);
queryBuilder.leftJoinAndSelect('projectRelations.project', 'project');
queryBuilder
.leftJoinAndSelect(
'user.projectRelations',
'projectRelations',
'projectRelations.role <> :projectRole',
{ projectRole: PROJECT_OWNER_ROLE_SLUG },
)
.leftJoinAndSelect('projectRelations.project', 'project')
.leftJoinAndSelect('projectRelations.role', 'projectRole');
}
return queryBuilder;

View File

@@ -1,5 +1,6 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
import { WorkflowStatistics } from '../entities';
@@ -122,7 +123,7 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
workflow: {
shared: {
role: 'workflow:owner',
project: { projectRelations: { userId, role: 'project:personalOwner' } },
project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } },
},
active: true,
},

View File

@@ -52,9 +52,9 @@ export class AuthRolesService {
}).filter((scope) => scope !== null);
if (scopesToUpdate.length > 0) {
this.logger.info(`Updating ${scopesToUpdate.length} scopes...`);
this.logger.debug(`Updating ${scopesToUpdate.length} scopes...`);
await this.scopeRepository.save(scopesToUpdate);
this.logger.info('Scopes updated successfully.');
this.logger.debug('Scopes updated successfully.');
} else {
this.logger.debug('No scopes to update.');
}
@@ -118,9 +118,9 @@ export class AuthRolesService {
})
.filter((role) => role !== null);
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);
this.logger.info(`${roleNamespace} roles updated successfully.`);
this.logger.debug(`${roleNamespace} roles updated successfully.`);
} else {
this.logger.debug(`No ${roleNamespace} roles to update.`);
}
@@ -128,9 +128,9 @@ export class AuthRolesService {
}
async init() {
this.logger.info('Initializing AuthRolesService...');
this.logger.debug('Initializing AuthRolesService...');
await this.syncScopes();
await this.syncRoles();
this.logger.info('AuthRolesService initialized successfully.');
this.logger.debug('AuthRolesService initialized successfully.');
}
}

View File

@@ -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 {
roleNamespaceSchema,
globalRoleSchema,
@@ -53,10 +60,26 @@ describe('assignableGlobalRoleSchema', () => {
describe('projectRoleSchema', () => {
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:editor', value: 'project:editor', expected: true },
{ name: 'valid role: project:viewer', value: 'project:viewer', expected: true },
{
name: `valid role: ${PROJECT_OWNER_ROLE_SLUG}`,
value: PROJECT_OWNER_ROLE_SLUG,
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 },
])('should validate $name', ({ value, expected }) => {
const result = projectRoleSchema.safeParse(value);

View File

@@ -43,3 +43,8 @@ export const API_KEY_RESOURCES = {
sourceControl: ['pull'] as const,
workflowTags: ['update', 'list'] 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';

View File

@@ -6,7 +6,7 @@ export * from './scope-information';
export * from './roles/role-maps.ee';
export * from './roles/all-roles';
export { projectRoleSchema } from './schemas.ee';
export { projectRoleSchema, teamRoleSchema } from './schemas.ee';
export { hasScope } from './utilities/has-scope.ee';
export { hasGlobalScope } from './utilities/has-global-scope.ee';

View File

@@ -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 {
CREDENTIALS_SHARING_SCOPE_MAP,
GLOBAL_SCOPE_MAP,
@@ -11,10 +17,10 @@ const ROLE_NAMES: Record<AllRoleTypes, string> = {
'global:owner': 'Owner',
'global:admin': 'Admin',
'global:member': 'Member',
'project:personalOwner': 'Project Owner',
'project:admin': 'Project Admin',
'project:editor': 'Project Editor',
'project:viewer': 'Project Viewer',
[PROJECT_OWNER_ROLE_SLUG]: 'Project Owner',
[PROJECT_ADMIN_ROLE_SLUG]: 'Project Admin',
[PROJECT_EDITOR_ROLE_SLUG]: 'Project Editor',
[PROJECT_VIEWER_ROLE_SLUG]: 'Project Viewer',
'credential:user': 'Credential User',
'credential:owner': 'Credential Owner',
'workflow:owner': 'Workflow Owner',

View File

@@ -1,5 +1,7 @@
import { z } from 'zod';
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']);
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 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']);

View File

@@ -58,6 +58,7 @@ export type CredentialSharingRole = z.infer<typeof credentialSharingRoleSchema>;
export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>;
export type TeamProjectRole = z.infer<typeof teamRoleSchema>;
export type ProjectRole = z.infer<typeof projectRoleSchema>;
export type CustomRole = string;
/** Union of all possible role types in the system */
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;

View File

@@ -1,7 +1,7 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
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 { type SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
@@ -39,6 +39,7 @@ mockInstance(CommunityPackagesService);
const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined);
mockInstance(AuthRolesService);
test('should start a task runner when task runners are enabled', async () => {
// arrange

View File

@@ -1,11 +1,13 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
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 { mock } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow';
import { Execute } from '../execute';
import { ActiveExecutions } from '@/active-executions';
import { DeprecationService } from '@/deprecation/deprecation.service';
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 { WorkflowRunner } from '@/workflow-runner';
import { Execute } from '../execute';
const taskRunnerModule = mockInstance(TaskRunnerModule);
const workflowRepository = mockInstance(WorkflowRepository);
const ownershipService = mockInstance(OwnershipService);
@@ -38,6 +38,7 @@ mockInstance(CommunityPackagesService);
const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined);
mockInstance(AuthRolesService);
test('should start a task runner when task runners are enabled', async () => {
// arrange

View File

@@ -9,7 +9,7 @@ import {
} from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
import { DbConnection } from '@n8n/db';
import { AuthRolesService, DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import {
BinaryDataConfig,
@@ -121,6 +121,9 @@ export abstract class BaseCommand<F = never> {
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();
if (process.env.EXECUTIONS_PROCESS === 'own') process.exit(-1);

View File

@@ -8,6 +8,7 @@ import {
} from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm';
import glob from 'fast-glob';
@@ -17,10 +18,10 @@ import type { ICredentialsEncrypted } from 'n8n-workflow';
import { jsonParse, UserError } from 'n8n-workflow';
import { z } from 'zod';
import { UM_FIX_INSTRUCTION } from '@/constants';
import { BaseCommand } from '../base-command';
import { UM_FIX_INSTRUCTION } from '@/constants';
const flagsSchema = z.object({
input: z
.string()
@@ -239,7 +240,7 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
if (sharedCredential && sharedCredential.project.type === 'personal') {
const user = await this.transactionManager.findOneByOrFail(User, {
projectRelations: {
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
projectId: sharedCredential.projectId,
},
});

View File

@@ -9,18 +9,19 @@ import {
} from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import glob from 'fast-glob';
import fs from 'fs';
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
import { jsonParse, UserError } from 'n8n-workflow';
import { z } from 'zod';
import { BaseCommand } from '../base-command';
import { UM_FIX_INSTRUCTION } from '@/constants';
import type { IWorkflowToImport } from '@/interfaces';
import { ImportService } from '@/services/import.service';
import { BaseCommand } from '../base-command';
function assertHasWorkflowsToImport(
workflows: unknown[],
): asserts workflows is IWorkflowToImport[] {
@@ -167,7 +168,7 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
if (sharing && sharing.project.type === 'personal') {
const user = await Container.get(UserRepository).findOneByOrFail({
projectRelations: {
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
projectId: sharing.projectId,
},
});

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
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 { Container } from '@n8n/di';
import glob from 'fast-glob';
@@ -13,8 +13,6 @@ import replaceStream from 'replacestream';
import { pipeline } from 'stream/promises';
import { z } from 'zod';
import { BaseCommand } from './base-command';
import { ActiveExecutions } from '@/active-executions';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
@@ -34,6 +32,8 @@ import { UrlService } from '@/services/url.service';
import { WaitTracker } from '@/wait-tracker';
import { WorkflowRunner } from '@/workflow-runner';
import { BaseCommand } from './base-command';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const open = require('open');
@@ -173,8 +173,6 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
await super.init();
await Container.get(AuthRolesService).init();
this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
const isMultiMainEnabled =

View File

@@ -14,12 +14,7 @@ import {
Param,
Query,
} from '@n8n/decorators';
import {
combineScopes,
getAuthPrincipalScopes,
getRoleScopes,
hasGlobalScope,
} from '@n8n/permissions';
import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions';
import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, Not } from '@n8n/typeorm';
@@ -69,13 +64,18 @@ export class ProjectController {
uiContext: payload.uiContext,
});
const relations = await this.projectsService.getProjectRelations(project.id);
return {
...project,
role: 'project:admin',
scopes: [
...combineScopes({
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) {
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
this.projectRepository.create(pr.project),
{ role: pr.role, scopes: [] },
{ role: pr.role.slug, scopes: [] },
);
if (result.scopes) {
result.scopes.push(
...combineScopes({
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) {
throw new NotFoundError('Could not find a personal project for this user');
}
const relations = await this.projectsService.getProjectRelations(project.id);
const scopes: Scope[] = [
...combineScopes({
global: getAuthPrincipalScopes(req.user),
project: getRoleScopes('project:personalOwner'),
project:
relations
.find((pr) => pr.userId === req.user.id)
?.role.scopes.map((scope) => scope.slug) ?? [],
}),
];
return {
@@ -191,12 +196,12 @@ export class ProjectController {
email: r.user.email,
firstName: r.user.firstName,
lastName: r.user.lastName,
role: r.role,
role: r.role.slug,
})),
scopes: [
...combineScopes({
global: getAuthPrincipalScopes(req.user),
...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}),
...(myRelation ? { project: myRelation.role.scopes.map((scope) => scope.slug) } : {}),
}),
],
};

View File

@@ -124,7 +124,7 @@ export class UsersController {
...user,
projectRelations: u.projectRelations?.map((pr) => ({
id: pr.projectId,
role: pr.role, // normalize role for frontend
role: pr.role.slug, // normalize role for frontend
name: pr.project.name,
})),
};

View File

@@ -3,8 +3,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */
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 { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { EntityNotFoundError, In } from '@n8n/typeorm';
import { Credentials, getAdditionalKeys } from 'n8n-core';
@@ -496,9 +502,9 @@ export class CredentialsHelper extends ICredentialsHelper {
role: 'credential:owner',
project: {
projectRelations: {
role: In(['project:personalOwner', 'project:admin']),
role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]) },
user: {
role: In(['global:owner', 'global:admin']),
role: { slug: In([GLOBAL_OWNER_ROLE.slug, GLOBAL_ADMIN_ROLE.slug]) },
},
},
},

View File

@@ -25,12 +25,17 @@ import {
Param,
Query,
} from '@n8n/decorators';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { deepCopy } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
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 { ForbiddenError } from '@/errors/response-errors/forbidden.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 * as utils from '@/utils';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
@RestController('/credentials')
export class CredentialsController {
constructor(
@@ -364,7 +365,7 @@ export class CredentialsController {
const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds),
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
});
await this.userManagementMailer.notifyCredentialsShared({

View File

@@ -10,7 +10,7 @@ import {
UserRepository,
} from '@n8n/db';
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
import {
In,
@@ -27,6 +27,8 @@ import type {
} 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 { CredentialTypes } from '@/credential-types';
import { createCredentialsFromCredentialsEntity } from '@/credentials-helper';
@@ -38,12 +40,9 @@ import { userHasScopes } from '@/permissions.ee/check-access';
import type { CredentialRequest, ListQuery } from '@/requests';
import { CredentialsTester } from '@/services/credentials-tester.service';
import { OwnershipService } from '@/services/ownership.service';
// eslint-disable-next-line import-x/no-cycle
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service';
export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope }
| { allowGlobalScope: false };
@@ -300,7 +299,7 @@ export class CredentialsService {
role: 'credential:owner',
project: {
projectRelations: {
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
userId: user.id,
},
},

View File

@@ -1,5 +1,5 @@
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 {
SharedCredentials,
SharedWorkflow,
@@ -83,7 +83,7 @@ describe('SourceControlExportService', () => {
type: 'personal',
projectRelations: [
{
role: 'project:personalOwner',
role: PROJECT_OWNER_ROLE,
user: mock({ email: 'user@example.com' }),
},
],
@@ -268,7 +268,7 @@ describe('SourceControlExportService', () => {
mock<SharedWorkflow>({
project: mock({
type: 'personal',
projectRelations: [{ role: 'project:personalOwner', user: mock() }],
projectRelations: [{ role: PROJECT_OWNER_ROLE, user: mock() }],
}),
workflow: mock(),
}),

View File

@@ -80,7 +80,7 @@ describe('SourceControlImportService', () => {
};
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValueOnce([]);
sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValueOnce([]);
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
@@ -312,7 +312,7 @@ describe('SourceControlImportService', () => {
],
};
sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValue([
sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValue([
Object.assign(new Project(), {
id: 'project1',
}),

View File

@@ -10,6 +10,7 @@ import {
WorkflowRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
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 path from 'path';
import { formatWorkflow } from '@/workflows/workflow.formatter';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_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 { VariablesService } from '../variables/variables.service.ee';
import { formatWorkflow } from '@/workflows/workflow.formatter';
@Service()
export class SourceControlExportService {
private gitFolder: string;
@@ -145,7 +146,7 @@ export class SourceControlExportService {
if (project.type === 'personal') {
const ownerRelation = project.projectRelations.find(
(pr) => pr.role === 'project:personalOwner',
(pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
);
if (!ownerRelation) {
throw new UnexpectedError(
@@ -251,7 +252,7 @@ export class SourceControlExportService {
}
const allowedProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
const fileName = getFoldersPath(this.gitFolder);
@@ -409,7 +410,7 @@ export class SourceControlExportService {
let owner: RemoteResourceOwner | null = null;
if (sharing.project.type === 'personal') {
const ownerRelation = sharing.project.projectRelations.find(
(pr) => pr.role === 'project:personalOwner',
(pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
);
if (ownerRelation) {
owner = {

View File

@@ -15,6 +15,7 @@ import {
UserRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
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 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 {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
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 { 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 = (
owner: RemoteResourceOwner,
accessibleProjects: Project[],
@@ -59,7 +60,7 @@ const findOwnerProject = (
if (typeof owner === 'string') {
return accessibleProjects.find((project) =>
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.type === 'personal' &&
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') {
const personalEmail = remoteOwnerProject.projectRelations?.find(
(r) => r.role === 'project:personalOwner',
(r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG,
)?.user?.email;
if (personalEmail) {
@@ -148,7 +149,7 @@ export class SourceControlImportService {
});
const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
const remoteWorkflowsRead = await Promise.all(
remoteWorkflowFiles.map(async (file) => {
@@ -251,7 +252,9 @@ export class SourceControlImportService {
name: true,
type: true,
projectRelations: {
role: true,
role: {
slug: true,
},
user: {
email: true,
},
@@ -300,7 +303,7 @@ export class SourceControlImportService {
});
const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
const remoteCredentialFilesRead = await Promise.all(
remoteCredentialFiles.map(async (file) => {
@@ -368,7 +371,9 @@ export class SourceControlImportService {
name: true,
type: true,
projectRelations: {
role: true,
role: {
slug: true,
},
user: {
email: true,
},
@@ -426,7 +431,7 @@ export class SourceControlImportService {
});
const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
mappedFolders.folders = mappedFolders.folders.filter(
(folder) =>

View File

@@ -29,20 +29,21 @@ export class SourceControlScopedService {
}
const ctx = new SourceControlContext(req.user);
const projectsWithAdminAccess = await this.getAdminProjectsFromContext(ctx);
const projectsWithAdminAccess = await this.getAuthorizedProjectsFromContext(ctx);
if (projectsWithAdminAccess?.length === 0) {
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()) {
// In case the user is a global admin or owner, we don't need a filter
return await this.projectRepository.find({
relations: {
projectRelations: {
user: true,
role: true,
},
},
});
@@ -52,6 +53,7 @@ export class SourceControlScopedService {
relations: {
projectRelations: {
user: true,
role: true,
},
},
select: {
@@ -59,7 +61,7 @@ export class SourceControlScopedService {
name: true,
type: true,
},
where: this.getAdminProjectsByContextFilter(context),
where: this.getProjectsWithPushScopeByContextFilter(context),
});
}
@@ -85,7 +87,7 @@ export class SourceControlScopedService {
});
}
getAdminProjectsByContextFilter(
getProjectsWithPushScopeByContextFilter(
context: SourceControlContext,
): FindOptionsWhere<Project> | undefined {
if (context.hasAccessToAllProjects()) {
@@ -96,7 +98,11 @@ export class SourceControlScopedService {
return {
type: 'team',
projectRelations: {
role: 'project:admin',
role: {
scopes: {
slug: 'sourceControl:push',
},
},
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
// that the user is an admin off
return {
homeProject: this.getAdminProjectsByContextFilter(context),
homeProject: this.getProjectsWithPushScopeByContextFilter(context),
};
}
@@ -130,7 +136,7 @@ export class SourceControlScopedService {
return {
shared: {
role: 'workflow:owner',
project: this.getAdminProjectsByContextFilter(context),
project: this.getProjectsWithPushScopeByContextFilter(context),
},
};
}
@@ -148,7 +154,7 @@ export class SourceControlScopedService {
return {
shared: {
role: 'credential:owner',
project: this.getAdminProjectsByContextFilter(context),
project: this.getProjectsWithPushScopeByContextFilter(context),
},
};
}

View File

@@ -6,6 +6,7 @@ import {
WorkflowRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { snakeCase } from 'change-case';
import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow';
@@ -13,6 +14,9 @@ import { TelemetryHelpers } from 'n8n-workflow';
import os from 'node:os';
import { get as pslGet } from 'psl';
import { EventRelay } from './event-relay';
import { Telemetry } from '../../telemetry';
import config from '@/config';
import { N8N_VERSION } from '@/constants';
import { EventService } from '@/events/event.service';
@@ -22,9 +26,6 @@ import type { IExecutionTrackProperties } from '@/interfaces';
import { License } from '@/license';
import { NodeTypes } from '@/node-types';
import { EventRelay } from './event-relay';
import { Telemetry } from '../../telemetry';
@Service()
export class TelemetryEventRelay extends EventRelay {
constructor(
@@ -604,7 +605,7 @@ export class TelemetryEventRelay extends EventRelay {
projectId: workflowOwner.id,
});
if (projectRole && projectRole !== 'project:personalOwner') {
if (projectRole && projectRole?.slug !== PROJECT_OWNER_ROLE_SLUG) {
userRole = 'member';
}
}

View File

@@ -1,12 +1,6 @@
import type { User, ExecutionSummaries } from '@n8n/db';
import { Get, Patch, Post, RestController } from '@n8n/decorators';
import 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 { PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
import { ExecutionService } from './execution.service';
import { EnterpriseExecutionsService } from './execution.service.ee';
@@ -14,6 +8,12 @@ import { ExecutionRequest } from './execution.types';
import { parseRangeQuery } from './parse-range-query.middleware';
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')
export class ExecutionsController {
constructor(
@@ -29,7 +29,7 @@ export class ExecutionsController {
} else {
return await this.workflowSharingService.getSharedWorkflowIds(user, {
workflowRoles: ['workflow:owner'],
projectRoles: ['project:personalOwner'],
projectRoles: [PROJECT_OWNER_ROLE_SLUG],
});
}
}

View File

@@ -1,10 +1,12 @@
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import {
type Role,
GLOBAL_MEMBER_ROLE,
GLOBAL_OWNER_ROLE,
ProjectRelationRepository,
type Project,
type User,
PROJECT_ADMIN_ROLE,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
@@ -71,7 +73,7 @@ describe('dataStoreAggregate', () => {
{
userId: user.id,
projectId: project1.id,
role: 'project:admin',
role: PROJECT_ADMIN_ROLE,
user,
project: project1,
createdAt: new Date(),
@@ -81,7 +83,7 @@ describe('dataStoreAggregate', () => {
{
userId: user.id,
projectId: project2.id,
role: 'project:viewer',
role: { slug: 'project:viewer' } as Role,
user,
project: project2,
createdAt: new Date(),
@@ -147,7 +149,7 @@ describe('dataStoreAggregate', () => {
{
userId: user.id,
projectId: project1.id,
role: 'project:admin',
role: PROJECT_ADMIN_ROLE,
user,
project: project1,
createdAt: new Date(),
@@ -157,7 +159,7 @@ describe('dataStoreAggregate', () => {
{
userId: user.id,
projectId: project2.id,
role: 'project:viewer',
role: { slug: 'project:viewer' } as Role,
user,
project: project2,
createdAt: new Date(),
@@ -196,7 +198,7 @@ describe('dataStoreAggregate', () => {
{
userId: user.id,
projectId: project1.id,
role: 'project:admin',
role: PROJECT_ADMIN_ROLE,
user,
project: project1,
createdAt: new Date(),

View File

@@ -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(
ProjectRepository,
mock<ProjectRepository>({
find: jest.fn().mockResolvedValue([
{
id: 'projectId',
projectRelations: [{ userId: 'userId', role: 'project:admin' }],
},
]),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
}),
);
});

View File

@@ -2,8 +2,6 @@ import type { User } from '@n8n/db';
import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
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 { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -30,21 +28,21 @@ export async function userHasScopes(
if (globalOnly) return false;
// Find which project roles are defined to contain the required scopes.
// Then find projects having this user and having those project roles.
const projectRoles = rolesWithScope('project', scopes);
// Find which projects the user has access to with the required scopes.
// This is done by finding the projects where the user has a role with at least the required scopes
const userProjectIds = (
await Container.get(ProjectRepository).find({
where: {
projectRelations: {
userId: user.id,
role: In(projectRoles),
},
},
select: ['id'],
})
).map((p) => p.id);
await Container.get(ProjectRepository)
.createQueryBuilder('project')
.innerJoin('project.projectRelations', 'relation')
.innerJoin('relation.role', 'role')
.innerJoin('role.scopes', 'scope')
.where('relation.userId = :userId', { userId: user.id })
.andWhere('scope.slug IN (:...scopes)', { scopes })
.groupBy('project.id')
.having('COUNT(DISTINCT scope.slug) = :scopeCount', { scopeCount: scopes.length })
.select(['project.id AS id'])
.getRawMany()
).map((row: { id: string }) => row.id);
// Find which resource roles are defined to contain the required scopes.
// Then find at least one of the above qualifying projects having one of

View File

@@ -9,7 +9,7 @@ import {
WorkflowRepository,
} from '@n8n/db';
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 { License } from '@/license';
@@ -32,7 +32,7 @@ export async function getSharedWorkflowIds(
} else {
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
workflowRoles: ['workflow:owner'],
projectRoles: ['project:personalOwner'],
projectRoles: [PROJECT_OWNER_ROLE_SLUG],
projectId,
});
}

View File

@@ -8,7 +8,13 @@ import type {
ListQueryDb,
WorkflowHistory,
} from '@n8n/db';
import type { AssignableGlobalRole, ProjectRole, Scope } from '@n8n/permissions';
import type {
AssignableGlobalRole,
CustomRole,
GlobalRole,
ProjectRole,
Scope,
} from '@n8n/permissions';
import type {
ICredentialDataDecryptedObject,
INodeCredentialTestRequest,
@@ -268,14 +274,16 @@ export declare namespace ActiveWorkflowRequest {
// ----------------------------------
export declare namespace ProjectRequest {
type GetMyProjectsResponse = Array<Project & { role: string; scopes?: Scope[] }>;
type GetMyProjectsResponse = Array<
Project & { role: ProjectRole | GlobalRole | CustomRole; scopes?: Scope[] }
>;
type ProjectRelationResponse = {
id: string;
email: string;
firstName: string;
lastName: string;
role: ProjectRole;
role: ProjectRole | CustomRole;
};
type ProjectWithRelations = {
id: string;

View File

@@ -1,11 +1,17 @@
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db';
import type { CredentialsEntity, User } from '@n8n/db';
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 { mockEntityManager } from '@test/mocking';
import { mock } from 'jest-mock-extended';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { mockEntityManager } from '@test/mocking';
describe('CredentialsFinderService', () => {
const entityManager = mockEntityManager(SharedCredentials);
@@ -56,10 +62,10 @@ describe('CredentialsFinderService', () => {
project: {
projectRelations: {
role: In([
'project:admin',
'project:personalOwner',
'project:editor',
'project:viewer',
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
]),
userId: member.id,
},
@@ -84,10 +90,10 @@ describe('CredentialsFinderService', () => {
project: {
projectRelations: {
role: In([
'project:admin',
'project:personalOwner',
'project:editor',
'project:viewer',
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
]),
userId: member.id,
},

View File

@@ -10,7 +10,9 @@ import {
SharedWorkflowRepository,
UserRepository,
GLOBAL_OWNER_ROLE,
PROJECT_OWNER_ROLE,
} from '@n8n/db';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { v4 as uuid } from 'uuid';
import { OwnershipService } from '@/services/ownership.service';
@@ -64,7 +66,7 @@ describe('OwnershipService', () => {
const owner = new User();
owner.role = GLOBAL_OWNER_ROLE;
const projectRelation = new ProjectRelation();
projectRelation.role = 'project:personalOwner';
projectRelation.role = PROJECT_OWNER_ROLE;
(projectRelation.project = project), (projectRelation.user = owner);
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
@@ -92,7 +94,7 @@ describe('OwnershipService', () => {
owner.id = uuid();
owner.role = GLOBAL_OWNER_ROLE;
const projectRelation = new ProjectRelation();
projectRelation.role = 'project:personalOwner';
projectRelation.role = { slug: PROJECT_OWNER_ROLE_SLUG } as any;
(projectRelation.project = project), (projectRelation.user = owner);
cacheService.getHashValue.mockResolvedValueOnce(owner);
@@ -116,7 +118,7 @@ describe('OwnershipService', () => {
mockOwner.role = GLOBAL_OWNER_ROLE;
const projectRelation = Object.assign(new ProjectRelation(), {
role: 'project:personalOwner',
role: PROJECT_OWNER_ROLE_SLUG,
project: mockProject,
user: mockOwner,
});

View File

@@ -1,12 +1,14 @@
import type { ProjectRelation } from '@n8n/api-types';
import type { DatabaseConfig } from '@n8n/config';
import type {
Project,
ProjectRepository,
SharedCredentialsRepository,
ProjectRelationRepository,
SharedCredentials,
import {
type Project,
type ProjectRepository,
type SharedCredentialsRepository,
type ProjectRelationRepository,
type SharedCredentials,
PROJECT_ADMIN_ROLE,
} from '@n8n/db';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
@@ -58,7 +60,7 @@ describe('ProjectService', () => {
// ACT & ASSERT
await expect(
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.");
});
@@ -100,7 +102,7 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({
where: { id: projectId, type: 'team' },
relations: { projectRelations: true },
relations: { projectRelations: { role: true } },
});
expect(manager.delete).toHaveBeenCalled();
@@ -139,7 +141,7 @@ describe('ProjectService', () => {
mock<Project>({
id: projectId,
type: 'team',
projectRelations: [{ userId: 'user1', role: 'project:admin' }],
projectRelations: [{ userId: 'user1', role: PROJECT_ADMIN_ROLE }],
}),
);
roleService.isRoleLicensed.mockReturnValue(false);
@@ -156,9 +158,9 @@ describe('ProjectService', () => {
describe('changeUserRoleInProject', () => {
const projectId = '12345';
const mockRelations: ProjectRelation[] = [
{ userId: 'user1', role: 'project:admin' },
{ userId: 'user2', role: 'project:viewer' },
const mockRelations = [
{ userId: 'user1', role: { slug: 'project:admin' } },
{ userId: 'user2', role: { slug: 'project:viewer' } },
];
beforeEach(() => {
@@ -185,12 +187,12 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({
where: { id: projectId, type: 'team' },
relations: { projectRelations: true },
relations: { projectRelations: { role: true } },
});
expect(projectRelationRepository.update).toHaveBeenCalledWith(
{ projectId, userId: 'user2' },
{ role: 'project:admin' },
{ role: { slug: 'project:admin' } },
);
});
@@ -210,13 +212,13 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({
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 () => {
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.');
});
@@ -230,7 +232,7 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({
where: { id: projectId, type: 'team' },
relations: { projectRelations: true },
relations: { projectRelations: { role: true } },
});
});
});

View File

@@ -12,7 +12,15 @@ import {
SharedWorkflowRepository,
} from '@n8n/db';
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
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
// 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 { RoleService } from './role.service';
type Relation = Pick<ProjectRelation, 'userId' | 'role'>;
export class TeamProjectOverQuotaError extends UserError {
constructor(limit: number) {
super(
@@ -37,7 +43,7 @@ export class TeamProjectOverQuotaError extends UserError {
}
export class UnlicensedProjectRoleError extends UserError {
constructor(role: ProjectRole) {
constructor(role: ProjectRole | CustomRole) {
super(`Your instance is not licensed to use role "${role}".`);
}
}
@@ -71,14 +77,12 @@ export class ProjectService {
) {}
private get workflowService() {
// eslint-disable-next-line import-x/no-cycle
return import('@/workflows/workflow.service').then(({ WorkflowService }) =>
Container.get(WorkflowService),
);
}
private get credentialsService() {
// eslint-disable-next-line import-x/no-cycle
return import('@/credentials/credentials.service').then(({ CredentialsService }) =>
Container.get(CredentialsService),
);
@@ -262,7 +266,7 @@ export class ProjectService {
async getProjectRelationsForUser(user: User): Promise<ProjectRelation[]> {
return await this.projectRelationRepository.find({
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 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);
this.checkRolesLicensed(project, relations);
@@ -300,31 +307,38 @@ export class ProjectService {
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.");
}
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) {
const project = await this.projectRepository.findOne({
where: { id: projectId, type: 'team' },
relations: { projectRelations: true },
relations: { projectRelations: { role: true } },
});
ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
return project;
}
/** 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) {
const existing = project.projectRelations.find((pr) => pr.userId === userId);
// We don't throw an error if the user already exists with that role so
// 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);
}
}
@@ -332,7 +346,7 @@ export class ProjectService {
private isUserProjectOwner(project: Project, userId: string) {
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) {
if (role === 'project:personalOwner') {
if (role === PROJECT_OWNER_ROLE_SLUG) {
throw new ForbiddenError('Personal owner cannot be added to a team project.');
}
@@ -360,7 +374,7 @@ export class ProjectService {
throw new ProjectNotFoundError(projectId);
}
await this.projectRelationRepository.update({ projectId, userId }, { role });
await this.projectRelationRepository.update({ projectId, userId }, { role: { slug: role } });
}
async clearCredentialCanUseExternalSecretsCache(projectId: string) {
@@ -385,7 +399,7 @@ export class ProjectService {
async addManyRelations(
em: EntityManager,
project: Project,
relations: Array<{ userId: string; role: ProjectRole }>,
relations: Array<{ userId: string; role: ProjectRole | CustomRole }>,
) {
await em.insert(
ProjectRelation,
@@ -394,7 +408,7 @@ export class ProjectService {
this.projectRelationRepository.create({
projectId: project.id,
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 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;
return await trx.save(ProjectRelation, {
projectId,
userId,
role,
role: { slug: role },
});
}
@@ -454,7 +472,7 @@ export class ProjectService {
async getProjectRelations(projectId: string): Promise<ProjectRelation[]> {
return await this.projectRelationRepository.find({
where: { projectId },
relations: { user: true },
relations: { user: true, role: true },
});
}
@@ -463,7 +481,7 @@ export class ProjectService {
where: {
projectRelations: {
userId,
role: In(['project:personalOwner', 'project:admin']),
role: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]),
},
},
});

View File

@@ -8,7 +8,7 @@ import type {
ProjectRelation,
} from '@n8n/db';
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 { UnexpectedError } from 'n8n-workflow';
@@ -97,7 +97,7 @@ export class RoleService {
);
let projectScopes: Scope[] = [];
if (pr) {
projectScopes = getRoleScopes(pr.role);
projectScopes = pr.role.scopes.map((s) => s.slug);
}
const resourceMask = getRoleScopes(sharedEntity.role);
const mergedScopes = combineScopes(
@@ -112,7 +112,7 @@ export class RoleService {
return [...scopesSet].sort();
}
isRoleLicensed(role: AllRoleTypes) {
isRoleLicensed(role: ProjectRole | CustomRole) {
// TODO: move this info into FrontendSettings
switch (role) {
case 'project:admin':
@@ -124,6 +124,7 @@ export class RoleService {
case 'global:admin':
return this.license.isAdvancedPermissionsLicensed();
default:
// TODO: handle custom roles licensing
return true;
}
}

View File

@@ -7,6 +7,7 @@ import {
type ProjectRole,
type WorkflowSharingRole,
type Scope,
PROJECT_OWNER_ROLE_SLUG,
} from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
@@ -74,7 +75,7 @@ export class WorkflowSharingService {
project: {
projectRelations: {
userId: user.id,
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
},
},
@@ -115,7 +116,7 @@ export class WorkflowSharingService {
project: {
projectRelations: {
userId: user.id,
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
},
},
},

View File

@@ -23,6 +23,8 @@ import omit from 'lodash/omit';
import type { IWorkflowBase, WorkflowId } 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 { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
@@ -34,8 +36,6 @@ import { FolderService } from '@/services/folder.service';
import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee';
import { WorkflowFinderService } from './workflow-finder.service';
@Service()
export class EnterpriseWorkflowService {
constructor(
@@ -74,7 +74,7 @@ export class EnterpriseWorkflowService {
);
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.
.map((project) =>
this.sharedWorkflowRepository.create({

View File

@@ -30,6 +30,7 @@ import {
Query,
RestController,
} from '@n8n/decorators';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type FindOptionsRelations } from '@n8n/typeorm';
import axios from 'axios';
@@ -37,6 +38,14 @@ import express from 'express';
import { UnexpectedError } from 'n8n-workflow';
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 { ForbiddenError } from '@/errors/response-errors/forbidden.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 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')
export class WorkflowsController {
constructor(
@@ -535,7 +536,7 @@ export class WorkflowsController {
const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds),
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
});
await this.mailer.notifyWorkflowShared({

View File

@@ -328,7 +328,6 @@ describe('Member', () => {
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
console.log(newApiKeyResponse.body);
expect(newApiKeyResponse.statusCode).toBe(200);
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
expect(newApiKeyResponse.body.data.apiKey).not.toBeNull();

View File

@@ -13,13 +13,9 @@ import {
UserRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
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 {
assertReturnedUserProps,
assertStoredUserProps,
@@ -29,6 +25,11 @@ import { createMember, createOwner, createUserShell } from '../../shared/db/user
import * as utils from '../../shared/utils';
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', () => {
const mailer = mockInstance(UserManagementMailer);
const externalHooks = mockInstance(ExternalHooks);
@@ -296,7 +297,7 @@ describe('InvitationController', () => {
const projectRelation = await projectRelationRepository.findOneOrFail({
where: {
userId: storedUser.id,
role: 'project:personalOwner',
role: { slug: PROJECT_OWNER_ROLE_SLUG },
project: {
type: 'personal',
},

View File

@@ -20,12 +20,14 @@ import {
SharedWorkflowRepository,
} from '@n8n/db';
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 { 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 {
@@ -36,6 +38,10 @@ import {
import { createMember, createOwner, createUser } from './shared/db/users';
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({
endpointGroups: ['project'],
enabledFeatures: [
@@ -193,7 +199,7 @@ describe('GET /projects/my-projects', () => {
[
personalProject1,
{
role: 'project:personalOwner',
role: PROJECT_OWNER_ROLE_SLUG,
scopes: ['project:list', 'project:read', 'credential:create'],
},
],
@@ -270,7 +276,7 @@ describe('GET /projects/my-projects', () => {
[
ownerProject,
{
role: 'project:personalOwner',
role: PROJECT_OWNER_ROLE_SLUG,
scopes: [
'project:list',
'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 === testUser2.id)).toBeUndefined();
expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin');
expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor');
expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer');
expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role.slug).toBe('project:admin');
expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role.slug).toBe('project:editor');
expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:viewer');
// 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 === testUser1.id)).toBeUndefined();
expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor');
expect(tp2Relations.find((p) => p.userId === ownerUser.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.slug).toBe('project:editor');
});
test.each([['project:viewer'], ['project:editor']] as const)(
@@ -656,8 +662,14 @@ describe('PATCH /projects/:projectId', () => {
expect(tp1Relations.length).toBe(2);
expect(tp1Relations).toMatchObject(
expect.arrayContaining([
expect.objectContaining({ userId: actor.id, role }),
expect.objectContaining({ userId: projectEditor.id, role: 'project:editor' }),
expect.objectContaining({
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).toMatchObject(
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 === testUser2.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser3.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?.slug).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 () => {
@@ -768,9 +783,9 @@ describe('PATCH /projects/:projectId', () => {
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 === testUser3.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer');
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:viewer');
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).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 () => {
@@ -782,7 +797,7 @@ describe('PATCH /projects/:projectId', () => {
const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({
relations: [
{ userId: testUser1.id, role: 'project:personalOwner' },
{ userId: testUser1.id, role: PROJECT_OWNER_ROLE_SLUG },
{ userId: testUser2.id, role: 'project:admin' },
] as Array<{
userId: string;

View File

@@ -61,7 +61,7 @@ describe('ProjectService', () => {
expect(relations[0]).toMatchObject({
projectId: project.id,
userId: user.id,
role: 'project:admin',
role: { slug: 'project:admin' },
});
});
@@ -82,7 +82,7 @@ describe('ProjectService', () => {
expect(relations[0]).toMatchObject({
projectId: project.id,
userId: user.id,
role: 'project:editor',
role: { slug: 'project:editor' },
});
});
});
@@ -103,7 +103,7 @@ describe('ProjectService', () => {
expect(relations[0]).toMatchObject({
projectId: project.id,
userId: user.id,
role: 'project:admin',
role: { slug: 'project:admin' },
});
});
});

View File

@@ -564,23 +564,20 @@ describe('Projects in Public API', () => {
expect(projectAfter.length).toEqual(3);
const adminRelation = projectAfter.find(
(relation) => relation.userId === owner.id && relation.role === 'project:admin',
);
expect(adminRelation).toEqual(
expect.objectContaining({ userId: owner.id, role: 'project:admin' }),
(relation) => relation.userId === owner.id && relation.role.slug === 'project:admin',
);
expect(adminRelation!.userId).toBe(owner.id);
expect(adminRelation!.role.slug).toBe('project:admin');
const viewerRelation = projectAfter.find(
(relation) => relation.userId === member.id && relation.role === 'project:viewer',
);
expect(viewerRelation).toEqual(
expect.objectContaining({ userId: member.id, role: 'project:viewer' }),
(relation) => relation.userId === member.id && relation.role.slug === 'project:viewer',
);
expect(viewerRelation!.userId).toBe(member.id);
expect(viewerRelation!.role.slug).toBe('project:viewer');
const editorRelation = projectAfter.find(
(relation) => relation.userId === member2.id && relation.role === 'project:editor',
);
expect(editorRelation).toEqual(
expect.objectContaining({ userId: member2.id, role: 'project:editor' }),
(relation) => relation.userId === member2.id && relation.role.slug === '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 () => {
@@ -797,8 +794,12 @@ describe('Projects in Public API', () => {
expect(response.status).toBe(204);
expect(projectBefore.length).toEqual(2);
expect(projectBefore.find((p) => p.role === 'project:admin')?.userId).toEqual(owner.id);
expect(projectBefore.find((p) => p.role === 'project:viewer')?.userId).toEqual(member.id);
expect(projectBefore.find((p) => p.role.slug === 'project:admin')?.userId).toEqual(
owner.id,
);
expect(projectBefore.find((p) => p.role.slug === 'project:viewer')?.userId).toEqual(
member.id,
);
expect(projectAfter.length).toEqual(1);
expect(projectAfter[0].userId).toEqual(owner.id);

View File

@@ -1,4 +1,4 @@
import { getRoleScopes } from '@n8n/permissions';
import { getRoleScopes, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type {
GlobalRole,
ProjectRole,
@@ -76,8 +76,8 @@ beforeAll(async () => {
expectedProjectRoles = [
{
name: 'Project Owner',
role: 'project:personalOwner',
scopes: getRoleScopes('project:personalOwner'),
role: PROJECT_OWNER_ROLE_SLUG,
scopes: getRoleScopes(PROJECT_OWNER_ROLE_SLUG),
licensed: true,
description: 'Project Owner',
},

View File

@@ -1,12 +1,12 @@
import { testDb } from '@n8n/backend-test-utils';
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { ProjectRole, Scope } from '@n8n/permissions';
import { ProjectService } from '@/services/project.service.ee';
import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions';
import { createMember } from '../shared/db/users';
import { ProjectService } from '@/services/project.service.ee';
let projectRepository: ProjectRepository;
let projectService: ProjectService;
let projectRelationRepository: ProjectRelationRepository;
@@ -33,7 +33,7 @@ describe('ProjectService', () => {
'project:viewer',
'project:admin',
'project:editor',
'project:personalOwner',
PROJECT_OWNER_ROLE_SLUG,
] as ProjectRole[])(
'creates a relation between the user and the project using the role %s',
async (role) => {
@@ -57,7 +57,7 @@ describe('ProjectService', () => {
// ASSERT
//
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 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({
where: { userId: member.id, projectId: project.id },
relations: { role: true },
});
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', () => {
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 project = await projectRepository.save(
@@ -233,7 +234,7 @@ describe('ProjectService', () => {
await projectService.deleteUserFromProject(project.id, user.id);
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();

View File

@@ -1092,7 +1092,7 @@ describe('DELETE /users/:id', () => {
id: teamProject.id,
projectRelations: {
userId: transferee.id,
role: 'project:editor',
role: { slug: 'project:editor' },
},
}),
).resolves.not.toBeNull(),