mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
fix: Restrict updating/deleting of shared but not owned credentials (#7950)
## Summary Fix shared members being able to edit and delete credentials they don't own #### How to test the change: 1. ... ## Issues fixed Include links to Github issue or Community forum post or **Linear ticket**: > Important in order to close automatically and provide context to reviewers ... ## Review / Merge checklist - [x] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [x] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. A feature is not complete without tests. > > *(internal)* You can use Slack commands to trigger [e2e tests](https://www.notion.so/n8n/How-to-use-Test-Instances-d65f49dfc51f441ea44367fb6f67eb0a?pvs=4#a39f9e5ba64a48b58a71d81c837e8227) or [deploy test instance](https://www.notion.so/n8n/How-to-use-Test-Instances-d65f49dfc51f441ea44367fb6f67eb0a?pvs=4#f6a177d32bde4b57ae2da0b8e454bfce) or [deploy early access version on Cloud](https://www.notion.so/n8n/Cloudbot-3dbe779836004972b7057bc989526998?pvs=4#fef2d36ab02247e1a0f65a74f6fb534e).
This commit is contained in:
parent
3ba7deb337
commit
42e828d5c6
|
@ -15,6 +15,7 @@ import { InternalHooks } from '@/InternalHooks';
|
|||
import { listQueryMiddleware } from '@/middlewares';
|
||||
import { Logger } from '@/Logger';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
||||
|
||||
export const credentialsController = express.Router();
|
||||
credentialsController.use('/', EECredentialsController);
|
||||
|
@ -142,10 +143,15 @@ credentialsController.patch(
|
|||
ResponseHelper.send(async (req: CredentialRequest.Update): Promise<ICredentialsDb> => {
|
||||
const { id: credentialId } = req.params;
|
||||
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentialId, {
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:update',
|
||||
});
|
||||
const sharing = await CredentialsService.getSharing(
|
||||
req.user,
|
||||
credentialId,
|
||||
{
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:update',
|
||||
},
|
||||
['credentials', 'role'],
|
||||
);
|
||||
|
||||
if (!sharing) {
|
||||
Container.get(Logger).info(
|
||||
|
@ -160,6 +166,17 @@ credentialsController.patch(
|
|||
);
|
||||
}
|
||||
|
||||
if (sharing.role.name !== 'owner' && !(await req.user.hasGlobalScope('credential:update'))) {
|
||||
Container.get(Logger).info(
|
||||
'Attempt to update credential blocked due to lack of permissions',
|
||||
{
|
||||
credentialId,
|
||||
userId: req.user.id,
|
||||
},
|
||||
);
|
||||
throw new UnauthorizedError('You can only update credentials owned by you');
|
||||
}
|
||||
|
||||
const { credentials: credential } = sharing;
|
||||
|
||||
const decryptedData = CredentialsService.decrypt(credential);
|
||||
|
@ -195,10 +212,15 @@ credentialsController.delete(
|
|||
ResponseHelper.send(async (req: CredentialRequest.Delete) => {
|
||||
const { id: credentialId } = req.params;
|
||||
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentialId, {
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:delete',
|
||||
});
|
||||
const sharing = await CredentialsService.getSharing(
|
||||
req.user,
|
||||
credentialId,
|
||||
{
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:delete',
|
||||
},
|
||||
['credentials', 'role'],
|
||||
);
|
||||
|
||||
if (!sharing) {
|
||||
Container.get(Logger).info(
|
||||
|
@ -213,6 +235,17 @@ credentialsController.delete(
|
|||
);
|
||||
}
|
||||
|
||||
if (sharing.role.name !== 'owner' && !(await req.user.hasGlobalScope('credential:delete'))) {
|
||||
Container.get(Logger).info(
|
||||
'Attempt to delete credential blocked due to lack of permissions',
|
||||
{
|
||||
credentialId,
|
||||
userId: req.user.id,
|
||||
},
|
||||
);
|
||||
throw new UnauthorizedError('You can only remove credentials owned by you');
|
||||
}
|
||||
|
||||
const { credentials: credential } = sharing;
|
||||
|
||||
await CredentialsService.delete(credential);
|
||||
|
|
|
@ -4,7 +4,6 @@ export const ownerPermissions: Scope[] = [
|
|||
'auditLogs:manage',
|
||||
'credential:create',
|
||||
'credential:read',
|
||||
'credential:update',
|
||||
'credential:delete',
|
||||
'credential:list',
|
||||
'credential:share',
|
||||
|
|
|
@ -2,7 +2,7 @@ import type { SuperAgentTest } from 'supertest';
|
|||
import { In } from 'typeorm';
|
||||
import type { IUser } from 'n8n-workflow';
|
||||
|
||||
import type { Credentials } from '@/requests';
|
||||
import type { ListQuery } from '@/requests';
|
||||
import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper';
|
||||
import type { Role } from '@db/entities/Role';
|
||||
import type { User } from '@db/entities/User';
|
||||
|
@ -99,10 +99,10 @@ describe('GET /credentials', () => {
|
|||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data).toHaveLength(2); // owner retrieved owner cred and member cred
|
||||
const ownerCredential = response.body.data.find(
|
||||
(e: Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === owner.id,
|
||||
(e: ListQuery.Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === owner.id,
|
||||
);
|
||||
const memberCredential = response.body.data.find(
|
||||
(e: Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === member1.id,
|
||||
(e: ListQuery.Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === member1.id,
|
||||
);
|
||||
|
||||
validateMainCredentialData(ownerCredential);
|
||||
|
@ -540,7 +540,7 @@ describe('PUT /credentials/:id/share', () => {
|
|||
});
|
||||
});
|
||||
|
||||
function validateMainCredentialData(credential: Credentials.WithOwnedByAndSharedWith) {
|
||||
function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) {
|
||||
expect(typeof credential.name).toBe('string');
|
||||
expect(typeof credential.type).toBe('string');
|
||||
expect(typeof credential.nodesAccess[0].nodeType).toBe('string');
|
||||
|
|
|
@ -2,7 +2,7 @@ import type { SuperAgentTest } from 'supertest';
|
|||
|
||||
import config from '@/config';
|
||||
import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper';
|
||||
import type { Credentials } from '@/requests';
|
||||
import type { ListQuery } from '@/requests';
|
||||
import type { Role } from '@db/entities/Role';
|
||||
import type { User } from '@db/entities/User';
|
||||
|
||||
|
@ -10,7 +10,7 @@ import { randomCredentialPayload, randomName, randomString } from './shared/rand
|
|||
import * as testDb from './shared/testDb';
|
||||
import type { SaveCredentialFunction } from './shared/types';
|
||||
import * as utils from './shared/utils/';
|
||||
import { affixRoleToSaveCredential } from './shared/db/credentials';
|
||||
import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials';
|
||||
import { getCredentialOwnerRole, getGlobalMemberRole, getGlobalOwnerRole } from './shared/db/roles';
|
||||
import { createManyUsers, createUser } from './shared/db/users';
|
||||
import { CredentialsRepository } from '@db/repositories/credentials.repository';
|
||||
|
@ -25,6 +25,7 @@ let globalOwnerRole: Role;
|
|||
let globalMemberRole: Role;
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let secondMember: User;
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
let authMemberAgent: SuperAgentTest;
|
||||
let saveCredential: SaveCredentialFunction;
|
||||
|
@ -36,6 +37,7 @@ beforeAll(async () => {
|
|||
|
||||
owner = await createUser({ globalRole: globalOwnerRole });
|
||||
member = await createUser({ globalRole: globalMemberRole });
|
||||
secondMember = await createUser({ globalRole: globalMemberRole });
|
||||
|
||||
saveCredential = affixRoleToSaveCredential(credentialOwnerRole);
|
||||
|
||||
|
@ -63,7 +65,7 @@ describe('GET /credentials', () => {
|
|||
expect(response.body.data.length).toBe(2); // owner retrieved owner cred and member cred
|
||||
|
||||
const savedCredentialsIds = [savedOwnerCredentialId, savedMemberCredentialId];
|
||||
response.body.data.forEach((credential: Credentials.WithOwnedByAndSharedWith) => {
|
||||
response.body.data.forEach((credential: ListQuery.Credentials.WithOwnedByAndSharedWith) => {
|
||||
validateMainCredentialData(credential);
|
||||
expect('data' in credential).toBe(false);
|
||||
expect(savedCredentialsIds).toContain(credential.id);
|
||||
|
@ -225,6 +227,26 @@ describe('DELETE /credentials/:id', () => {
|
|||
expect(deletedSharedCredential).toBeDefined(); // not deleted
|
||||
});
|
||||
|
||||
test('should not delete non-owned but shared cred for member', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: secondMember });
|
||||
|
||||
await shareCredentialWithUsers(savedCredential, [member]);
|
||||
|
||||
const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
|
||||
const shellCredential = await Container.get(CredentialsRepository).findOneBy({
|
||||
id: savedCredential.id,
|
||||
});
|
||||
|
||||
expect(shellCredential).toBeDefined(); // not deleted
|
||||
|
||||
const deletedSharedCredential = await Container.get(SharedCredentialsRepository).findOneBy({});
|
||||
|
||||
expect(deletedSharedCredential).toBeDefined(); // not deleted
|
||||
});
|
||||
|
||||
test('should fail if cred not found', async () => {
|
||||
const response = await authOwnerAgent.delete('/credentials/123');
|
||||
|
||||
|
@ -269,7 +291,7 @@ describe('PATCH /credentials/:id', () => {
|
|||
expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated
|
||||
});
|
||||
|
||||
test('should update non-owned cred for owner', async () => {
|
||||
test('should not update non-owned cred for owner', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member });
|
||||
const patchPayload = randomCredentialPayload();
|
||||
|
||||
|
@ -277,25 +299,14 @@ describe('PATCH /credentials/:id', () => {
|
|||
.patch(`/credentials/${savedCredential.id}`)
|
||||
.send(patchPayload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.statusCode).toBe(404);
|
||||
|
||||
const { id, name, type, nodesAccess, data: encryptedData } = response.body.data;
|
||||
const credential = await Container.get(CredentialsRepository).findOneByOrFail({
|
||||
id: savedCredential.id,
|
||||
});
|
||||
|
||||
expect(name).toBe(patchPayload.name);
|
||||
expect(type).toBe(patchPayload.type);
|
||||
|
||||
if (!patchPayload.nodesAccess) {
|
||||
fail('Payload did not contain a nodesAccess array');
|
||||
}
|
||||
expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType);
|
||||
|
||||
expect(encryptedData).not.toBe(patchPayload.data);
|
||||
|
||||
const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id });
|
||||
|
||||
expect(credential.name).toBe(patchPayload.name);
|
||||
expect(credential.type).toBe(patchPayload.type);
|
||||
expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType);
|
||||
expect(credential.name).not.toBe(patchPayload.name);
|
||||
expect(credential.type).not.toBe(patchPayload.type);
|
||||
expect(credential.data).not.toBe(patchPayload.data);
|
||||
|
||||
const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({
|
||||
|
@ -303,7 +314,7 @@ describe('PATCH /credentials/:id', () => {
|
|||
where: { credentialsId: credential.id },
|
||||
});
|
||||
|
||||
expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated
|
||||
expect(sharedCredential.credentials.name).not.toBe(patchPayload.name); // updated
|
||||
});
|
||||
|
||||
test('should update owned cred for member', async () => {
|
||||
|
@ -360,6 +371,42 @@ describe('PATCH /credentials/:id', () => {
|
|||
expect(shellCredential.name).not.toBe(patchPayload.name); // not updated
|
||||
});
|
||||
|
||||
test('should not update non-owned but shared cred for member', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: secondMember });
|
||||
await shareCredentialWithUsers(savedCredential, [member]);
|
||||
const patchPayload = randomCredentialPayload();
|
||||
|
||||
const response = await authMemberAgent
|
||||
.patch(`/credentials/${savedCredential.id}`)
|
||||
.send(patchPayload);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
|
||||
const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({
|
||||
id: savedCredential.id,
|
||||
});
|
||||
|
||||
expect(shellCredential.name).not.toBe(patchPayload.name); // not updated
|
||||
});
|
||||
|
||||
test('should not update non-owned but shared cred for instance owner', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: secondMember });
|
||||
await shareCredentialWithUsers(savedCredential, [owner]);
|
||||
const patchPayload = randomCredentialPayload();
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.patch(`/credentials/${savedCredential.id}`)
|
||||
.send(patchPayload);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
|
||||
const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({
|
||||
id: savedCredential.id,
|
||||
});
|
||||
|
||||
expect(shellCredential.name).not.toBe(patchPayload.name); // not updated
|
||||
});
|
||||
|
||||
test('should fail with invalid inputs', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||
|
||||
|
@ -497,7 +544,7 @@ describe('GET /credentials/:id', () => {
|
|||
});
|
||||
});
|
||||
|
||||
function validateMainCredentialData(credential: Credentials.WithOwnedByAndSharedWith) {
|
||||
function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) {
|
||||
const { name, type, nodesAccess, sharedWith, ownedBy } = credential;
|
||||
|
||||
expect(typeof name).toBe('string');
|
||||
|
|
Loading…
Reference in a new issue