mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 21:07:28 -08:00
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:
parent
2dad9ce44c
commit
2cf4364ee0
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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<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.
|
||||
*/
|
||||
|
|
|
@ -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<CredentialsEntity> {
|
||||
constructor(dataSource: DataSource) {
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
readonly roleService: RoleService,
|
||||
) {
|
||||
super(CredentialsEntity, dataSource.manager);
|
||||
}
|
||||
|
||||
|
@ -82,4 +88,64 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
|
|||
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -150,4 +150,36 @@ export class UserRepository extends Repository<User> {
|
|||
// 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<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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<User, 'email' | 'firstName' | 'lastName'> {
|
||||
@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 }
|
||||
>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
// ----------------------------------------
|
||||
|
|
|
@ -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(
|
||||
context: IRestApiContext,
|
||||
data: ICredentialsDecrypted,
|
||||
|
|
|
@ -262,6 +262,7 @@ onBeforeMount(async () => {
|
|||
v-model="formData.name"
|
||||
type="text"
|
||||
name="name"
|
||||
data-test-id="project-settings-name-input"
|
||||
@input="onNameInput"
|
||||
/>
|
||||
</fieldset>
|
||||
|
|
|
@ -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<ICredentialsResponse[]> {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const credentials = await getAllCredentialsForWorkflow(rootStore.getRestApiContext, options);
|
||||
this.setCredentials(credentials);
|
||||
return credentials;
|
||||
},
|
||||
async getCredentialData({
|
||||
id,
|
||||
}: {
|
||||
|
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await this.environmentsStore.fetchAllVariables();
|
||||
|
|
Loading…
Reference in a new issue