mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(core): Don't allow creating more projects than allowed by exploiting a race condition (#15218)
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { Container } from '@n8n/di';
|
||||||
|
|
||||||
|
import { DatabaseConfig } from '../database.config';
|
||||||
|
|
||||||
|
describe('DatabaseConfig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Container.reset();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('`isLegacySqlite` defaults to true', () => {
|
||||||
|
const databaseConfig = Container.get(DatabaseConfig);
|
||||||
|
expect(databaseConfig.isLegacySqlite).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['mariadb', 'mysqldb', 'postgresdb'] satisfies Array<DatabaseConfig['type']>)(
|
||||||
|
'`isLegacySqlite` returns false if dbType is `%s`',
|
||||||
|
(dbType) => {
|
||||||
|
const databaseConfig = Container.get(DatabaseConfig);
|
||||||
|
databaseConfig.sqlite.poolSize = 0;
|
||||||
|
databaseConfig.type = dbType;
|
||||||
|
expect(databaseConfig.isLegacySqlite).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('`isLegacySqlite` returns false if dbType is `sqlite` and `poolSize` > 0', () => {
|
||||||
|
const databaseConfig = Container.get(DatabaseConfig);
|
||||||
|
databaseConfig.sqlite.poolSize = 1;
|
||||||
|
databaseConfig.type = 'sqlite';
|
||||||
|
expect(databaseConfig.isLegacySqlite).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('`isLegacySqlite` returns true if dbType is `sqlite` and `poolSize` is 0', () => {
|
||||||
|
const databaseConfig = Container.get(DatabaseConfig);
|
||||||
|
databaseConfig.sqlite.poolSize = 0;
|
||||||
|
databaseConfig.type = 'sqlite';
|
||||||
|
expect(databaseConfig.isLegacySqlite).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -145,6 +145,15 @@ export class DatabaseConfig {
|
|||||||
@Env('DB_TYPE', dbTypeSchema)
|
@Env('DB_TYPE', dbTypeSchema)
|
||||||
type: DbType = 'sqlite';
|
type: DbType = 'sqlite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is true if the default sqlite data source of TypeORM is used, as opposed
|
||||||
|
* to any other (e.g. postgres)
|
||||||
|
* This also returns false if n8n's new pooled sqlite data source is used.
|
||||||
|
*/
|
||||||
|
get isLegacySqlite() {
|
||||||
|
return this.type === 'sqlite' && this.sqlite.poolSize === 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Prefix for table names */
|
/** Prefix for table names */
|
||||||
@Env('DB_TABLE_PREFIX')
|
@Env('DB_TABLE_PREFIX')
|
||||||
tablePrefix: string = '';
|
tablePrefix: string = '';
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe('GlobalConfig', () => {
|
|||||||
},
|
},
|
||||||
tablePrefix: '',
|
tablePrefix: '',
|
||||||
type: 'sqlite',
|
type: 'sqlite',
|
||||||
|
isLegacySqlite: true,
|
||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
defaultName: 'My credentials',
|
defaultName: 'My credentials',
|
||||||
@@ -309,7 +310,10 @@ describe('GlobalConfig', () => {
|
|||||||
it('should use all default values when no env variables are defined', () => {
|
it('should use all default values when no env variables are defined', () => {
|
||||||
process.env = {};
|
process.env = {};
|
||||||
const config = Container.get(GlobalConfig);
|
const config = Container.get(GlobalConfig);
|
||||||
expect(structuredClone(config)).toEqual(defaultConfig);
|
// Makes sure the objects are structurally equal while respecting getters,
|
||||||
|
// which `toEqual` and `toBe` does not do.
|
||||||
|
expect(defaultConfig).toMatchObject(config);
|
||||||
|
expect(config).toMatchObject(defaultConfig);
|
||||||
expect(mockFs.readFileSync).not.toHaveBeenCalled();
|
expect(mockFs.readFileSync).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -365,7 +369,7 @@ describe('GlobalConfig', () => {
|
|||||||
mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file');
|
mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file');
|
||||||
|
|
||||||
const config = Container.get(GlobalConfig);
|
const config = Container.get(GlobalConfig);
|
||||||
expect(structuredClone(config)).toEqual({
|
const expected = {
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
database: {
|
database: {
|
||||||
...defaultConfig.database,
|
...defaultConfig.database,
|
||||||
@@ -374,7 +378,11 @@ describe('GlobalConfig', () => {
|
|||||||
password: 'password-from-file',
|
password: 'password-from-file',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
// Makes sure the objects are structurally equal while respecting getters,
|
||||||
|
// which `toEqual` and `toBe` does not do.
|
||||||
|
expect(config).toMatchObject(expected);
|
||||||
|
expect(expected).toMatchObject(config);
|
||||||
expect(mockFs.readFileSync).toHaveBeenCalled();
|
expect(mockFs.readFileSync).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["test/**"]
|
"exclude": ["test/**", "src/**/__tests__/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { CreateProjectDto, ProjectType, UpdateProjectDto } from '@n8n/api-types';
|
import type { CreateProjectDto, ProjectType, UpdateProjectDto } from '@n8n/api-types';
|
||||||
|
import { LicenseState } from '@n8n/backend-common';
|
||||||
|
import { DatabaseConfig } from '@n8n/config';
|
||||||
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
|
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
|
||||||
import type { User } from '@n8n/db';
|
import type { User } from '@n8n/db';
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +22,6 @@ import { UserError } from 'n8n-workflow';
|
|||||||
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';
|
||||||
import { License } from '@/license';
|
|
||||||
|
|
||||||
import { CacheService } from './cache/cache.service';
|
import { CacheService } from './cache/cache.service';
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ export class ProjectService {
|
|||||||
private readonly projectRelationRepository: ProjectRelationRepository,
|
private readonly projectRelationRepository: ProjectRelationRepository,
|
||||||
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
private readonly license: License,
|
private readonly licenseState: LicenseState,
|
||||||
|
private readonly databaseConfig: DatabaseConfig,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get workflowService() {
|
private get workflowService() {
|
||||||
@@ -181,25 +183,48 @@ export class ProjectService {
|
|||||||
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
|
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTeamProject(adminUser: User, data: CreateProjectDto): Promise<Project> {
|
private async createTeamProjectWithEntityManager(
|
||||||
const limit = this.license.getTeamProjectLimit();
|
adminUser: User,
|
||||||
if (
|
data: CreateProjectDto,
|
||||||
limit !== UNLIMITED_LICENSE_QUOTA &&
|
trx: EntityManager,
|
||||||
limit <= (await this.projectRepository.count({ where: { type: 'team' } }))
|
) {
|
||||||
) {
|
const limit = this.licenseState.getMaxTeamProjects();
|
||||||
throw new TeamProjectOverQuotaError(limit);
|
if (limit !== UNLIMITED_LICENSE_QUOTA) {
|
||||||
|
const teamProjectCount = await trx.count(Project, { where: { type: 'team' } });
|
||||||
|
if (teamProjectCount >= limit) {
|
||||||
|
throw new TeamProjectOverQuotaError(limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await this.projectRepository.save(
|
const project = await trx.save(
|
||||||
|
Project,
|
||||||
this.projectRepository.create({ ...data, type: 'team' }),
|
this.projectRepository.create({ ...data, type: 'team' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Link admin
|
// Link admin
|
||||||
await this.addUser(project.id, adminUser.id, 'project:admin');
|
await this.addUser(project.id, adminUser.id, 'project:admin', trx);
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createTeamProject(adminUser: User, data: CreateProjectDto): Promise<Project> {
|
||||||
|
if (this.databaseConfig.isLegacySqlite) {
|
||||||
|
// Using transaction in the sqlite legacy driver can cause data loss, so
|
||||||
|
// we avoid this here.
|
||||||
|
return await this.createTeamProjectWithEntityManager(
|
||||||
|
adminUser,
|
||||||
|
data,
|
||||||
|
this.projectRepository.manager,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// This needs to be SERIALIZABLE otherwise the count would not block a
|
||||||
|
// concurrent transaction and we could insert multiple projects.
|
||||||
|
return await this.projectRepository.manager.transaction('SERIALIZABLE', async (trx) => {
|
||||||
|
return await this.createTeamProjectWithEntityManager(adminUser, data, trx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateProject(
|
async updateProject(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
data: Pick<UpdateProjectDto, 'name' | 'icon'>,
|
data: Pick<UpdateProjectDto, 'name' | 'icon'>,
|
||||||
@@ -252,11 +277,11 @@ export class ProjectService {
|
|||||||
private isProjectRoleLicensed(role: ProjectRole) {
|
private isProjectRoleLicensed(role: ProjectRole) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'project:admin':
|
case 'project:admin':
|
||||||
return this.license.isProjectRoleAdminLicensed();
|
return this.licenseState.isProjectRoleAdminLicensed();
|
||||||
case 'project:editor':
|
case 'project:editor':
|
||||||
return this.license.isProjectRoleEditorLicensed();
|
return this.licenseState.isProjectRoleEditorLicensed();
|
||||||
case 'project:viewer':
|
case 'project:viewer':
|
||||||
return this.license.isProjectRoleViewerLicensed();
|
return this.licenseState.isProjectRoleViewerLicensed();
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -326,8 +351,9 @@ export class ProjectService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addUser(projectId: string, userId: string, role: ProjectRole) {
|
async addUser(projectId: string, userId: string, role: ProjectRole, trx?: EntityManager) {
|
||||||
return await this.projectRelationRepository.save({
|
trx = trx ?? this.projectRelationRepository.manager;
|
||||||
|
return await trx.save(ProjectRelation, {
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
role,
|
role,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import type { Project } from '@n8n/db';
|
import type { Project } from '@n8n/db';
|
||||||
import { FolderRepository } from '@n8n/db';
|
import { FolderRepository } from '@n8n/db';
|
||||||
import { ProjectRelationRepository } from '@n8n/db';
|
import { ProjectRelationRepository } from '@n8n/db';
|
||||||
@@ -432,6 +433,34 @@ describe('POST /projects/', () => {
|
|||||||
|
|
||||||
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(2);
|
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const globalConfig = Container.get(GlobalConfig);
|
||||||
|
// Preventing this relies on transactions and we can't use them with the
|
||||||
|
// sqlite legacy driver due to data loss risks.
|
||||||
|
if (!globalConfig.database.isLegacySqlite) {
|
||||||
|
test('should respect the quota when trying to create multiple projects in parallel (no race conditions)', async () => {
|
||||||
|
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(0);
|
||||||
|
testServer.license.setQuota('quota:maxTeamProjects', 3);
|
||||||
|
const ownerUser = await createOwner();
|
||||||
|
const ownerAgent = testServer.authAgentFor(ownerUser);
|
||||||
|
await expect(
|
||||||
|
Container.get(ProjectRepository).count({ where: { type: 'team' } }),
|
||||||
|
).resolves.toBe(0);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
ownerAgent.post('/projects/').send({ name: 'Test Team Project 1' }),
|
||||||
|
ownerAgent.post('/projects/').send({ name: 'Test Team Project 2' }),
|
||||||
|
ownerAgent.post('/projects/').send({ name: 'Test Team Project 3' }),
|
||||||
|
ownerAgent.post('/projects/').send({ name: 'Test Team Project 4' }),
|
||||||
|
ownerAgent.post('/projects/').send({ name: 'Test Team Project 5' }),
|
||||||
|
ownerAgent.post('/projects/').send({ name: 'Test Team Project 6' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
Container.get(ProjectRepository).count({ where: { type: 'team' } }),
|
||||||
|
).resolves.toBe(3);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /projects/:projectId', () => {
|
describe('PATCH /projects/:projectId', () => {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { LicenseState } from '@n8n/backend-common';
|
||||||
import type { User } from '@n8n/db';
|
import type { User } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
import { License } from '@/license';
|
|
||||||
import { ProjectService } from '@/services/project.service.ee';
|
import { ProjectService } from '@/services/project.service.ee';
|
||||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||||
|
|
||||||
import { createUser } from '../shared/db/users';
|
import { createUser } from '../shared/db/users';
|
||||||
import { createWorkflow, shareWorkflowWithUsers } from '../shared/db/workflows';
|
import { createWorkflow, shareWorkflowWithUsers } from '../shared/db/workflows';
|
||||||
import { LicenseMocker } from '../shared/license';
|
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
|
|
||||||
let owner: User;
|
let owner: User;
|
||||||
@@ -21,11 +21,10 @@ beforeAll(async () => {
|
|||||||
owner = await createUser({ role: 'global:owner' });
|
owner = await createUser({ role: 'global:owner' });
|
||||||
member = await createUser({ role: 'global:member' });
|
member = await createUser({ role: 'global:member' });
|
||||||
anotherMember = await createUser({ role: 'global:member' });
|
anotherMember = await createUser({ role: 'global:member' });
|
||||||
let license: LicenseMocker;
|
const licenseMock = mock<LicenseState>();
|
||||||
license = new LicenseMocker();
|
licenseMock.isSharingLicensed.mockReturnValue(true);
|
||||||
license.mock(Container.get(License));
|
licenseMock.getMaxTeamProjects.mockReturnValue(-1);
|
||||||
license.enable('feat:sharing');
|
Container.set(LicenseState, licenseMock);
|
||||||
license.setQuota('quota:maxTeamProjects', -1);
|
|
||||||
workflowSharingService = Container.get(WorkflowSharingService);
|
workflowSharingService = Container.get(WorkflowSharingService);
|
||||||
projectService = Container.get(ProjectService);
|
projectService = Container.get(ProjectService);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user