fix(API): Skip projectRelations eager-load in GET /credentials (#37138)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Sam Wooler
2026-08-27 15:25:47 +00:00
committed by GitHub
parent 7e6cfed293
commit b3a77b0ba8
6 changed files with 54 additions and 6 deletions
@@ -146,6 +146,19 @@ describe('CredentialsRepository', () => {
expect(callArg!.order).toBeUndefined();
});
it('should honor a caller-provided relations array', async () => {
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
await credentialsRepository.findManyAndCount({
take: 10,
skip: 0,
relations: ['shared', 'shared.project'],
});
const callArg = entityManager.findAndCount.mock.calls[0]?.[1];
expect(callArg?.relations).toEqual(['shared', 'shared.project']);
});
it('should apply credentialIds filter when provided', async () => {
entityManager.findAndCount.mockResolvedValueOnce([[], 0]);
@@ -18,9 +18,21 @@ import { parseListQuerySortBy } from '../utils/list-query-sort';
const SORTABLE_COLUMNS = new Set(['id', 'name', 'createdAt', 'updatedAt']);
export type CredentialSharingRelation =
| 'shared'
| 'shared.project'
| 'shared.project.projectRelations';
const DEFAULT_CREDENTIAL_RELATIONS: CredentialSharingRelation[] = [
'shared',
'shared.project',
'shared.project.projectRelations',
];
type CredentialsListQueryOptions = ListQuery.Options & {
includeData?: boolean;
user?: User;
relations?: CredentialSharingRelation[];
};
@Service()
@@ -149,7 +161,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
type Select = Array<keyof CredentialsEntity>;
const defaultRelations = ['shared', 'shared.project', 'shared.project.projectRelations'];
const relations = listQueryOptions?.relations ?? DEFAULT_CREDENTIAL_RELATIONS;
const defaultSelect: Select = [
'id',
'name',
@@ -165,7 +177,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
if (!listQueryOptions) {
return {
select: defaultSelect,
relations: defaultRelations,
relations,
} as FindManyOptions<CredentialsEntity>;
}
@@ -197,7 +209,7 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
if (!findManyOptions.select) {
findManyOptions.select = defaultSelect;
findManyOptions.relations = defaultRelations;
findManyOptions.relations = relations;
}
if (sortBy) {
@@ -525,7 +537,6 @@ export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
// Apply relations
if (!options.select) {
// Only add relations if using default select
qb.leftJoinAndSelect('credential.shared', 'shared')
.leftJoinAndSelect('shared.project', 'project')
.leftJoinAndSelect('project.projectRelations', 'projectRelations');
+1 -1
View File
@@ -10,7 +10,7 @@ export { AuthIdentityRepository } from './auth-identity.repository';
export { AuthProviderSyncHistoryRepository } from './auth-provider-sync-history.repository';
export { BaseRepository } from './base-repository';
export { BinaryDataRepository } from './binary-data.repository';
export { CredentialsRepository } from './credentials.repository';
export { CredentialsRepository, type CredentialSharingRelation } from './credentials.repository';
export { CredentialDependencyRepository } from './credential-dependency.repository';
export {
DeploymentKeyRepository,
@@ -19,6 +19,7 @@ import type {
ICredentialsDb,
ScopesField,
OperationContext,
CredentialSharingRelation,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
@@ -108,7 +109,9 @@ type PrepareUpdateDataOptions = {
};
type GetManyOptions = {
listQueryOptions?: ListQuery.Options;
listQueryOptions?: ListQuery.Options & {
relations?: CredentialSharingRelation[];
};
includeScopes?: boolean;
includeData?: boolean;
onlySharedWithMe?: boolean;
@@ -131,6 +131,8 @@ const credentialsHandlers: CredentialsHandlers = {
take: limit,
skip: offset,
sortBy: 'createdAt:desc',
// skip eager-loading shared.project.projectRelations to avoid query fan-out
relations: ['shared', 'shared.project'],
},
},
);
@@ -374,6 +374,25 @@ describe('GET /credentials', () => {
);
});
test('should return correct shared info for a credential in a team project with multiple members', async () => {
const teamProject = await createTeamProject('multi-member-project', owner);
await linkUserToProject(member, teamProject, 'project:editor');
const saved = await saveCredential(dbCredential(), { project: teamProject });
const response = await authOwnerAgent.get('/credentials');
expect(response.statusCode).toBe(200);
const item = response.body.data.find((c: { id: string }) => c.id === saved.id);
expect(item).toBeDefined();
expect(item.shared).toEqual([
expect.objectContaining({
id: teamProject.id,
name: teamProject.name,
role: 'credential:owner',
}),
]);
});
test('should return empty list when no credentials exist', async () => {
const response = await authOwnerAgent.get('/credentials');