fix: When editing nodes only show the credentials in the dropdown that the user is allowed to use in that workflow (#9718)

This commit is contained in:
Danny Martini 2024-06-14 14:48:49 +02:00 committed by GitHub
parent 2dad9ce44c
commit 2cf4364ee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 682 additions and 22 deletions

View file

@ -10,6 +10,7 @@ export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]'); 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 getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button');
export const getProjectSettingsCancelButton = () => export const getProjectSettingsCancelButton = () =>
cy.getByTestId('project-settings-cancel-button'); cy.getByTestId('project-settings-cancel-button');
@ -55,3 +56,11 @@ export function createCredential(name: string) {
credentialsModal.actions.save(); credentialsModal.actions.save();
credentialsModal.actions.close(); credentialsModal.actions.close();
} }
export const actions = {
createProject: (name: string) => {
getAddProjectButton().click();
getProjectSettingsNameInput().type(name);
getProjectSettingsSaveButton().click();
},
};

View file

@ -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 { import {
CredentialsModal, CredentialsModal,
CredentialsPage, CredentialsPage,
@ -8,6 +8,7 @@ import {
WorkflowsPage, WorkflowsPage,
} from '../pages'; } from '../pages';
import { getVisibleSelect } from '../utils'; import { getVisibleSelect } from '../utils';
import * as projects from '../composables/projects';
/** /**
* User U1 - Instance owner * User U1 - Instance owner
@ -188,3 +189,177 @@ describe('Sharing', { disableAutoLogin: true }, () => {
credentialsModal.actions.close(); 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);
});
});

View file

@ -102,7 +102,7 @@ const switchBetweenEditorAndHistory = () => {
const switchBetweenEditorAndWorkflowlist = () => { const switchBetweenEditorAndWorkflowlist = () => {
cy.getByTestId('menu-item').first().click(); 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(); 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/users').as('getUsers');
cy.intercept('GET', '/rest/workflows?*').as('getWorkflows'); cy.intercept('GET', '/rest/workflows?*').as('getWorkflows');
cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows'); cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows');
cy.intercept('GET', '/rest/credentials?*').as('getCredentials'); cy.intercept('GET', '/rest/projects').as('getProjects');
switchBetweenEditorAndHistory(); switchBetweenEditorAndHistory();
zoomInAndCheckNodes(); zoomInAndCheckNodes();

View file

@ -55,7 +55,7 @@ export class CredentialsModal extends BasePage {
close: () => { close: () => {
this.getters.closeButton().click(); this.getters.closeButton().click();
}, },
fillCredentialsForm: () => { fillCredentialsForm: (closeModal = true) => {
this.getters.credentialsEditModal().should('be.visible'); this.getters.credentialsEditModal().should('be.visible');
this.getters.credentialInputs().should('have.length.greaterThan', 0); this.getters.credentialInputs().should('have.length.greaterThan', 0);
this.getters this.getters
@ -65,14 +65,23 @@ export class CredentialsModal extends BasePage {
cy.wrap($el).type('test'); cy.wrap($el).type('test');
}); });
this.getters.saveButton().click(); 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) => { renameCredential: (newName: string) => {
this.getters.nameInput().type('{selectall}'); this.getters.nameInput().type('{selectall}');
this.getters.nameInput().type(newName); this.getters.nameInput().type(newName);
this.getters.nameInput().type('{enter}'); this.getters.nameInput().type('{enter}');
}, },
changeTab: (tabName: string) => { changeTab: (tabName: 'Sharing') => {
this.getters.menuItem(tabName).click(); this.getters.menuItem(tabName).click();
}, },
}; };

View file

@ -32,10 +32,13 @@ declare global {
* @param [workflowName] Optional name for the workflow. A random nanoid is used if not given * @param [workflowName] Optional name for the workflow. A random nanoid is used if not given
*/ */
createFixtureWorkflow(fixtureKey: string, workflowName?: string): void; createFixtureWorkflow(fixtureKey: string, workflowName?: string): void;
/** @deprecated */ /** @deprecated use signinAsOwner, signinAsAdmin or signinAsMember instead */
signin(payload: SigninPayload): void; signin(payload: SigninPayload): void;
signinAsOwner(): void; signinAsOwner(): void;
signinAsAdmin(): void; signinAsAdmin(): void;
/**
* Omitting the index will default to index 0.
*/
signinAsMember(index?: number): void; signinAsMember(index?: number): void;
signout(): void; signout(): void;
overrideSettings(value: Partial<IN8nUISettings>): void; overrideSettings(value: Partial<IN8nUISettings>): void;

View file

@ -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') @Get('/new')
async generateUniqueName(req: CredentialRequest.NewName) { async generateUniqueName(req: CredentialRequest.NewName) {
const requestedName = req.query.name ?? config.getEnv('credentials.defaultName'); const requestedName = req.query.name ?? config.getEnv('credentials.defaultName');

View file

@ -36,6 +36,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { ProjectRelation } from '@/databases/entities/ProjectRelation'; import type { ProjectRelation } from '@/databases/entities/ProjectRelation';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import { UserRepository } from '@/databases/repositories/user.repository';
export type CredentialsGetSharedOptions = export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: true; globalScope: Scope }
@ -54,6 +55,7 @@ export class CredentialsService {
private readonly projectRepository: ProjectRepository, private readonly projectRepository: ProjectRepository,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
private readonly userRepository: UserRepository,
) {} ) {}
async getMany( async getMany(
@ -145,6 +147,70 @@ export class CredentialsService {
return credentials; 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<CredentialsEntity[]> {
// 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<CredentialsEntity[]> {
// 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. * Retrieve the sharing that matches a user and a credential.
*/ */

View file

@ -1,12 +1,18 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import { DataSource, In, Repository, Like } from '@n8n/typeorm'; 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 { CredentialsEntity } from '../entities/CredentialsEntity';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import type { User } from '../entities/User';
import type { Scope } from '@n8n/permissions';
import { RoleService } from '@/services/role.service';
@Service() @Service()
export class CredentialsRepository extends Repository<CredentialsEntity> { export class CredentialsRepository extends Repository<CredentialsEntity> {
constructor(dataSource: DataSource) { constructor(
dataSource: DataSource,
readonly roleService: RoleService,
) {
super(CredentialsEntity, dataSource.manager); super(CredentialsEntity, dataSource.manager);
} }
@ -82,4 +88,64 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
return await this.find(findManyOptions); return await this.find(findManyOptions);
} }
/**
* Find all credentials that are owned by a personal project.
*/
async findAllPersonalCredentials(): Promise<CredentialsEntity[]> {
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<CredentialsEntity[]> {
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<CredentialsEntity[]> {
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<CredentialsEntity> = {};
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 } });
}
} }

View file

@ -150,4 +150,36 @@ export class UserRepository extends Repository<User> {
// This is blocked by TypeORM having concurrency issues with transactions // This is blocked by TypeORM having concurrency issues with transactions
return await createInner(this.manager); 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<User | null> {
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<User | null> {
return await this.findOne({
where: {
projectRelations: {
role: 'project:personalOwner',
projectId,
},
},
});
}
} }

View file

@ -24,6 +24,7 @@ import type { WorkflowHistory } from '@db/entities/WorkflowHistory';
import type { Project, ProjectType } from '@db/entities/Project'; import type { Project, ProjectType } from '@db/entities/Project';
import type { ProjectRole } from './databases/entities/ProjectRelation'; import type { ProjectRole } from './databases/entities/ProjectRelation';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import type { ScopesField } from './services/role.service';
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> { export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
@Expose() @Expose()
@ -125,8 +126,6 @@ export namespace ListQuery {
type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null }; type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null };
type ScopesField = { scopes: Scope[] };
export type Plain = BaseFields; export type Plain = BaseFields;
export type WithSharing = BaseFields & SharedField; export type WithSharing = BaseFields & SharedField;
@ -150,8 +149,6 @@ export namespace ListQuery {
type SharedWithField = { sharedWithProjects: SlimProject[] }; type SharedWithField = { sharedWithProjects: SlimProject[] };
type ScopesField = { scopes: Scope[] };
export type WithSharing = CredentialsEntity & SharedField; export type WithSharing = CredentialsEntity & SharedField;
export type WithOwnedByAndSharedWith = CredentialsEntity & export type WithOwnedByAndSharedWith = CredentialsEntity &
@ -223,6 +220,13 @@ export declare namespace CredentialRequest {
{}, {},
{ destinationProjectId: string } { destinationProjectId: string }
>; >;
type ForWorkflow = AuthenticatedRequest<
{},
{},
{},
{ workflowId: string } | { projectId: string }
>;
} }
// ---------------------------------- // ----------------------------------

View file

@ -27,6 +27,7 @@ import { combineScopes, type Resource, type Scope } from '@n8n/permissions';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { ApplicationError } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow';
import { License } from '@/License'; import { License } from '@/License';
import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow';
@ -96,6 +97,8 @@ const ROLE_NAMES: Record<
'workflow:editor': 'Workflow Editor', 'workflow:editor': 'Workflow Editor',
}; };
export type ScopesField = { scopes: Scope[] };
@Service() @Service()
export class RoleService { export class RoleService {
constructor(private readonly license: License) {} constructor(private readonly license: License) {}
@ -157,6 +160,11 @@ export class RoleService {
user: User, user: User,
userProjectRelations: ProjectRelation[], userProjectRelations: ProjectRelation[],
): ListQuery.Workflow.WithScopes; ): ListQuery.Workflow.WithScopes;
addScopes(
rawCredential: CredentialsEntity,
user: User,
userProjectRelations: ProjectRelation[],
): CredentialsEntity & ScopesField;
addScopes( addScopes(
rawCredential: rawCredential:
| ListQuery.Credentials.WithSharing | ListQuery.Credentials.WithSharing
@ -166,13 +174,17 @@ export class RoleService {
): ListQuery.Credentials.WithScopes; ): ListQuery.Credentials.WithScopes;
addScopes( addScopes(
rawEntity: rawEntity:
| CredentialsEntity
| ListQuery.Workflow.WithSharing | ListQuery.Workflow.WithSharing
| ListQuery.Credentials.WithOwnedByAndSharedWith | ListQuery.Credentials.WithOwnedByAndSharedWith
| ListQuery.Credentials.WithSharing | ListQuery.Credentials.WithSharing
| ListQuery.Workflow.WithOwnedByAndSharedWith, | ListQuery.Workflow.WithOwnedByAndSharedWith,
user: User, user: User,
userProjectRelations: ProjectRelation[], userProjectRelations: ProjectRelation[],
): ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes { ):
| (CredentialsEntity & ScopesField)
| ListQuery.Workflow.WithScopes
| ListQuery.Credentials.WithScopes {
const shared = rawEntity.shared; const shared = rawEntity.shared;
const entity = rawEntity as ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes; const entity = rawEntity as ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes;

View file

@ -30,6 +30,7 @@ import {
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { createTeamProject, linkUserToProject } from '../shared/db/projects'; import { createTeamProject, linkUserToProject } from '../shared/db/projects';
import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows';
const testServer = utils.setupTestServer({ const testServer = utils.setupTestServer({
endpointGroups: ['credentials'], 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 // GET /credentials/:id - fetch a certain credential
// ---------------------------------------- // ----------------------------------------

View file

@ -36,6 +36,15 @@ export async function getAllCredentials(
}); });
} }
export async function getAllCredentialsForWorkflow(
context: IRestApiContext,
options: { workflowId: string } | { projectId: string },
): Promise<ICredentialsResponse[]> {
return await makeRestApiRequest(context, 'GET', '/credentials/for-workflow', {
...options,
});
}
export async function createNewCredential( export async function createNewCredential(
context: IRestApiContext, context: IRestApiContext,
data: ICredentialsDecrypted, data: ICredentialsDecrypted,

View file

@ -262,6 +262,7 @@ onBeforeMount(async () => {
v-model="formData.name" v-model="formData.name"
type="text" type="text"
name="name" name="name"
data-test-id="project-settings-name-input"
@input="onNameInput" @input="onNameInput"
/> />
</fieldset> </fieldset>

View file

@ -11,6 +11,7 @@ import {
createNewCredential, createNewCredential,
deleteCredential, deleteCredential,
getAllCredentials, getAllCredentials,
getAllCredentialsForWorkflow,
getCredentialData, getCredentialData,
getCredentialsNewName, getCredentialsNewName,
getCredentialTypes, getCredentialTypes,
@ -262,6 +263,15 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
this.setCredentials(credentials); this.setCredentials(credentials);
return credentials; return credentials;
}, },
async fetchAllCredentialsForWorkflow(
options: { workflowId: string } | { projectId: string },
): Promise<ICredentialsResponse[]> {
const rootStore = useRootStore();
const credentials = await getAllCredentialsForWorkflow(rootStore.getRestApiContext, options);
this.setCredentials(credentials);
return credentials;
},
async getCredentialData({ async getCredentialData({
id, id,
}: { }: {

View file

@ -398,7 +398,6 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectSharingData } from '@/types/projects.types'; import type { ProjectSharingData } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { useAIStore } from '@/stores/ai.store'; import { useAIStore } from '@/stores/ai.store';
import { useStorage } from '@/composables/useStorage'; import { useStorage } from '@/composables/useStorage';
import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards'; import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards';
@ -4798,20 +4797,26 @@ export default defineComponent({
}, },
async loadCredentials(): Promise<void> { async loadCredentials(): Promise<void> {
const workflow = this.workflowsStore.getWorkflowById(this.currentWorkflow); const workflow = this.workflowsStore.getWorkflowById(this.currentWorkflow);
let projectId: string | undefined; let options: { workflowId: string } | { projectId: string };
if (workflow) { if (workflow) {
projectId = options = { workflowId: workflow.id };
workflow.homeProject?.type === ProjectTypes.Personal
? this.projectsStore.personalProject?.id
: workflow?.homeProject?.id ?? this.projectsStore.currentProjectId;
} else { } else {
const queryParam = const queryParam =
typeof this.$route.query?.projectId === 'string' typeof this.$route.query?.projectId === 'string'
? this.$route.query?.projectId ? this.$route.query?.projectId
: undefined; : 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<void> { async loadVariables(): Promise<void> {
await this.environmentsStore.fetchAllVariables(); await this.environmentsStore.fetchAllVariables();