feat(core): Use personal projects for chat hub executions (no-changelog) (#22231)

This commit is contained in:
Jaakko Husso 2025-11-25 16:47:27 +02:00 committed by GitHub
parent be598062fb
commit 151fcf1137
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 259 additions and 166 deletions

View File

@ -1,20 +1,37 @@
import type { User } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { ChatHubLLMProvider } from '@n8n/api-types';
import type {
CredentialsEntity,
Project,
ProjectRepository,
SharedWorkflowRepository,
User,
} from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import type { INodeCredentials } from 'n8n-workflow';
import {
ChatHubCredentialsService,
type CredentialWithProjectId,
} from '../chat-hub-credentials.service';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { ChatHubCredentialsService } from '../chat-hub-credentials.service';
import type { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { ChatHubLLMProvider } from '@n8n/api-types';
const CREDENTIAL_ID = 'credential-id-123';
const OTHER_CREDENTIAL_ID = 'other-credential-id-456';
const PERSONAL_PROJECT_ID = 'personal-project-id';
const OTHER_PROJECT_ID = 'other-project-id';
const GLOBAL_PROJECT_ID = 'global-project-id';
describe('ChatHubCredentialsService', () => {
const credentialsFinderService = mock<CredentialsFinderService>();
const service = new ChatHubCredentialsService(credentialsFinderService);
const credentialsService = mock<CredentialsService>();
const projectRepository = mock<ProjectRepository>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const service = new ChatHubCredentialsService(
credentialsService,
projectRepository,
sharedWorkflowRepository,
);
const mockUser = mock<User>({ id: 'user-123' });
const mockTrx = mock<EntityManager>();
@ -25,49 +42,70 @@ describe('ChatHubCredentialsService', () => {
describe('ensureCredentials', () => {
it('should return credential when user has access and credential is found', async () => {
const mockCredential = mock<CredentialWithProjectId>({
id: 'cred-123',
const mockCredential = mock({
id: CREDENTIAL_ID,
projectId: PERSONAL_PROJECT_ID,
name: 'OpenAI Credentials',
type: 'openAiApi',
projectId: 'project-456',
scopes: ['credential:read'],
isManaged: false,
isGlobal: false,
});
const credentials: INodeCredentials = {
openAiApi: { id: 'cred-123', name: 'OpenAI Credentials' },
openAiApi: { id: CREDENTIAL_ID, name: 'OpenAI Credentials' },
};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
projectRepository.getPersonalProjectForUser.mockResolvedValue({
id: PERSONAL_PROJECT_ID,
name: 'Personal Project',
} as Project);
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([mockCredential]);
const result = await service.ensureCredentials(
mockUser,
'openai' as ChatHubLLMProvider,
'openai' satisfies ChatHubLLMProvider,
credentials,
mockTrx,
);
expect(result).toEqual(mockCredential);
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
mockUser,
['credential:read'],
expect(projectRepository.getPersonalProjectForUser).toHaveBeenCalledWith(
mockUser.id,
mockTrx,
{ includeGlobalCredentials: true },
);
expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).toHaveBeenCalledWith(
mockUser,
{ projectId: PERSONAL_PROJECT_ID },
);
expect(result).toEqual({
id: mockCredential.id,
projectId: mockCredential.projectId,
});
});
it('should include global credentials when fetching credentials', async () => {
const mockGlobalCredential = mock<CredentialWithProjectId>({
id: 'global-cred-123',
const mockGlobalCredential = mock({
id: CREDENTIAL_ID,
projectId: GLOBAL_PROJECT_ID,
name: 'Global OpenAI Credentials',
type: 'openAiApi',
scopes: ['credential:read'],
isManaged: false,
isGlobal: true,
projectId: 'project-global',
});
const credentials: INodeCredentials = {
openAiApi: { id: 'global-cred-123', name: 'Global OpenAI Credentials' },
openAiApi: { id: CREDENTIAL_ID, name: 'Global OpenAI Credentials' },
};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockGlobalCredential]);
projectRepository.getPersonalProjectForUser.mockResolvedValue({
id: PERSONAL_PROJECT_ID,
name: 'Personal Project',
} as Project);
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
mockGlobalCredential,
]);
const result = await service.ensureCredentials(
mockUser,
@ -76,20 +114,24 @@ describe('ChatHubCredentialsService', () => {
mockTrx,
);
expect(result).toEqual(mockGlobalCredential);
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
mockUser,
['credential:read'],
expect(projectRepository.getPersonalProjectForUser).toHaveBeenCalledWith(
mockUser.id,
mockTrx,
{ includeGlobalCredentials: true },
);
expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).toHaveBeenCalledWith(
mockUser,
{ projectId: PERSONAL_PROJECT_ID },
);
expect(result).toEqual({
id: mockGlobalCredential.id,
projectId: PERSONAL_PROJECT_ID,
});
});
it('should throw BadRequestError when no credentials are provided', async () => {
const credentials: INodeCredentials = {};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
await expect(
service.ensureCredentials(mockUser, 'openai' as ChatHubLLMProvider, credentials, mockTrx),
).rejects.toThrow(BadRequestError);
@ -99,32 +141,46 @@ describe('ChatHubCredentialsService', () => {
});
it('should throw ForbiddenError when user does not have access to the credential', async () => {
const mockCredential = mock<CredentialWithProjectId>({
id: 'other-cred-456',
const mockCredential = mock({
id: OTHER_CREDENTIAL_ID,
projectId: OTHER_PROJECT_ID,
name: 'Other Credentials',
type: 'openAiApi',
projectId: 'project-other',
scopes: ['credential:read'],
isManaged: false,
isGlobal: false,
});
const credentials: INodeCredentials = {
openAiApi: { id: 'cred-123', name: 'OpenAI Credentials' },
};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
projectRepository.getPersonalProjectForUser.mockResolvedValue({
id: PERSONAL_PROJECT_ID,
name: 'Personal Project',
} as Project);
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([mockCredential]);
await expect(
service.ensureCredentials(mockUser, 'openai' as ChatHubLLMProvider, credentials, mockTrx),
).rejects.toThrow(ForbiddenError);
).rejects.toThrow(new ForbiddenError("You don't have access to the provided credentials"));
});
it('should throw ForbiddenError if personal project is not found', async () => {
const credentials: INodeCredentials = {
openAiApi: { id: 'cred-123', name: 'OpenAI Credentials' },
};
projectRepository.getPersonalProjectForUser.mockResolvedValue(null);
await expect(
service.ensureCredentials(mockUser, 'openai' as ChatHubLLMProvider, credentials, mockTrx),
).rejects.toThrow("You don't have access to the provided credentials");
).rejects.toThrow(new ForbiddenError('Missing personal project'));
});
it('should handle n8n provider by returning null credential ID', async () => {
const credentials: INodeCredentials = {};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
await expect(
service.ensureCredentials(mockUser, 'n8n' as ChatHubLLMProvider, credentials, mockTrx),
).rejects.toThrow(BadRequestError);
@ -133,8 +189,6 @@ describe('ChatHubCredentialsService', () => {
it('should handle custom-agent provider by returning null credential ID', async () => {
const credentials: INodeCredentials = {};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
await expect(
service.ensureCredentials(
mockUser,
@ -144,101 +198,100 @@ describe('ChatHubCredentialsService', () => {
),
).rejects.toThrow(BadRequestError);
});
});
it('should return first credential when credential is shared through multiple projects', async () => {
const mockCredential = mock<CredentialWithProjectId>({
id: 'cred-123',
name: 'Shared Credentials',
describe('ensureWorkflowCredentials', () => {
it('should return credential when workflow can use the credential', async () => {
const mockCredential = mock({
id: CREDENTIAL_ID,
projectId: PERSONAL_PROJECT_ID,
name: 'OpenAI Credentials',
type: 'openAiApi',
projectId: 'project-1',
scopes: ['credential:read'],
isManaged: false,
isGlobal: false,
});
const credentials: INodeCredentials = {
openAiApi: { id: 'cred-123', name: 'Shared Credentials' },
openAiApi: { id: CREDENTIAL_ID, name: 'OpenAI Credentials' },
};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
sharedWorkflowRepository.getWorkflowOwningProject.mockResolvedValue({
id: PERSONAL_PROJECT_ID,
} as Project);
credentialsService.findAllCredentialIdsForWorkflow.mockResolvedValue([
{ id: CREDENTIAL_ID },
{ id: OTHER_CREDENTIAL_ID },
] as CredentialsEntity[]);
const result = await service.ensureCredentials(
mockUser,
'openai' as ChatHubLLMProvider,
const result = await service.ensureWorkflowCredentials(
'openai' satisfies ChatHubLLMProvider,
credentials,
mockTrx,
'workflow-123',
);
expect(result).toEqual(mockCredential);
expect(result).toHaveProperty('projectId');
});
});
expect(sharedWorkflowRepository.getWorkflowOwningProject).toHaveBeenCalledWith(
'workflow-123',
);
expect(credentialsService.findAllCredentialIdsForWorkflow).toHaveBeenCalledWith(
'workflow-123',
);
describe('ensureCredentialById', () => {
it('should return credential when user has access to the credential', async () => {
const mockCredential = mock<CredentialWithProjectId>({
id: 'cred-123',
name: 'OpenAI Credentials',
type: 'openAiApi',
projectId: 'project-456',
expect(result).toEqual({
id: mockCredential.id,
projectId: mockCredential.projectId,
});
});
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
it('should throw BadRequestError when no credentials are provided for workflow', async () => {
const credentials: INodeCredentials = {};
const result = await service.ensureCredentialById(mockUser, 'cred-123', mockTrx);
expect(result).toEqual(mockCredential);
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
mockUser,
['credential:read'],
mockTrx,
{ includeGlobalCredentials: true },
await expect(
service.ensureWorkflowCredentials(
'openai' as ChatHubLLMProvider,
credentials,
'workflow-123',
),
).rejects.toThrow(
new BadRequestError('No credentials provided for the selected model provider'),
);
});
it('should include global credentials when fetching by ID', async () => {
const mockGlobalCredential = mock<CredentialWithProjectId>({
id: 'global-cred-123',
name: 'Global OpenAI Credentials',
type: 'openAiApi',
isGlobal: true,
projectId: 'project-global',
});
it('should throw ForbiddenError when workflow does not have access to the credential', async () => {
const credentials: INodeCredentials = {
openAiApi: { id: CREDENTIAL_ID, name: 'OpenAI Credentials' },
};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockGlobalCredential]);
sharedWorkflowRepository.getWorkflowOwningProject.mockResolvedValue({
id: PERSONAL_PROJECT_ID,
} as Project);
credentialsService.findAllCredentialIdsForWorkflow.mockResolvedValue([
{ id: OTHER_CREDENTIAL_ID },
] as CredentialsEntity[]);
const result = await service.ensureCredentialById(mockUser, 'global-cred-123', mockTrx);
expect(result).toEqual(mockGlobalCredential);
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
mockUser,
['credential:read'],
mockTrx,
{ includeGlobalCredentials: true },
);
await expect(
service.ensureWorkflowCredentials(
'openai' satisfies ChatHubLLMProvider,
credentials,
'workflow-123',
),
).rejects.toThrow(new ForbiddenError("You don't have access to the provided credentials"));
});
it('should throw ForbiddenError when user does not have access to the credential', async () => {
const mockCredential = mock<CredentialWithProjectId>({
id: 'other-cred-456',
name: 'Other Credentials',
type: 'openAiApi',
projectId: 'project-other',
});
it('should throw ForbiddenError if workflow owning project is not found', async () => {
const credentials: INodeCredentials = {
openAiApi: { id: CREDENTIAL_ID, name: 'OpenAI Credentials' },
};
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
sharedWorkflowRepository.getWorkflowOwningProject.mockResolvedValue(undefined);
await expect(service.ensureCredentialById(mockUser, 'cred-123', mockTrx)).rejects.toThrow(
ForbiddenError,
);
await expect(service.ensureCredentialById(mockUser, 'cred-123', mockTrx)).rejects.toThrow(
"You don't have access to the provided credentials",
);
});
it('should throw ForbiddenError when credential is not found', async () => {
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
await expect(service.ensureCredentialById(mockUser, 'cred-123', mockTrx)).rejects.toThrow(
ForbiddenError,
);
await expect(
service.ensureWorkflowCredentials(
'openai' satisfies ChatHubLLMProvider,
credentials,
'workflow-123',
),
).rejects.toThrow(new ForbiddenError('Missing owner project for the workflow'));
});
});
});

View File

@ -2,14 +2,14 @@ import { ChatModelsResponse } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { INode } from 'n8n-workflow';
import { v4 as uuidv4 } from 'uuid';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { ChatHubAgent } from './chat-hub-agent.entity';
import { ChatHubAgentRepository } from './chat-hub-agent.repository';
import { ChatHubCredentialsService } from './chat-hub-credentials.service';
import { INode } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@Service()
export class ChatHubAgentService {

View File

@ -3,64 +3,60 @@ import {
PROVIDER_CREDENTIAL_TYPE_MAP,
type ChatHubConversationModel,
} from '@n8n/api-types';
import type { User, CredentialsEntity } from '@n8n/db';
import { type User, ProjectRepository } from '@n8n/db';
import { SharedWorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
import type { INodeCredentials } from 'n8n-workflow';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
export type CredentialWithProjectId = CredentialsEntity & { projectId: string };
@Service()
export class ChatHubCredentialsService {
constructor(private readonly credentialsFinderService: CredentialsFinderService) {}
constructor(
private readonly credentialsService: CredentialsService,
private readonly projectRepository: ProjectRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {}
async ensureCredentials(
user: User,
provider: ChatHubLLMProvider,
credentials: INodeCredentials,
trx?: EntityManager,
): Promise<CredentialWithProjectId> {
const allCredentials = await this.credentialsFinderService.findAllCredentialsForUser(
user,
['credential:read'],
trx,
{ includeGlobalCredentials: true },
);
) {
const credentialId = this.pickCredentialId(provider, credentials);
if (!credentialId) {
throw new BadRequestError('No credentials provided for the selected model provider');
}
// If credential is shared through multiple projects just pick the first one.
const credential = allCredentials.find((c) => c.id === credentialId);
if (!credential) {
throw new ForbiddenError("You don't have access to the provided credentials");
}
return credential as CredentialWithProjectId;
return await this.ensureCredentialById(user, credentialId, trx);
}
async ensureCredentialById(
user: User,
credentialId: string,
trx?: EntityManager,
): Promise<CredentialWithProjectId> {
const allCredentials = await this.credentialsFinderService.findAllCredentialsForUser(
async ensureCredentialById(user: User, credentialId: string, trx?: EntityManager) {
const project = await this.projectRepository.getPersonalProjectForUser(user.id, trx);
if (!project) {
throw new ForbiddenError('Missing personal project');
}
const allCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(
user,
['credential:read'],
trx,
{ includeGlobalCredentials: true },
{
projectId: project.id,
},
);
const credential = allCredentials.find((c) => c.id === credentialId);
if (!credential) {
throw new ForbiddenError("You don't have access to the provided credentials");
}
return credential as CredentialWithProjectId;
return {
id: credential.id,
projectId: project.id,
};
}
private pickCredentialId(
@ -73,4 +69,33 @@ export class ChatHubCredentialsService {
return credentials[PROVIDER_CREDENTIAL_TYPE_MAP[provider]]?.id ?? null;
}
async ensureWorkflowCredentials(
provider: ChatHubLLMProvider,
credentials: INodeCredentials,
workflowId: string,
) {
const credentialId = this.pickCredentialId(provider, credentials);
if (!credentialId) {
throw new BadRequestError('No credentials provided for the selected model provider');
}
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflowId);
if (!project) {
throw new ForbiddenError('Missing owner project for the workflow');
}
const allCredentials =
await this.credentialsService.findAllCredentialIdsForWorkflow(workflowId);
const credential = allCredentials.find((c) => c.id === credentialId);
if (!credential) {
throw new ForbiddenError("You don't have access to the provided credentials");
}
return {
id: credential.id,
projectId: project.id,
};
}
}

View File

@ -52,7 +52,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { ChatHubAgentService } from './chat-hub-agent.service';
import { ChatHubCredentialsService, CredentialWithProjectId } from './chat-hub-credentials.service';
import { ChatHubCredentialsService } from './chat-hub-credentials.service';
import type { ChatHubMessage } from './chat-hub-message.entity';
import { ChatHubWorkflowService } from './chat-hub-workflow.service';
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
@ -1688,7 +1688,7 @@ export class ChatHubService {
): Promise<{
resolvedCredentials: INodeCredentials;
resolvedModel: ChatHubConversationModel;
credential: CredentialWithProjectId;
credential: { id: string; projectId: string };
}> {
if (model.provider === 'n8n') {
return await this.resolveFromN8nWorkflow(user, model, trx);
@ -1714,18 +1714,18 @@ export class ChatHubService {
private async resolveFromN8nWorkflow(
user: User,
model: ChatHubN8nModel,
{ workflowId }: ChatHubN8nModel,
trx: EntityManager,
): Promise<{
resolvedCredentials: INodeCredentials;
resolvedModel: ChatHubConversationModel;
credential: CredentialWithProjectId;
credential: { id: string; projectId: string };
}> {
const workflowEntity = await this.workflowFinderService.findWorkflowForUser(
model.workflowId,
workflowId,
user,
['workflow:read'],
{ includeTags: false, includeParentFolder: false },
{ includeTags: false, includeParentFolder: false, em: trx },
);
if (!workflowEntity) {
@ -1762,11 +1762,10 @@ export class ChatHubService {
);
}
const credential = await this.chatHubCredentialsService.ensureCredentials(
user,
const credential = await this.chatHubCredentialsService.ensureWorkflowCredentials(
modelNode.provider,
llmCredentials,
trx,
workflowId,
);
const resolvedModel: ChatHubConversationModel = {
@ -1807,7 +1806,7 @@ export class ChatHubService {
): Promise<{
resolvedCredentials: INodeCredentials;
resolvedModel: ChatHubConversationModel;
credential: CredentialWithProjectId;
credential: { id: string; projectId: string };
}> {
const agent = await this.chatHubAgentService.getAgentById(model.agentId, user.id);
if (!agent) {

View File

@ -1,6 +1,8 @@
import { LOCAL_STORAGE_CHAT_HUB_CREDENTIALS } from '@/app/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { credentialsMapSchema, type CredentialsMap } from '@/features/ai/chatHub/chat.types';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import {
chatHubProviderSchema,
@ -8,7 +10,7 @@ import {
type ChatHubProvider,
} from '@n8n/api-types';
import { useLocalStorage } from '@vueuse/core';
import { computed, onMounted, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { isLlmProvider } from '../chat.utils';
/**
@ -18,6 +20,7 @@ export function useChatCredentials(userId: string) {
const isInitialized = ref(false);
const credentialsStore = useCredentialsStore();
const settingsStore = useSettingsStore();
const projectStore = useProjectsStore();
const selectedCredentials = useLocalStorage<CredentialsMap>(
LOCAL_STORAGE_CHAT_HUB_CREDENTIALS(userId),
@ -60,8 +63,7 @@ export function useChatCredentials(userId: string) {
// Use default credential from settings if available to the user
if (
settings &&
settings.credentialId &&
settings?.credentialId &&
availableCredentials.some((c) => c.id === settings.credentialId)
) {
return [provider, settings.credentialId];
@ -90,13 +92,27 @@ export function useChatCredentials(userId: string) {
selectedCredentials.value = { ...selectedCredentials.value, [provider]: id };
}
onMounted(async () => {
await Promise.all([
credentialsStore.fetchCredentialTypes(false),
credentialsStore.fetchAllCredentials(),
]);
isInitialized.value = true;
});
watch(
() => projectStore.personalProject,
async (personalProject) => {
if (personalProject) {
const hasGlobalCredentialRead = hasPermission(['rbac'], {
rbac: { scope: 'credential:read' },
});
await Promise.all([
credentialsStore.fetchCredentialTypes(false),
// For non-owner users only fetch credentials from personal project.
hasGlobalCredentialRead
? credentialsStore.fetchAllCredentials()
: credentialsStore.fetchAllCredentials(personalProject.id),
]);
isInitialized.value = true;
}
},
{ immediate: true },
);
return { credentialsByProvider, selectCredential };
}