diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index eb8f8b1eb4f12..de5669ae11afd 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -10,6 +10,7 @@ export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]'); +export const getProjectSettingsNameInput = () => cy.getByTestId('project-settings-name-input'); export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button'); export const getProjectSettingsCancelButton = () => cy.getByTestId('project-settings-cancel-button'); @@ -55,3 +56,11 @@ export function createCredential(name: string) { credentialsModal.actions.save(); credentialsModal.actions.close(); } + +export const actions = { + createProject: (name: string) => { + getAddProjectButton().click(); + getProjectSettingsNameInput().type(name); + getProjectSettingsSaveButton().click(); + }, +}; diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index cce375b05d8d6..15c63eb6c209f 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -1,4 +1,4 @@ -import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants'; +import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN, NOTION_NODE_NAME } from '../constants'; import { CredentialsModal, CredentialsPage, @@ -8,6 +8,7 @@ import { WorkflowsPage, } from '../pages'; import { getVisibleSelect } from '../utils'; +import * as projects from '../composables/projects'; /** * User U1 - Instance owner @@ -188,3 +189,177 @@ describe('Sharing', { disableAutoLogin: true }, () => { credentialsModal.actions.close(); }); }); + +describe('Credential Usage in Cross Shared Workflows', () => { + beforeEach(() => { + cy.resetDatabase(); + cy.enableFeature('advancedPermissions'); + cy.enableFeature('projectRole:admin'); + cy.enableFeature('projectRole:editor'); + cy.changeQuota('maxTeamProjects', -1); + cy.reload(); + cy.signinAsOwner(); + cy.visit(credentialsPage.url); + }); + + it('should only show credentials from the same team project', () => { + cy.enableFeature('advancedPermissions'); + cy.enableFeature('projectRole:admin'); + cy.enableFeature('projectRole:editor'); + cy.changeQuota('maxTeamProjects', -1); + + // Create a notion credential in the home project + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // Create a notion credential in one project + projects.actions.createProject('Development'); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // Create a notion credential in another project + projects.actions.createProject('Test'); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + // Create a workflow with a notion node in the same project + projects.getProjectTabWorkflows().click(); + workflowsPage.actions.createWorkflowFromCard(); + workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); + + // Only the credential in this project (+ the 'Create new' option) should + // be in the dropdown + workflowPage.getters.nodeCredentialsSelect().click(); + getVisibleSelect().find('li').should('have.length', 2); + }); + + it('should only show credentials in their personal project for members', () => { + cy.enableFeature('sharing'); + cy.reload(); + + // Create a notion credential as the owner + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // Create another notion credential as the owner, but share it with member + // 0 + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API', false); + credentialsModal.actions.changeTab('Sharing'); + credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email); + credentialsModal.actions.saveSharing(); + + // As the member, create a new notion credential and a workflow + cy.signinAsMember(); + cy.visit(credentialsPage.url); + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + cy.visit(workflowsPage.url); + workflowsPage.actions.createWorkflowFromCard(); + workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); + + // Only the own credential the shared one (+ the 'Create new' option) + // should be in the dropdown + workflowPage.getters.nodeCredentialsSelect().click(); + getVisibleSelect().find('li').should('have.length', 3); + }); + + it('should only show credentials in their personal project for members if the workflow was shared with them', () => { + const workflowName = 'Test workflow'; + cy.enableFeature('sharing'); + cy.reload(); + + // Create a notion credential as the owner and a workflow that is shared + // with member 0 + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + //cy.visit(workflowsPage.url); + projects.getProjectTabWorkflows().click(); + workflowsPage.actions.createWorkflowFromCard(); + workflowPage.actions.setWorkflowName(workflowName); + workflowPage.actions.openShareModal(); + workflowSharingModal.actions.addUser(INSTANCE_MEMBERS[0].email); + workflowSharingModal.actions.save(); + + // As the member, create a new notion credential + cy.signinAsMember(); + cy.visit(credentialsPage.url); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCard(workflowName).click(); + workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); + + // Only the own credential the shared one (+ the 'Create new' option) + // should be in the dropdown + workflowPage.getters.nodeCredentialsSelect().click(); + getVisibleSelect().find('li').should('have.length', 2); + }); + + it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => { + const workflowName = 'Test workflow'; + cy.enableFeature('sharing'); + + // As member 1, create a new notion credential. This should not show up. + cy.signinAsMember(1); + cy.visit(credentialsPage.url); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // As admin, create a new notion credential. This should show up. + cy.signinAsAdmin(); + cy.visit(credentialsPage.url); + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // As member 0, create a new notion credential and a workflow and share it + // with the global owner and the admin. + cy.signinAsMember(); + cy.visit(credentialsPage.url); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + cy.visit(workflowsPage.url); + workflowsPage.actions.createWorkflowFromCard(); + workflowPage.actions.setWorkflowName(workflowName); + workflowPage.actions.openShareModal(); + workflowSharingModal.actions.addUser(INSTANCE_OWNER.email); + workflowSharingModal.actions.addUser(INSTANCE_ADMIN.email); + workflowSharingModal.actions.save(); + + // As the global owner, create a new notion credential and open the shared + // workflow + cy.signinAsOwner(); + cy.visit(credentialsPage.url); + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCard(workflowName).click(); + workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); + + // Only the personal credentials of the workflow owner and the global owner + // should show up. + workflowPage.getters.nodeCredentialsSelect().click(); + getVisibleSelect().find('li').should('have.length', 4); + }); + + it('should show all personal credentials if the global owner owns the workflow', () => { + cy.enableFeature('sharing'); + + // As member 0, create a new notion credential. + cy.signinAsMember(); + cy.visit(credentialsPage.url); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // As the global owner, create a workflow and add a notion node + cy.signinAsOwner(); + cy.visit(workflowsPage.url); + workflowsPage.actions.createWorkflowFromCard(); + workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); + + // Show all personal credentials + workflowPage.getters.nodeCredentialsSelect().click(); + getVisibleSelect().find('li').should('have.have.length', 2); + }); +}); diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 6258a7698ecd7..26ba3024ade74 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -102,7 +102,7 @@ const switchBetweenEditorAndHistory = () => { const switchBetweenEditorAndWorkflowlist = () => { cy.getByTestId('menu-item').first().click(); - cy.wait(['@getUsers', '@getWorkflows', '@getActiveWorkflows', '@getCredentials']); + cy.wait(['@getUsers', '@getWorkflows', '@getActiveWorkflows', '@getProjects']); cy.getByTestId('resources-list-item').first().click(); @@ -197,7 +197,7 @@ describe('Editor zoom should work after route changes', () => { cy.intercept('GET', '/rest/users').as('getUsers'); cy.intercept('GET', '/rest/workflows?*').as('getWorkflows'); cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows'); - cy.intercept('GET', '/rest/credentials?*').as('getCredentials'); + cy.intercept('GET', '/rest/projects').as('getProjects'); switchBetweenEditorAndHistory(); zoomInAndCheckNodes(); diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index 9492f59bbe3fd..dff38d035fd8d 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -55,7 +55,7 @@ export class CredentialsModal extends BasePage { close: () => { this.getters.closeButton().click(); }, - fillCredentialsForm: () => { + fillCredentialsForm: (closeModal = true) => { this.getters.credentialsEditModal().should('be.visible'); this.getters.credentialInputs().should('have.length.greaterThan', 0); this.getters @@ -65,14 +65,23 @@ export class CredentialsModal extends BasePage { cy.wrap($el).type('test'); }); this.getters.saveButton().click(); - this.getters.closeButton().click(); + if (closeModal) { + this.getters.closeButton().click(); + } + }, + createNewCredential: (type: string, closeModal = true) => { + this.getters.newCredentialModal().should('be.visible'); + this.getters.newCredentialTypeSelect().should('be.visible'); + this.getters.newCredentialTypeOption(type).click(); + this.getters.newCredentialTypeButton().click(); + this.actions.fillCredentialsForm(closeModal); }, renameCredential: (newName: string) => { this.getters.nameInput().type('{selectall}'); this.getters.nameInput().type(newName); this.getters.nameInput().type('{enter}'); }, - changeTab: (tabName: string) => { + changeTab: (tabName: 'Sharing') => { this.getters.menuItem(tabName).click(); }, }; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 6e16f6c2b9598..cb0f42bdce744 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -32,10 +32,13 @@ declare global { * @param [workflowName] Optional name for the workflow. A random nanoid is used if not given */ createFixtureWorkflow(fixtureKey: string, workflowName?: string): void; - /** @deprecated */ + /** @deprecated use signinAsOwner, signinAsAdmin or signinAsMember instead */ signin(payload: SigninPayload): void; signinAsOwner(): void; signinAsAdmin(): void; + /** + * Omitting the index will default to index 0. + */ signinAsMember(index?: number): void; signout(): void; overrideSettings(value: Partial): void; diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 2dfa547a937a1..d75e6436b10cf 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -52,6 +52,14 @@ export class CredentialsController { }); } + @Get('/for-workflow') + async getProjectCredentials(req: CredentialRequest.ForWorkflow) { + const options = z + .union([z.object({ workflowId: z.string() }), z.object({ projectId: z.string() })]) + .parse(req.query); + return await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(req.user, options); + } + @Get('/new') async generateUniqueName(req: CredentialRequest.NewName) { const requestedName = req.query.name ?? config.getEnv('credentials.defaultName'); diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 08ee5aa454ee9..8b59dbdc720b6 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -36,6 +36,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { ProjectRelation } from '@/databases/entities/ProjectRelation'; import { RoleService } from '@/services/role.service'; +import { UserRepository } from '@/databases/repositories/user.repository'; export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } @@ -54,6 +55,7 @@ export class CredentialsService { private readonly projectRepository: ProjectRepository, private readonly projectService: ProjectService, private readonly roleService: RoleService, + private readonly userRepository: UserRepository, ) {} async getMany( @@ -145,6 +147,70 @@ export class CredentialsService { return credentials; } + /** + * @param user The user making the request + * @param options.workflowId The workflow that is being edited + * @param options.projectId The project owning the workflow This is useful + * for workflows that have not been saved yet. + */ + async getCredentialsAUserCanUseInAWorkflow( + user: User, + options: { workflowId: string } | { projectId: string }, + ) { + // necessary to get the scopes + const projectRelations = await this.projectService.getProjectRelationsForUser(user); + + // get all credentials the user has access to + const allCredentials = await this.credentialsRepository.findCredentialsForUser(user, [ + 'credential:read', + ]); + + // get all credentials the workflow or project has access to + const allCredentialsForWorkflow = + 'workflowId' in options + ? (await this.findAllCredentialIdsForWorkflow(options.workflowId)).map((c) => c.id) + : (await this.findAllCredentialIdsForProject(options.projectId)).map((c) => c.id); + + // the intersection of both is all credentials the user can use in this + // workflow or project + const intersection = allCredentials.filter((c) => allCredentialsForWorkflow.includes(c.id)); + + return intersection + .map((c) => this.roleService.addScopes(c, user, projectRelations)) + .map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + scopes: c.scopes, + })); + } + + async findAllCredentialIdsForWorkflow(workflowId: string): Promise { + // If the workflow is owned by a personal project and the owner of the + // project has global read permissions it can use all personal credentials. + const user = await this.userRepository.findPersonalOwnerForWorkflow(workflowId); + if (user?.hasGlobalScope('credential:read')) { + return await this.credentialsRepository.findAllPersonalCredentials(); + } + + // Otherwise the workflow can only use credentials from projects it's part + // of. + return await this.credentialsRepository.findAllCredentialsForWorkflow(workflowId); + } + + async findAllCredentialIdsForProject(projectId: string): Promise { + // If this is a personal project and the owner of the project has global + // read permissions then all workflows in that project can use all + // credentials of all personal projects. + const user = await this.userRepository.findPersonalOwnerForProject(projectId); + if (user?.hasGlobalScope('credential:read')) { + return await this.credentialsRepository.findAllPersonalCredentials(); + } + + // Otherwise only the credentials in this project can be used. + return await this.credentialsRepository.findAllCredentialsForProject(projectId); + } + /** * Retrieve the sharing that matches a user and a credential. */ diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index 5af221c81b54d..a5162af0157a9 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -1,12 +1,18 @@ import { Service } from 'typedi'; import { DataSource, In, Repository, Like } from '@n8n/typeorm'; -import type { FindManyOptions } from '@n8n/typeorm'; +import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; import { CredentialsEntity } from '../entities/CredentialsEntity'; import type { ListQuery } from '@/requests'; +import type { User } from '../entities/User'; +import type { Scope } from '@n8n/permissions'; +import { RoleService } from '@/services/role.service'; @Service() export class CredentialsRepository extends Repository { - constructor(dataSource: DataSource) { + constructor( + dataSource: DataSource, + readonly roleService: RoleService, + ) { super(CredentialsEntity, dataSource.manager); } @@ -82,4 +88,64 @@ export class CredentialsRepository extends Repository { return await this.find(findManyOptions); } + + /** + * Find all credentials that are owned by a personal project. + */ + async findAllPersonalCredentials(): Promise { + return await this.findBy({ shared: { project: { type: 'personal' } } }); + } + + /** + * Find all credentials that are part of any project that the workflow is + * part of. + * + * This is useful to for finding credentials that can be used in the + * workflow. + */ + async findAllCredentialsForWorkflow(workflowId: string): Promise { + return await this.findBy({ + shared: { project: { sharedWorkflows: { workflowId } } }, + }); + } + + /** + * Find all credentials that are part of that project. + * + * This is useful for finding credentials that can be used in workflows that + * are part of this project. + */ + async findAllCredentialsForProject(projectId: string): Promise { + return await this.findBy({ shared: { projectId } }); + } + + /** + * Find all credentials that the user has access to taking the scopes into + * account. + * + * This also returns `credentials.shared` which is useful for constructing + * all scopes the user has for the credential using `RoleService.addScopes`. + **/ + async findCredentialsForUser(user: User, scopes: Scope[]) { + let where: FindOptionsWhere = {}; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const credentialRoles = this.roleService.rolesWithScope('credential', scopes); + where = { + ...where, + shared: { + role: In(credentialRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }, + }; + } + + return await this.find({ where, relations: { shared: true } }); + } } diff --git a/packages/cli/src/databases/repositories/user.repository.ts b/packages/cli/src/databases/repositories/user.repository.ts index a934b0f05fdb2..d642df6445080 100644 --- a/packages/cli/src/databases/repositories/user.repository.ts +++ b/packages/cli/src/databases/repositories/user.repository.ts @@ -150,4 +150,36 @@ export class UserRepository extends Repository { // This is blocked by TypeORM having concurrency issues with transactions return await createInner(this.manager); } + + /** + * Find the user that owns the personal project that owns the workflow. + * + * Returns null if the workflow does not exist or is owned by a team project. + */ + async findPersonalOwnerForWorkflow(workflowId: string): Promise { + return await this.findOne({ + where: { + projectRelations: { + role: 'project:personalOwner', + project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } }, + }, + }, + }); + } + + /** + * Find the user that owns the personal project. + * + * Returns null if the project does not exist or is not a personal project. + */ + async findPersonalOwnerForProject(projectId: string): Promise { + return await this.findOne({ + where: { + projectRelations: { + role: 'project:personalOwner', + projectId, + }, + }, + }); + } } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index a73efe003121c..8363968815f2d 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -24,6 +24,7 @@ import type { WorkflowHistory } from '@db/entities/WorkflowHistory'; import type { Project, ProjectType } from '@db/entities/Project'; import type { ProjectRole } from './databases/entities/ProjectRelation'; import type { Scope } from '@n8n/permissions'; +import type { ScopesField } from './services/role.service'; export class UserUpdatePayload implements Pick { @Expose() @@ -125,8 +126,6 @@ export namespace ListQuery { type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null }; - type ScopesField = { scopes: Scope[] }; - export type Plain = BaseFields; export type WithSharing = BaseFields & SharedField; @@ -150,8 +149,6 @@ export namespace ListQuery { type SharedWithField = { sharedWithProjects: SlimProject[] }; - type ScopesField = { scopes: Scope[] }; - export type WithSharing = CredentialsEntity & SharedField; export type WithOwnedByAndSharedWith = CredentialsEntity & @@ -223,6 +220,13 @@ export declare namespace CredentialRequest { {}, { destinationProjectId: string } >; + + type ForWorkflow = AuthenticatedRequest< + {}, + {}, + {}, + { workflowId: string } | { projectId: string } + >; } // ---------------------------------- diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index 09841422e670e..292db6dfa32ec 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -27,6 +27,7 @@ import { combineScopes, type Resource, type Scope } from '@n8n/permissions'; import { Service } from 'typedi'; import { ApplicationError } from 'n8n-workflow'; import { License } from '@/License'; +import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; @@ -96,6 +97,8 @@ const ROLE_NAMES: Record< 'workflow:editor': 'Workflow Editor', }; +export type ScopesField = { scopes: Scope[] }; + @Service() export class RoleService { constructor(private readonly license: License) {} @@ -157,6 +160,11 @@ export class RoleService { user: User, userProjectRelations: ProjectRelation[], ): ListQuery.Workflow.WithScopes; + addScopes( + rawCredential: CredentialsEntity, + user: User, + userProjectRelations: ProjectRelation[], + ): CredentialsEntity & ScopesField; addScopes( rawCredential: | ListQuery.Credentials.WithSharing @@ -166,13 +174,17 @@ export class RoleService { ): ListQuery.Credentials.WithScopes; addScopes( rawEntity: + | CredentialsEntity | ListQuery.Workflow.WithSharing | ListQuery.Credentials.WithOwnedByAndSharedWith | ListQuery.Credentials.WithSharing | ListQuery.Workflow.WithOwnedByAndSharedWith, user: User, userProjectRelations: ProjectRelation[], - ): ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes { + ): + | (CredentialsEntity & ScopesField) + | ListQuery.Workflow.WithScopes + | ListQuery.Credentials.WithScopes { const shared = rawEntity.shared; const entity = rawEntity as ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes; diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 15db5def7eb6e..df8a562a76768 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -30,6 +30,7 @@ import { import type { SuperAgentTest } from '../shared/types'; import { mockInstance } from '../../shared/mocking'; import { createTeamProject, linkUserToProject } from '../shared/db/projects'; +import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows'; const testServer = utils.setupTestServer({ endpointGroups: ['credentials'], @@ -243,6 +244,256 @@ describe('GET /credentials', () => { }); }); +describe('GET /credentials/for-workflow', () => { + describe('for team projects', () => { + test.each([ + ['workflowId', 'member', () => member], + ['projectId', 'member', () => member], + ['workflowId', 'owner', () => owner], + ['projectId', 'owner', () => owner], + ])( + 'it will only return the credentials in that project if "%s" is used as the query parameter and the actor is a "%s"', + async (_, queryParam, actorGetter) => { + const actor = actorGetter(); + // Credential in personal project that should not be returned + await saveCredential(randomCredentialPayload(), { user: actor }); + + const teamProject = await createTeamProject(); + await linkUserToProject(actor, teamProject, 'project:viewer'); + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: teamProject, + }); + const savedWorkflow = await createWorkflow({}, teamProject); + + { + const response = await testServer + .authAgentFor(actor) + .get('/credentials/for-workflow') + .query( + queryParam === 'workflowId' + ? { workflowId: savedWorkflow.id } + : { projectId: teamProject.id }, + ); + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: savedCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + } + }, + ); + }); + + describe('for personal projects', () => { + test.each(['projectId', 'workflowId'])( + 'it returns only personal credentials for a members, if "%s" is used as the query parameter', + async (queryParam) => { + const savedWorkflow = await createWorkflow({}, member); + await shareWorkflowWithUsers(savedWorkflow, [anotherMember]); + + // should be returned respectively + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const anotherSavedCredential = await saveCredential(randomCredentialPayload(), { + user: anotherMember, + }); + + // should not be returned + await saveCredential(randomCredentialPayload(), { user: owner }); + const teamProject = await createTeamProject(); + await saveCredential(randomCredentialPayload(), { project: teamProject }); + + // member should only see their credential + { + const response = await testServer + .authAgentFor(member) + .get('/credentials/for-workflow') + .query( + queryParam === 'workflowId' + ? { workflowId: savedWorkflow.id } + : { projectId: memberPersonalProject.id }, + ); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: savedCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + } + + // another member should only see their credential + { + const response = await testServer + .authAgentFor(anotherMember) + .get('/credentials/for-workflow') + .query( + queryParam === 'workflowId' + ? { workflowId: savedWorkflow.id } + : { projectId: anotherMemberPersonalProject.id }, + ); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: anotherSavedCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + } + }, + ); + + test('if the actor is a global owner and the workflow has not been shared with them, it returns all credentials of all projects the workflow is part of', async () => { + const memberWorkflow = await createWorkflow({}, member); + await shareWorkflowWithUsers(memberWorkflow, [owner]); + + // should be returned + const memberCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const ownerCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + // should not be returned + await saveCredential(randomCredentialPayload(), { user: anotherMember }); + const teamProject = await createTeamProject(); + await saveCredential(randomCredentialPayload(), { project: teamProject }); + + const response = await testServer + .authAgentFor(owner) + .get('/credentials/for-workflow') + .query({ workflowId: memberWorkflow.id }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: memberCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: ownerCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + }); + + test('if the actor is a global owner and the workflow has been shared with them, it returns all credentials of all projects the workflow is part of', async () => { + const memberWorkflow = await createWorkflow({}, member); + await shareWorkflowWithUsers(memberWorkflow, [anotherMember]); + + // should be returned + const memberCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const anotherMemberCredential = await saveCredential(randomCredentialPayload(), { + user: anotherMember, + }); + + // should not be returned + await saveCredential(randomCredentialPayload(), { user: owner }); + const teamProject = await createTeamProject(); + await saveCredential(randomCredentialPayload(), { project: teamProject }); + + const response = await testServer + .authAgentFor(owner) + .get('/credentials/for-workflow') + .query({ workflowId: memberWorkflow.id }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: memberCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: anotherMemberCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + }); + + test('if the projectId is passed by a global owner it will return all credentials in that project', async () => { + // should be returned + const memberCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + // should not be returned + await saveCredential(randomCredentialPayload(), { user: anotherMember }); + await saveCredential(randomCredentialPayload(), { user: owner }); + const teamProject = await createTeamProject(); + await saveCredential(randomCredentialPayload(), { project: teamProject }); + + const response = await testServer + .authAgentFor(owner) + .get('/credentials/for-workflow') + .query({ projectId: memberPersonalProject.id }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: memberCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + }); + + test.each(['workflowId', 'projectId'] as const)( + 'if the global owner owns the workflow it will return all credentials of all personal projects, when using "%s" as the query parameter', + async (queryParam) => { + const ownerWorkflow = await createWorkflow({}, owner); + + // should be returned + const memberCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const anotherMemberCredential = await saveCredential(randomCredentialPayload(), { + user: anotherMember, + }); + const ownerCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + // should not be returned + const teamProject = await createTeamProject(); + await saveCredential(randomCredentialPayload(), { project: teamProject }); + + const response = await testServer + .authAgentFor(owner) + .get('/credentials/for-workflow') + .query( + queryParam === 'workflowId' + ? { workflowId: ownerWorkflow.id } + : { projectId: ownerPersonalProject.id }, + ); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(3); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: memberCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: anotherMemberCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + expect(response.body.data).toContainEqual( + expect.objectContaining({ + id: ownerCredential.id, + scopes: expect.arrayContaining(['credential:read']), + }), + ); + }, + ); + }); +}); + // ---------------------------------------- // GET /credentials/:id - fetch a certain credential // ---------------------------------------- diff --git a/packages/editor-ui/src/api/credentials.ts b/packages/editor-ui/src/api/credentials.ts index 5bc12bdb6e776..6ddb37c16ad44 100644 --- a/packages/editor-ui/src/api/credentials.ts +++ b/packages/editor-ui/src/api/credentials.ts @@ -36,6 +36,15 @@ export async function getAllCredentials( }); } +export async function getAllCredentialsForWorkflow( + context: IRestApiContext, + options: { workflowId: string } | { projectId: string }, +): Promise { + return await makeRestApiRequest(context, 'GET', '/credentials/for-workflow', { + ...options, + }); +} + export async function createNewCredential( context: IRestApiContext, data: ICredentialsDecrypted, diff --git a/packages/editor-ui/src/components/Projects/ProjectSettings.vue b/packages/editor-ui/src/components/Projects/ProjectSettings.vue index 42c99baca745d..9451436d90c5f 100644 --- a/packages/editor-ui/src/components/Projects/ProjectSettings.vue +++ b/packages/editor-ui/src/components/Projects/ProjectSettings.vue @@ -262,6 +262,7 @@ onBeforeMount(async () => { v-model="formData.name" type="text" name="name" + data-test-id="project-settings-name-input" @input="onNameInput" /> diff --git a/packages/editor-ui/src/stores/credentials.store.ts b/packages/editor-ui/src/stores/credentials.store.ts index 44454c0f343ad..a6c5ff8c7de21 100644 --- a/packages/editor-ui/src/stores/credentials.store.ts +++ b/packages/editor-ui/src/stores/credentials.store.ts @@ -11,6 +11,7 @@ import { createNewCredential, deleteCredential, getAllCredentials, + getAllCredentialsForWorkflow, getCredentialData, getCredentialsNewName, getCredentialTypes, @@ -262,6 +263,15 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, { this.setCredentials(credentials); return credentials; }, + async fetchAllCredentialsForWorkflow( + options: { workflowId: string } | { projectId: string }, + ): Promise { + const rootStore = useRootStore(); + + const credentials = await getAllCredentialsForWorkflow(rootStore.getRestApiContext, options); + this.setCredentials(credentials); + return credentials; + }, async getCredentialData({ id, }: { diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 9af97218bd7e8..52d2acf3ae4a7 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -398,7 +398,6 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useProjectsStore } from '@/stores/projects.store'; import type { ProjectSharingData } from '@/types/projects.types'; -import { ProjectTypes } from '@/types/projects.types'; import { useAIStore } from '@/stores/ai.store'; import { useStorage } from '@/composables/useStorage'; import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards'; @@ -4798,20 +4797,26 @@ export default defineComponent({ }, async loadCredentials(): Promise { const workflow = this.workflowsStore.getWorkflowById(this.currentWorkflow); - let projectId: string | undefined; + let options: { workflowId: string } | { projectId: string }; + if (workflow) { - projectId = - workflow.homeProject?.type === ProjectTypes.Personal - ? this.projectsStore.personalProject?.id - : workflow?.homeProject?.id ?? this.projectsStore.currentProjectId; + options = { workflowId: workflow.id }; } else { const queryParam = typeof this.$route.query?.projectId === 'string' ? this.$route.query?.projectId : undefined; - projectId = queryParam ?? this.projectsStore.personalProject?.id; + const projectId = queryParam ?? this.projectsStore.personalProject?.id; + + if (projectId === undefined) { + throw new Error( + 'Could not find projectId in the query nor could I find the personal project in the project store', + ); + } + + options = { projectId }; } - await this.credentialsStore.fetchAllCredentials(projectId, false); + await this.credentialsStore.fetchAllCredentialsForWorkflow(options); }, async loadVariables(): Promise { await this.environmentsStore.fetchAllVariables();