fix(core): Separate scopes for project and global data store list endpoints (no-changelog) (#18394)

This commit is contained in:
Jaakko Husso
2025-08-15 14:13:51 +03:00
committed by GitHub
parent 17ffa2edc5
commit d59cfed74a
7 changed files with 371 additions and 10 deletions

View File

@@ -26,7 +26,7 @@ export const RESOURCES = {
folder: [...DEFAULT_OPERATIONS, 'move'] as const,
insights: ['list'] as const,
oidc: ['manage'] as const,
dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow'] as const,
dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
} as const;
export const API_KEY_RESOURCES = {

View File

@@ -79,6 +79,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'insights:list',
'folder:move',
'oidc:manage',
'dataStore:list',
];
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
@@ -98,4 +99,5 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [
'user:list',
'variable:list',
'variable:read',
'dataStore:list',
];

View File

@@ -36,7 +36,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
'dataStore:delete',
'dataStore:read',
'dataStore:update',
'dataStore:list',
'dataStore:listProject',
'dataStore:readRow',
'dataStore:writeRow',
];
@@ -69,7 +69,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
'dataStore:delete',
'dataStore:read',
'dataStore:update',
'dataStore:list',
'dataStore:listProject',
'dataStore:readRow',
'dataStore:writeRow',
];
@@ -97,7 +97,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
'dataStore:delete',
'dataStore:read',
'dataStore:update',
'dataStore:list',
'dataStore:listProject',
'dataStore:readRow',
'dataStore:writeRow',
];
@@ -111,7 +111,7 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [
'workflow:read',
'folder:read',
'folder:list',
'dataStore:list',
'dataStore:listProject',
'dataStore:read',
'dataStore:readRow',
];

View File

@@ -0,0 +1,358 @@
import type { DataStore } from '@n8n/api-types';
import {
createTeamProject,
getPersonalProject,
linkUserToProject,
testDb,
} from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import { createDataStore } from '@test-integration/db/data-stores';
import { createOwner, createMember, createAdmin } from '@test-integration/db/users';
import type { SuperAgentTest } from '@test-integration/types';
import * as utils from '@test-integration/utils';
import { DateTime } from 'luxon';
let owner: User;
let member: User;
let admin: User;
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
let authAdminAgent: SuperAgentTest;
let ownerProject: Project;
let memberProject: Project;
const testServer = utils.setupTestServer({
endpointGroups: ['data-store'],
modules: ['data-store'],
});
beforeAll(async () => {
await testDb.init();
});
beforeEach(async () => {
await testDb.truncate(['DataStore', 'DataStoreColumn', 'Project', 'ProjectRelation']);
owner = await createOwner();
member = await createMember();
admin = await createAdmin();
authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(member);
authAdminAgent = testServer.authAgentFor(admin);
ownerProject = await getPersonalProject(owner);
memberProject = await getPersonalProject(member);
});
afterAll(async () => {
await testDb.terminate();
});
describe('GET /data-stores-global', () => {
test('should not list data stores when no data stores exist', async () => {
const response = await authOwnerAgent.get('/data-stores-global').expect(200);
expect(response.body.data.count).toBe(0);
expect(response.body.data.data).toHaveLength(0);
});
test('should not list data stores from projects member has no access to', async () => {
const project = await createTeamProject('test project', owner);
await createDataStore(project, { name: 'Test Data Store' });
const response = await authMemberAgent.get('/data-stores-global').expect(200);
expect(response.body.data.count).toBe(0);
expect(response.body.data.data).toHaveLength(0);
});
test('should not list data stores from projects admin has no access to', async () => {
const project = await createTeamProject('test project', owner);
await createDataStore(project, { name: 'Test Data Store' });
const response = await authAdminAgent.get('/data-stores-global').expect(200);
expect(response.body.data.count).toBe(0);
expect(response.body.data.data).toHaveLength(0);
});
test("should not list data stores from another user's personal project", async () => {
await createDataStore(ownerProject, { name: 'Personal Data Store' });
const response = await authAdminAgent.get('/data-stores-global').expect(200);
expect(response.body.data.count).toBe(0);
expect(response.body.data.data).toHaveLength(0);
});
test('should list data stores from team projects where user has project:viewer role', async () => {
const project = await createTeamProject('test project', owner);
await linkUserToProject(member, project, 'project:viewer');
await createDataStore(project, { name: 'Test Data Store' });
const response = await authMemberAgent.get('/data-stores-global').expect(200);
expect(response.body.data.count).toBe(1);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0].name).toBe('Test Data Store');
});
test("should list data stores from user's own personal project", async () => {
await createDataStore(ownerProject, { name: 'Personal Data Store 1' });
await createDataStore(ownerProject, { name: 'Personal Data Store 2' });
const response = await authOwnerAgent.get('/data-stores-global').expect(200);
expect(response.body.data.count).toBe(2);
expect(response.body.data.data).toHaveLength(2);
expect(response.body.data.data.map((f: DataStore) => f.name).sort()).toEqual(
['Personal Data Store 1', 'Personal Data Store 2'].sort(),
);
});
test('should filter data stores by projectId', async () => {
await createDataStore(ownerProject, { name: 'Test Data Store 1' });
await createDataStore(ownerProject, { name: 'Test Data Store 2' });
await createDataStore(memberProject, { name: 'Another Data Store' });
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ filter: JSON.stringify({ projectId: ownerProject.id }) })
.expect(200);
expect(response.body.data.count).toBe(2);
expect(response.body.data.data).toHaveLength(2);
expect(response.body.data.data.map((f: DataStore) => f.name).sort()).toEqual(
['Test Data Store 1', 'Test Data Store 2'].sort(),
);
});
test('should not list projects the user cant access even with project filters', async () => {
await createDataStore(ownerProject, { name: 'Test Data Store 1' });
await createDataStore(ownerProject, { name: 'Test Data Store 2' });
await createDataStore(memberProject, { name: 'Another Data Store' });
const response = await authMemberAgent
.get('/data-stores-global')
.query({ filter: JSON.stringify({ projectId: ownerProject.id }) })
.expect(200);
expect(response.body.data.count).toBe(0);
expect(response.body.data.data).toHaveLength(0);
});
test('should filter data stores by name', async () => {
const project = await createTeamProject('test project', owner);
await createDataStore(ownerProject, { name: 'Test Data Store' });
await createDataStore(ownerProject, { name: 'Another Data Store' });
await createDataStore(project, { name: 'Test Something Else' });
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ filter: JSON.stringify({ name: 'test' }) })
.expect(200);
expect(response.body.data.count).toBe(2);
expect(response.body.data.data).toHaveLength(2);
expect(response.body.data.data.map((f: any) => f.name).sort()).toEqual(
['Test Data Store', 'Test Something Else'].sort(),
);
});
test('should filter data stores by id', async () => {
const dataStore1 = await createDataStore(ownerProject, { name: 'Data Store 1' });
await createDataStore(ownerProject, { name: 'Data Store 2' });
await createDataStore(ownerProject, { name: 'Data Store 3' });
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ filter: JSON.stringify({ id: dataStore1.id }) })
.expect(200);
expect(response.body.data.count).toBe(1);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0].name).toBe('Data Store 1');
});
test('should filter data stores by multiple names (AND operator)', async () => {
const project = await createTeamProject('test project', owner);
await createDataStore(ownerProject, { name: 'Data Store' });
await createDataStore(ownerProject, { name: 'Test Store' });
await createDataStore(project, { name: 'Another Store' });
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ filter: JSON.stringify({ name: ['Store', 'Test'] }) })
.expect(200);
expect(response.body.data.count).toBe(1);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0].name).toBe('Test Store');
});
test('should apply pagination with take parameter', async () => {
const project = await createTeamProject('test project', owner);
for (let i = 1; i <= 5; i++) {
await createDataStore(i % 2 ? ownerProject : project, {
name: `Data Store ${i}`,
updatedAt: DateTime.now()
.minus({ minutes: 6 - i })
.toJSDate(),
});
}
const response = await authOwnerAgent.get('/data-stores-global').query({ take: 3 }).expect(200);
expect(response.body.data.count).toBe(5); // Total count should be 5
expect(response.body.data.data).toHaveLength(3); // But only 3 returned
expect(response.body.data.data.map((store: DataStore) => store.name)).toEqual([
'Data Store 5',
'Data Store 4',
'Data Store 3',
]);
});
test('should apply pagination with skip parameter', async () => {
const project = await createTeamProject('test project', owner);
for (let i = 1; i <= 5; i++) {
await createDataStore(i % 2 ? ownerProject : project, {
name: `Data Store ${i}`,
updatedAt: DateTime.now()
.minus({ minutes: 6 - i })
.toJSDate(),
});
}
const response = await authOwnerAgent.get('/data-stores-global').query({ skip: 2 }).expect(200);
expect(response.body.data.count).toBe(5);
expect(response.body.data.data).toHaveLength(3);
expect(response.body.data.data.map((store: DataStore) => store.name)).toEqual([
'Data Store 3',
'Data Store 2',
'Data Store 1',
]);
});
test('should apply combined skip and take parameters', async () => {
const project = await createTeamProject('test project', owner);
for (let i = 1; i <= 5; i++) {
await createDataStore(i % 2 ? ownerProject : project, {
name: `Data Store ${i}`,
updatedAt: DateTime.now()
.minus({ minutes: 6 - i })
.toJSDate(),
});
}
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ skip: 1, take: 2 })
.expect(200);
expect(response.body.data.count).toBe(5);
expect(response.body.data.data).toHaveLength(2);
expect(response.body.data.data.map((store: DataStore) => store.name)).toEqual([
'Data Store 4',
'Data Store 3',
]);
});
test('should sort data stores by name ascending', async () => {
await createDataStore(ownerProject, { name: 'Z Data Store' });
await createDataStore(ownerProject, { name: 'A Data Store' });
await createDataStore(ownerProject, { name: 'M Data Store' });
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ sortBy: 'name:asc' })
.expect(200);
expect(response.body.data.data.map((store: DataStore) => store.name)).toEqual([
'A Data Store',
'M Data Store',
'Z Data Store',
]);
});
test('should sort data stores by name descending', async () => {
await createDataStore(ownerProject, { name: 'Z Data Store' });
await createDataStore(ownerProject, { name: 'A Data Store' });
await createDataStore(ownerProject, { name: 'M Data Store' });
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ sortBy: 'name:desc' })
.expect(200);
expect(response.body.data.data.map((f: DataStore) => f.name)).toEqual([
'Z Data Store',
'M Data Store',
'A Data Store',
]);
});
test('should sort data stores by updatedAt', async () => {
await createDataStore(ownerProject, {
name: 'Older Data Store',
updatedAt: DateTime.now().minus({ days: 2 }).toJSDate(),
});
await createDataStore(ownerProject, {
name: 'Newest Data Store',
updatedAt: DateTime.now().toJSDate(),
});
await createDataStore(ownerProject, {
name: 'Middle Data Store',
updatedAt: DateTime.now().minus({ days: 1 }).toJSDate(),
});
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ sortBy: 'updatedAt:desc' })
.expect(200);
expect(response.body.data.data.map((f: DataStore) => f.name)).toEqual([
'Newest Data Store',
'Middle Data Store',
'Older Data Store',
]);
});
test('should combine multiple query parameters correctly', async () => {
const dataStore1 = await createDataStore(ownerProject, { name: 'Test Data Store' });
await createDataStore(ownerProject, { name: 'Another Data Store' });
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ filter: JSON.stringify({ name: 'data', id: dataStore1.id }), sortBy: 'name:asc' })
.expect(200);
expect(response.body.data.count).toBe(1);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0].name).toBe('Test Data Store');
});
test('should include columns', async () => {
await createDataStore(ownerProject, {
name: 'Test Data Store',
columns: [
{
name: 'test-column-1',
type: 'string',
},
{
name: 'test-column-2',
type: 'boolean',
},
],
});
const response = await authOwnerAgent
.get('/data-stores-global')
.query({ filter: JSON.stringify({ name: 'test' }) })
.expect(200);
expect(response.body.data.count).toBe(1);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0].columns).toHaveLength(2);
});
});

View File

@@ -331,7 +331,8 @@ describe('GET /projects/:projectId/data-stores', () => {
await createDataStore(ownerProject, { name: 'Another Store' });
const response = await authOwnerAgent
.get(`/projects/${ownerProject.id}/data-stores?filter={ "name": ["Store", "Test"]}`)
.get(`/projects/${ownerProject.id}/data-stores`)
.query({ filter: JSON.stringify({ name: ['Store', 'Test'] }) })
.expect(200);
expect(response.body.data.count).toBe(1);

View File

@@ -1,6 +1,6 @@
import { ListDataStoreQueryDto } from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Get, ProjectScope, Query, RestController } from '@n8n/decorators';
import { Get, GlobalScope, Query, RestController } from '@n8n/decorators';
import { DataStoreAggregateService } from './data-store-aggregate.service';
@@ -9,7 +9,7 @@ export class DataStoreAggregateController {
constructor(private readonly dataStoreAggregateService: DataStoreAggregateService) {}
@Get('/')
@ProjectScope('dataStore:list')
@GlobalScope('dataStore:list')
async listDataStores(
req: AuthenticatedRequest,
_res: Response,

View File

@@ -58,8 +58,8 @@ export class DataStoreController {
}
@Get('/')
@ProjectScope('dataStore:list')
async listDataStores(
@ProjectScope('dataStore:listProject')
async listProjectDataStores(
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Query payload: ListDataStoreQueryDto,