Skip to content

Commit

Permalink
fix: When editing nodes only show the credentials in the dropdown tha…
Browse files Browse the repository at this point in the history
…t the user is allowed to use in that workflow (#9718)
  • Loading branch information
despairblue authored Jun 14, 2024
1 parent 2dad9ce commit 2cf4364
Show file tree
Hide file tree
Showing 16 changed files with 682 additions and 22 deletions.
9 changes: 9 additions & 0 deletions cypress/composables/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
},
};
177 changes: 176 additions & 1 deletion cypress/e2e/17-sharing.cy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -8,6 +8,7 @@ import {
WorkflowsPage,
} from '../pages';
import { getVisibleSelect } from '../utils';
import * as projects from '../composables/projects';

/**
* User U1 - Instance owner
Expand Down Expand Up @@ -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);
});
});
4 changes: 2 additions & 2 deletions cypress/e2e/30-editor-after-route-changes.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down
15 changes: 12 additions & 3 deletions cypress/pages/modals/credentials-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
},
};
Expand Down
5 changes: 4 additions & 1 deletion cypress/support/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IN8nUISettings>): void;
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/credentials/credentials.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/credentials/credentials.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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(
Expand Down Expand Up @@ -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<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.
*/
Expand Down
Loading

0 comments on commit 2cf4364

Please sign in to comment.